Merge remote-tracking branch 'origin/master' into feat/profile-plugin-management

# Conflicts:
#	apps/cli/src/headless.ts
#	docs/event-producer-consumer.md
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
Turtle
2026-08-06 20:03:22 +08:00
695 changed files with 22129 additions and 4237 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 `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares 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

@@ -15,7 +15,7 @@ export type {
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'

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. */
@@ -2420,6 +2502,12 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
],
}),
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
// The fixture endpoint is imaginary, so the interrogation answers the
// catalog it already serves — enough for a surface to exercise adopting
// candidates without a reachable provider.
discoverModels: request => ok(request, {
models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))),
}),
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Same routing discipline as the host: rpcId first, then the payload's
@@ -2537,6 +2625,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'credentials.unset': return this.api.credentials.unset(request)
case 'llm.providers': return this.api.llm.providers(request)
case 'llm.models': return this.api.llm.models(request)
case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal)
}
}

View File

@@ -25,7 +25,7 @@ export type {
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
} from './api.ts'
export {
RpcId,

View File

@@ -44,10 +44,15 @@ export const Config: z<ConnectionConfig> = z.object({
* reconnaissance no anonymous caller should have. `trustedHosts` is a
* DNS-rebinding fence, explicitly not authentication, so the whole
* configuration plane stays loopback-same-origin until a real authentication
* layer exists. The model catalog (`llm.providers`, `llm.models`) is
* deliberately NOT here: it carries provider ids, display names, and model
* lists — no endpoints, keys, or key state — and a LAN client's model picker
* legitimately needs it.
* layer exists. `llm.discoverModels` belongs to that plane on both counts: it
* carries a draft credential, and it makes the HOST issue a GET to a URL the
* caller chose and reports back the status or the parsed body — an anonymous
* LAN caller would have a probe for whatever the host can reach and the
* browser cannot.
*
* The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here:
* it carries provider ids, display names, and model lists — no endpoints,
* keys, or key state — and a LAN client's model picker legitimately needs it.
*/
const PRIVILEGED_METHODS = new Set([
'host.pickDirectory',
@@ -60,6 +65,7 @@ const PRIVILEGED_METHODS = new Set([
'credentials.describe',
'credentials.set',
'credentials.unset',
'llm.discoverModels',
])
/**

View File

@@ -197,6 +197,7 @@ export class FakeApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

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

@@ -129,13 +129,15 @@ describe('connection node half', () => {
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
// The privileged set: native dialogs plus the whole settings/credential
// configuration plane, reads included. The same declared authority reaches
// configuration plane, reads included, plus the one method that makes the
// host fetch a caller-chosen URL. The same declared authority reaches
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'llm.discoverModels',
]) {
const denied = fakeResponse()
await routes[0]!.handler(
@@ -221,6 +223,9 @@ describe('connection node half over a real HTTP server', () => {
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
// Carries a draft credential and turns the host into a fetcher for a
// URL the caller picked: an anonymous LAN caller must not reach it.
'llm.discoverModels',
]) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
}

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: 8ac29a4258bbd7456b20c61e547d48c570e84d27
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d

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.
@@ -28,7 +34,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## The human transcript
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.

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 列表顺序。
@@ -28,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 面向人的 transcript文本记录
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩compaction检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上每次落地的压缩compaction检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
@@ -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

@@ -48,11 +48,14 @@ export type {
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
ConversationContext, ConversationContextOriginKind,
} from './sessions/conversation-context.ts'
export type {
ContextProvenanceView, ContextRole, KnownContextForm,
} from './sessions/context-provenance.ts'
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './sessions/request-inspection.ts'

View File

@@ -11,11 +11,15 @@ import type {
PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts'
import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
import { SteeringHistory } from '../sessions/steering-history.ts'
import type {
ConversationContext, ConversationContextOriginKind,
} 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 +34,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 +44,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 +59,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>()
@@ -147,6 +128,7 @@ function materializeNode(
resultView: ToolResultView | null,
assistantTiming: AssistantTiming | undefined,
requestConfig: AssistantRequestConfig | undefined,
steering: boolean,
): ConversationNode {
switch (event.type) {
case 'user/message':
@@ -154,6 +136,15 @@ function materializeNode(
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
if (steering) {
return {
kind: 'steering', messageId: event.data.id,
seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
@@ -353,6 +344,11 @@ export function projectConversationHistory(
entries: readonly HistoryEntry[],
): ConversationHistoryProjection {
const events = entries.map(entry => entry.event)
const steeringHistory = new SteeringHistory()
const steeringSeqs = new Set<number>()
for (const event of events) {
if (steeringHistory.apply(event)) steeringSeqs.add(event.seq)
}
const baseSeq = events[0]?.seq ?? 0
const eventsBySeq = new Map(events.map(event => [event.seq, event]))
const callIndex = new Map<string, CallIndexEntry>()
@@ -381,6 +377,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 +386,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)
@@ -432,6 +409,7 @@ export function projectConversationHistory(
resultViews.get(seq) ?? null,
assistantTimings.get(seq),
assistantRequestConfigs.get(seq),
steeringSeqs.has(seq),
)
nodeCache.set(seq, node)
return node

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

@@ -0,0 +1,116 @@
// Context provenance projection: the role and the human-facing producer name
// of one logged non-user `user/message`, read from its durable `source` alone.
// The client keeps no table of known plugin ids — a renamed or newly mounted
// producer must never need a client release to stay identifiable, and a resumed
// or foreign log must project the same way as a live one.
/**
* Which model-facing role a logged non-user message plays.
*
* `recall` marks material lifted out of another session's log; `inject` marks
* every other producer-supplied context. Mid-turn steering is the third role
* the transcript distinguishes, but it has its own event and node kind
* (`steering/message` / `SteeringMessageNode`) and never reaches here.
*/
export type ContextRole = 'inject' | 'recall'
/** Role and producer name presented for one logged non-user message. */
export interface ContextProvenanceView {
/** The role this context plays in the model-facing conversation. */
role: ContextRole
/**
* Producer name for the row header, taken from the durable source: the
* instruction paths, the referenced session titles, the plugin id, or the
* bare source kind for a producer this UI version does not know. Null only
* when the source carries no readable kind at all.
*/
label: string | null
}
/** One durable source narrowed to the readable-record shape; null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/** A record field read as a non-empty string, or null. */
function readString(record: Record<string, unknown>, key: string): string | null {
const value = record[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */
function collect(source: Record<string, unknown>, member: string, field: string): string[] {
const list = source[member]
if (!Array.isArray(list)) return []
const seen: string[] = []
for (const entry of list) {
const record = asRecord(entry)
const value = record === null ? null : readString(record, field)
if (value !== null && !seen.includes(value)) seen.push(value)
}
return seen
}
/** A collected name list rendered as one label; null when the list is empty. */
function joined(names: string[]): string | null {
return names.length > 0 ? names.join(', ') : null
}
/**
* Project one durable message source onto its transcript role and producer name.
*
* The source arrives over the wire as opaque JSON (`MessageSource` is
* merge-extensible, so no client-side union can be exhaustive), and a durable
* log may predate or postdate this UI; every unreadable shape therefore
* degrades to `inject` with whatever name the record still carries.
* @param source - the logged `user/message` source, exactly as recorded.
* @returns the role and producer name to present for this context.
*/
export function contextProvenance(source: unknown): ContextProvenanceView {
const record = asRecord(source)
const kind = record === null ? null : readString(record, 'kind')
if (record === null || kind === null) return { role: 'inject', label: null }
switch (kind) {
// Cross-session snapshots are the one durable source that carries another
// session's material; its references name the sessions they were read from.
case 'session-reference':
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
// Workspace instructions name the files they were reconciled from, which
// identifies the producer far better than the plugin id would.
case 'workspace-instructions':
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
case 'plugin':
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
// Documented default arm of the merge-extensible source map: an unknown
// producer still identifies itself by its own durable kind.
default:
return { role: 'inject', label: kind }
}
}
/**
* Context forms this UI version renders with a dedicated presentation. The
* durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an
* unrecognized or absent value degrades to the opaque presentation rather than
* dropping the row, so a log written by a newer or foreign producer still
* renders.
*/
const KNOWN_FORMS = ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'] as const
/** One durable context form this UI version knows how to present. */
export type KnownContextForm = typeof KNOWN_FORMS[number]
/**
* Read the producer-declared form off one durable message source.
* @param source - the logged `user/message` source, exactly as recorded.
* @returns the form when this UI version presents it, otherwise null (opaque).
*/
export function contextForm(source: unknown): KnownContextForm | null {
const record = asRecord(source)
const form = record === null ? null : readString(record, 'form')
return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
? form as KnownContextForm
: null
}

View File

@@ -12,6 +12,7 @@ import type {
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
@@ -102,6 +103,18 @@ export interface AssistantMessageNode {
interrupted?: true
}
/** A human message admitted from the next-step inbox while a turn was running. */
export interface SteeringMessageNode {
kind: 'steering'
/** Stable message identity shared with its pre-admission inbox occurrence. */
messageId: MessageId
seq: number
/** Unix epoch ms from the source session event. */
time: number
content: readonly ContentBlock[]
source: unknown
}
/** A context/system injection surfaced in the flow. */
export interface ContextMessageNode {
kind: 'context'
@@ -110,6 +123,10 @@ export interface ContextMessageNode {
time: number
content: readonly ContentBlock[]
source: unknown
/** Role and producer name projected from `source` ({@link contextProvenance}). */
provenance: ContextProvenanceView
/** Producer-declared information form ({@link contextForm}); null presents as opaque. */
form: KnownContextForm | null
}
/** Durable notice that a closed failed step is waiting for a model-request retry. */
@@ -223,6 +240,7 @@ export interface CommandNode {
export type ConversationNode =
| UserMessageNode
| AssistantMessageNode
| SteeringMessageNode
| ContextMessageNode
| ModelRetryNode
| TurnErrorNode

View File

@@ -29,6 +29,8 @@ export interface SessionListEntry {
projectionValues?: Readonly<Partial<SessionProjectionMap>>
/** User interaction currently blocking this session, derived from live mux frames. */
pendingInteraction?: PendingInteractionStatus
/** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */
completed: boolean
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
@@ -39,11 +41,13 @@ export interface SessionListEntry {
* hydrated list from mutable timestamps.
* @param summaries - the host's session.list items.
* @param pendingInteractions - current manager-owned interaction status by session.
* @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false).
* @returns display rows in render order.
*/
export function flattenLineage(
summaries: readonly TitledSessionSummary[],
pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
completed?: ReadonlySet<SessionId>,
): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
@@ -72,6 +76,7 @@ export function flattenLineage(
out.push({
...s,
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
completed: completed?.has(s.sessionId) ?? false,
depth,
})
const kids = children.get(s.sessionId)

View File

@@ -109,6 +109,14 @@ export class SessionManager {
* sessions never instantiated. Cleared per connection generation — the reopen replay re-adds
* still-pending requests — and on session-removed. */
private readonly pendingInteractions = new Map<SessionId, Map<string, PendingInteractionStatus>>()
/**
* Sessions that finished running while not selected — the sidebar's green
* "done" reminder (manager-owned, survives connection generations; cleared
* on select and session-removed, re-armed by the next completion).
*/
private readonly completedNotifications = new Set<SessionId>()
/** Last-observed running bits per session; the true→false edge here arms {@link completedNotifications}. */
private readonly prevRunning = new Map<SessionId, boolean>()
/** Per-session projection value stores, retained independently of instance arrival (the
* title-snapshot precedent, generalized): push frames land here whether or not the Session
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
@@ -175,6 +183,8 @@ export class SessionManager {
: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
)
this.selected = sessionId
// Looking at the session consumes its completion reminder (dot clears).
this.completedNotifications.delete(sessionId)
void this.refreshSubagents(sessionId)
this.notifier.notifyNow()
}
@@ -192,6 +202,7 @@ export class SessionManager {
this.addresses.set(address.childSessionId, address)
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
this.selected = address.childSessionId
this.completedNotifications.delete(address.childSessionId)
void this.refreshSubagents(address.childSessionId)
this.notifier.notifyNow()
}
@@ -414,13 +425,28 @@ export class SessionManager {
try {
const { result } = await this.api.sessions.list({})
if (result.ok) {
let summaries = this.listPhase === 'pending'
const baseline = this.listPhase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
for (const mutation of mutations) summaries = applyMutation(summaries, mutation)
// Seed first observations from the pull-time baseline BEFORE replaying
// in-flight mutations, then reconcile the reminders after EVERY
// replayed mutation: an edge that happens entirely between mutations
// (baseline idle → running → idle) must still arm, which a single
// sync on the folded result would collapse away.
for (const s of baseline) {
if (!this.prevRunning.has(s.sessionId)) this.prevRunning.set(s.sessionId, s.running)
}
let summaries = baseline
for (const mutation of mutations) {
summaries = applyMutation(summaries, mutation)
this.summaries = summaries
this.syncCompletedNotifications()
}
this.summaries = summaries
this.listState = 'idle'
this.listPhase = 'ready'
// Covers the empty-mutations pull (a plain baseline carries no edge).
this.syncCompletedNotifications()
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) {
const session = this.sessions.get(s.sessionId)
@@ -566,6 +592,8 @@ export class SessionManager {
private recordMutation(mutation: SessionListMutation): void {
this.listMutations?.push(mutation)
this.summaries = applyMutation(this.summaries, mutation)
// Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames.
this.syncCompletedNotifications()
this.notifier.markDirty()
}
@@ -893,6 +921,38 @@ export class SessionManager {
})
}
/**
* Reconcile completion reminders against the latest summaries, eagerly after
* every mutation and pull (a snapshot-build-time pass would collapse
* consecutive status frames into one observation). A running→idle edge of a
* non-selected session arms its reminder; running disarms it; removal drops
* it. First observation only records the running bit — sessions already
* idle at load get no reminder.
*/
private syncCompletedNotifications(): void {
const seen = new Set<SessionId>()
for (const s of this.summaries) {
seen.add(s.sessionId)
const prev = this.prevRunning.get(s.sessionId)
if (prev === undefined) {
this.prevRunning.set(s.sessionId, s.running)
continue
}
if (prev && !s.running) {
if (s.sessionId !== this.selected) this.completedNotifications.add(s.sessionId)
} else if (s.running) {
this.completedNotifications.delete(s.sessionId)
}
this.prevRunning.set(s.sessionId, s.running)
}
for (const id of this.prevRunning.keys()) {
if (!seen.has(id)) this.prevRunning.delete(id)
}
for (const id of this.completedNotifications) {
if (!seen.has(id)) this.completedNotifications.delete(id)
}
}
private buildListSnapshot(): SessionListSnapshot {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
// List rows read the generic 'title' projection key (host-computed unit
@@ -914,7 +974,7 @@ export class SessionManager {
const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0]
if (status !== undefined) pendingInteractions.set(sessionId, status)
}
const fresh = flattenLineage(merged, pendingInteractions)
const fresh = flattenLineage(merged, pendingInteractions, this.completedNotifications)
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
@@ -924,6 +984,7 @@ export class SessionManager {
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.pendingInteraction === entry.pendingInteraction
&& prev.projectionValues === entry.projectionValues
&& prev.completed === entry.completed
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry

View File

@@ -51,6 +51,8 @@ export interface SessionSummary {
running: boolean
/** User interaction currently blocking this session (sidebar amber-dot state). */
pendingInteraction?: PendingInteractionStatus
/** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */
completed?: boolean
/**
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the
@@ -614,6 +616,7 @@ export class SessionsService implements ISessions {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
...(entry.completed ? { completed: true } : {}),
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.pendingInteraction === undefined

View File

@@ -0,0 +1,65 @@
/** Reconstruct durable steering identity from the event-sourced agent inbox. */
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
type InboxTarget = 'next-turn' | 'next-step'
/** Minimal pending identity retained while replaying durable inbox splices. */
interface PendingIdentity {
readonly id: string
}
/** Client-side structural view of the host-owned inbox event. */
interface InboxSplice {
readonly target: InboxTarget
readonly start: number
readonly removedCount?: number
readonly inserted: readonly PendingIdentity[]
readonly outcome?: 'canceled'
}
/**
* Incrementally identifies `user/message` events claimed from the next-step
* inbox. The agent loop records all admitted input as `user/message`; the
* preceding `agent/inbox/spliced` events preserve whether it came from the
* queued-turn list or the next-step list.
*/
export class SteeringHistory {
private readonly inbox: Record<InboxTarget, PendingIdentity[]> = {
'next-turn': [],
'next-step': [],
}
private readonly claimedNextStep = new Set<string>()
/** Clear all replay state before rebuilding a history window. */
reset(): void {
this.inbox['next-turn'] = []
this.inbox['next-step'] = []
this.claimedNextStep.clear()
}
/**
* Apply one event and report whether it is a durable human steering message.
* @param event - next raw session event in sequence order.
* @returns true only for a user-origin message previously claimed from `next-step`.
*/
apply(event: SessionEvent): boolean {
if ((event.type as string) === 'agent/inbox/spliced') {
this.applySplice(event.data as unknown as InboxSplice)
return false
}
if (event.type !== 'user/message') return false
const id = event.data.id
if (!this.claimedNextStep.delete(id)) return false
return event.data.source.kind === 'user'
}
/** Replay one host-validated inbox splice. */
private applySplice({ target, start, removedCount = 0, inserted, outcome }: InboxSplice): void {
const removed = this.inbox[target].splice(start, removedCount, ...inserted)
for (const identity of inserted) this.claimedNextStep.delete(identity.id)
if (target !== 'next-step' || outcome === 'canceled') return
for (const identity of removed) this.claimedNextStep.add(identity.id)
}
}

View File

@@ -22,6 +22,10 @@ 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 { contextForm, contextProvenance } from './context-provenance.ts'
import { SteeringHistory } from './steering-history.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
@@ -44,11 +48,13 @@ interface CallIndexEntry {
callView: ToolCallView | null
}
/** One event -> UI node (pure function; the eight-variant ConversationNode union). */
/** One event -> UI node (pure function; the ten-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
steering: boolean,
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
): ConversationNode {
switch (event.type) {
case 'user/message':
@@ -59,6 +65,15 @@ function materializeNode(
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
if (steering) {
return {
kind: 'steering', messageId: event.data.id,
seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
@@ -70,6 +85,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,8 +186,12 @@ 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>()
/** Durable inbox replay used to distinguish next-step human input from queued prompts. */
private readonly steeringHistory = new SteeringHistory()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so it is not a surface
@@ -200,6 +220,9 @@ export class TranscriptAdapter {
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
this.steeringHistory.reset()
const steeringSeqs = new Set<number>()
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,12 +230,14 @@ export class TranscriptAdapter {
this.eventIndex.set(event.seq, event)
this.indexCall(event, views?.[i])
this.indexCommand(event)
if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq)
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.
const projected: ConversationNode[] = []
for (const event of events) {
if (isTranscriptEvent(event)) projected.push(this.materialize(event))
if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
}
this.projected = projected
}
@@ -229,9 +254,11 @@ export class TranscriptAdapter {
append(event: SessionEvent, view?: ToolEventView): void {
this.eventIndex.set(event.seq, event)
this.indexCall(event, view)
const steering = this.steeringHistory.apply(event)
indexAssistantStepTiming(this.stepTimings, event)
if (this.indexCommand(event)) this.rev++
if (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event)]
this.projected = [...this.projected, this.materialize(event, steering)]
this.rev++
}
@@ -264,10 +291,16 @@ export class TranscriptAdapter {
}
/** Materialize one transcript event against the complete current indexes. */
private materialize(event: SessionEvent): ConversationNode {
private materialize(event: SessionEvent, steering: boolean): 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,
steering,
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

@@ -232,6 +232,7 @@ export class FakeApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

View File

@@ -1,4 +1,4 @@
import { createMessage } from '@deepseek-ai/dsh-llm'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
@@ -10,6 +10,49 @@ const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
describe('projectConversationHistory', () => {
it('names an injected context node from its durable source, like the live adapter', () => {
// The fold declares its own node mapping (jscpd:ignore in the source), so
// the provenance projection is pinned on both sides independently.
const injected = at(0, {
type: 'user/message',
surfaceOp: 'append',
data: createUserMessage({
content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
// A plugin source, because the client program does not see the host
// packages that merge richer source kinds; those arms are pinned in
// context-provenance.spec.ts.
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
}),
})
const { contexts } = projectConversationHistory([{ event: injected }])
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
kind: 'context',
seq: 0,
provenance: { role: 'inject', label: 'dsh-tool-skill' },
form: 'catalog',
}])
})
it('projects next-step human input as durable steering', () => {
const steering = createUserMessage({
content: [{ type: 'text', text: 'change course' }],
source: { kind: 'user' },
})
const events = [
at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }),
at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
]
const projection = projectConversationHistory(events.map(event => ({ event })))
expect(projection.eventNodes).toMatchObject([{
kind: 'steering', messageId: steering.id, seq: 2,
}])
})
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
const baseSeq = 400_000
const events = [

View File

@@ -52,4 +52,11 @@ describe('flattenLineage', () => {
warnSpy.mockRestore()
}
})
it('projects the completion-reminder set into rows (absent = false)', () => {
const out = flattenLineage([s('a', 10), s('b', 20)], undefined, new Set(['b' as SessionId]))
expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false)
expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true)
expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false)
})
})

View File

@@ -985,3 +985,128 @@ describe('pending-interaction list status', () => {
expect(session.getSnapshot().pending).toEqual([])
})
})
describe('completed reminder', () => {
const status = (rpcId: string, sessionId: SessionId, running: boolean) => ({
rpcId: rpcId as never,
payload: { type: 'host/session-status' as const, sessionId, running },
})
const added = (rpcId: string, sessionId: SessionId) => ({
rpcId: rpcId as never,
payload: { type: 'host/session-added' as const, sessionId, blank: false },
})
const entry = (manager: SessionManager, sessionId: SessionId) =>
manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
it('arms on a running→idle flip of a non-selected session and clears on select', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
// Opening the session consumes the reminder.
manager.select(S2)
expect(entry(manager, S2)?.completed).toBe(false)
})
it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S2)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder
// Switch away; a fresh run completing again arms the reminder.
manager.select(S1)
manager.handleHostEnvelope(status('s3', S2, true))
manager.handleHostEnvelope(status('s4', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
})
it('a re-run disarms the reminder while running and re-arms on its completion', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
// The user starts a new run without opening the session: running wins.
manager.handleHostEnvelope(status('s3', S2, true))
expect(entry(manager, S2)?.completed).toBe(false)
manager.handleHostEnvelope(status('s4', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
})
it('session-removed drops the reminder and a re-add starts clean', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope(added('h1', S1))
manager.handleHostEnvelope(added('h2', S2))
manager.select(S1)
manager.handleHostEnvelope(status('s1', S2, true))
manager.handleHostEnvelope(status('s2', S2, false))
expect(entry(manager, S2)?.completed).toBe(true)
manager.handleHostEnvelope({ rpcId: 'rm' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined()
manager.handleHostEnvelope(added('h3', S2))
expect(entry(manager, S2)?.completed).toBe(false)
})
it('a list refresh carrying the running→idle transition arms the reminder', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
await manager.refreshList()
expect(entry(manager, S2)?.completed).toBe(true)
})
it('never arms for sessions already idle at first observation', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
await manager.refreshList()
expect(entry(manager, S2)?.completed).toBe(false)
})
it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const refresh = manager.refreshList()
// The session finishes while the first pull is still in flight; the pull
// response recorded it as running at pull time.
manager.handleHostEnvelope(status('s-mid', S2, false))
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
await refresh
expect(entry(manager, S2)?.completed).toBe(true)
})
it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const refresh = manager.refreshList()
// The unknown session starts and finishes while the first pull is in
// flight; the pull-time baseline recorded it idle, so the running→idle
// edge lives entirely inside the replayed mutations.
manager.handleHostEnvelope(status('s-start', S2, true))
manager.handleHostEnvelope(status('s-finish', S2, false))
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
await refresh
expect(entry(manager, S2)?.completed).toBe(true)
})
})

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

@@ -85,22 +85,85 @@ describe('TranscriptAdapter', () => {
it('materializes every append-origin variant with field mapping', () => {
const adapter = new TranscriptAdapter()
const steering = createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
})
adapter.reset([
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
at(2, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }),
at(3, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(3, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(4, 0, 'c1', '结果'),
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(7, 0, 'c1', '结果'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'context', 'tool-result'])
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
})
})
it('identifies steering on the live append path', () => {
const adapter = new TranscriptAdapter()
const steering = createUserMessage({
content: [{ type: 'text', text: 'live steer' }],
source: { kind: 'user' },
})
adapter.reset([])
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }))
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }))
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
})
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
const adapter = new TranscriptAdapter()
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
const context = createUserMessage({
content: [{ type: 'text', text: 'context' }],
source: { kind: 'plugin', plugin: 'test' },
})
adapter.reset([
at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-turn', start: 0, inserted: [queued],
} }),
at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
} }),
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
at(3, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [canceled],
} }),
at(4, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
} }),
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
at(6, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [context],
} }),
at(7, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
])
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
})
it('skips events core does not call surface-eligible, marker or not', () => {
// The transcript is the append-origin surface, so log-only events (a chunk,
// a turn boundary, a compact/* provenance record) and a future type core
@@ -198,10 +261,15 @@ describe('TranscriptAdapter', () => {
adapter.reset([
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '注入的上下文' }],
source: { kind: 'plugin', plugin: 'compact' },
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
}) }),
])
expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }])
expect(adapter.nodes()).toMatchObject([{
kind: 'context',
seq: 0,
provenance: { role: 'inject', label: 'compact' },
form: 'instructions',
}])
})
it('ignores a foreign plugin s replacement user/message', () => {
@@ -408,4 +476,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

@@ -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-command/README.md
README.md: a19bfe7135acc5408448dc73d04813ed4104dd48
README.zh.md: 79d903b916728c1200ab1311055f2be190008787
README.md: bc7386c8fca3b5c623473328bee6322fa7295277
README.zh.md: 478ee4ccee4558c70075baa45ab34ac6e3d71617

View File

@@ -8,6 +8,8 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).
`PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`.
The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration.

View File

@@ -8,6 +8,8 @@
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent若预热它就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。
`PopupSelectController``src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边。壳是打开期间持有焦点的瞬态层onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。
`/client` 导出表层是插件主体(`apply``inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的契约类型;壳组件本身是 overlay 注册的内部实现。

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,9 +2,10 @@
* CommandService (`ctx.command`): the '/' command source over the
* session-keyed directory, the client-contribution registry, and the
* per-session popupSelect controllers. Candidate synthesis merges the host
* catalog with contributions by availability, then query/position filtering;
* a host/contribution name collision fails loud. Every execute addresses the
* session's agent by sessionId — sessions are always agent-backed.
* catalog with contributions by availability, then fuzzy query/position
* filtering; a host/contribution name collision fails loud. Every execute
* addresses the session's agent by sessionId — sessions are always
* agent-backed.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
@@ -27,6 +28,69 @@ interface LiveState {
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
}
/** One fuzzy match with its stable source position. */
interface RankedCandidate {
readonly candidate: SlashCandidate
readonly index: number
readonly prefix: boolean
readonly score: number
}
/** Extra weight for command-name starts and separator boundaries. */
function boundaryBonus(name: string, index: number): number {
return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0
}
/**
* Score the strongest ordered-subsequence alignment in O(name × query).
* Boundary and adjacent matches earn weight; skipped and leading characters
* cost weight.
*/
function fuzzyScore(name: string, query: string): number | undefined {
if (query === '') return 0
if (query.length > name.length) return undefined
const noMatch = Number.NEGATIVE_INFINITY
let previous = Array<number>(name.length).fill(noMatch)
for (let index = 0; index < name.length; index++) {
if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index
}
for (let queryIndex = 1; queryIndex < query.length; queryIndex++) {
const current = Array<number>(name.length).fill(noMatch)
let bestGapped = noMatch
for (let index = 0; index < name.length; index++) {
const gappedIndex = index - 2
if (gappedIndex >= 0) {
const prior = previous[gappedIndex] ?? noMatch
if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex)
}
if (name.charAt(index) !== query.charAt(queryIndex)) continue
const bonus = 1 + boundaryBonus(name, index)
const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch
if (adjacent !== noMatch) current[index] = adjacent + bonus + 4
if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index)
}
previous = current
}
let best = noMatch
for (const score of previous) best = Math.max(best, score)
return best === noMatch ? undefined : best
}
/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */
function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string): readonly SlashCandidate[] {
const query = rawQuery.toLowerCase()
if (query === '') return candidates
const ranked: RankedCandidate[] = []
candidates.forEach((candidate, index) => {
const name = candidate.name.toLowerCase()
const score = fuzzyScore(name, query)
if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score })
})
ranked.sort((left, right) =>
Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index)
return ranked.map(match => match.candidate)
}
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
export class CommandService extends Service implements CommandServiceContract {
static inject = ['slash', 'sessions', 'connection']
@@ -147,7 +211,7 @@ export class CommandService extends Service implements CommandServiceContract {
}
}
/** Menu candidates: host catalog + contribution availability, then query/position filtering. */
/** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */
private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly SlashCandidate[]> {
const list = await this.directory.ensureReady(session.sessionId, req.signal)
const rows: SlashCandidate[] = []
@@ -163,9 +227,10 @@ export class CommandService extends Service implements CommandServiceContract {
}
rows.push({ name: contribution.name, description: contribution.description })
}
return rows
.filter(c => c.name.startsWith(req.query))
.filter(c => req.position === 'leading' || c.hint === undefined)
return fuzzyCandidates(
rows.filter(c => req.position === 'leading' || c.hint === undefined),
req.query,
)
}
/** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */

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

@@ -164,13 +164,33 @@ describe('candidates', () => {
expect(b.listCalls).toEqual([])
})
it('pulls the session catalog; prefix filter and hint mapping apply', async () => {
it('pulls the session catalog; fuzzy filter and hint mapping apply', async () => {
const { source, listCalls } = await bench()
const list = await source.candidates(proj('s1'), req('g'))
expect(listCalls).toEqual([{ sessionId: sid('s1') }])
expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }])
})
it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => {
const commands: CommandDescriptor[] = [
{ name: 'q-xylophone', description: '' },
{ name: 'qx-long', description: '' },
{ name: 'fabulous', description: '' },
{ name: 'foo-bar', description: '' },
{ name: 'zuv', description: '' },
{ name: 'zu1v', description: '' },
{ name: 'yu1v', description: '' },
{ name: 'zu12v', description: '' },
]
const { source } = await bench({ commands: () => Promise.resolve({ commands }) })
const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name)
await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone'])
await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous'])
await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v'])
await expect(names('zzz')).resolves.toEqual([])
await expect(names('query-longer-than-every-name')).resolves.toEqual([])
})
it('catalogs are per session: another session pulls its own key', async () => {
const { source, listCalls } = await bench()
const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
@@ -195,10 +215,10 @@ describe('candidates', () => {
expect(s2Names).not.toContain('theme')
})
it('contribution rows ride the same query prefix filter', async () => {
it('contribution rows ride the same fuzzy query filter', async () => {
const { command, source } = await bench()
command.register(themeContribution())
const names = (await source.candidates(proj('s1'), req('th'))).map(c => c.name)
const names = (await source.candidates(proj('s1'), req('tm'))).map(c => c.name)
expect(names).toEqual(['theme'])
})

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: bbd115eac0eb914914dc11e504639633c801abdd
README.zh.md: 843b49e311fbf1a9157413c42a0ef3e9828284bc

View File

@@ -6,15 +6,15 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The view ring is a slot: the conversation registration declares the session-scoped `'conversation.view'` list in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
@@ -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

@@ -6,13 +6,13 @@
压缩compaction在检查点自身的消息流位置渲染为一行折叠标记不替换其上方的 transcript文本记录。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。该滚动容器无条件预留自己的滚动条槽选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero编辑器子树首个会话到达时彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。该滚动容器无条件预留自己的滚动条槽选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环是一个 slot会话注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`视图标签页则从注册选项(`id``order``label`投影而来。聊天视图是该包自身的配置项ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
视图环是一个 slot严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: <active id>`视图标签页则从注册选项(`id``order``label`投影而来。聊天视图是该包自身的配置项ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动并以内联 JSON 展示 `content``source`且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill技能目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区即按真实换行展示面向模型的文本并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering中途引导气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识
Think 行默认保持折叠并在不展开思维链的情况下暴露实时推理reasoning吞吐当推理块是流式输出尾部时摘要从结算后的首行切换到最新的非空行其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
@@ -30,13 +30,13 @@ 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 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。
@@ -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).
@@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ViewTab } from './contract/views.ts'
import type {
ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
@@ -33,7 +33,7 @@ import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession } from './skeleton/ConversationSession.tsx'
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { en, NS, zh, type ConversationKey } from './locales.ts'
@@ -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
@@ -127,6 +123,11 @@ export function apply(ctx: Context): void {
}
return tabs
}
const views = {
list: viewTabs,
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
}
// The per-session input machine registry (InputService face; published as
// ctx.conversation.input by the service below sharing this one instance).
@@ -155,6 +156,7 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.session.header': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
@@ -180,27 +182,36 @@ export function apply(ctx: Context): void {
}),
}, ConversationRoot)
// The strict session subtree owns only per-session store and view content;
// the resident parent keeps Hero and composer layout identity stable.
// The strict session body fills the resident scrollport without owning it;
// the Hero/composer path therefore stays fixed while the first blank
// session appears after a Workspace pick.
slots.register({
name: 'conversation.session',
locale: NS,
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
views: {
list: viewTabs,
subscribe: fn => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
},
views,
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
open: (id) => { sessions.open(id) },
}),
}, ConversationSession)
// Header chrome sits above the resident scrollport but shares the same
// per-session chat store (active view) as its body and view entries.
slots.register({
name: 'conversation.session.header',
locale: NS,
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (): ConversationSessionHeaderInjected => ({
views,
open: (id) => { sessions.open(id) },
}),
}, ConversationSessionHeader)
// The default composer body: its own single slot inside the composer
// chain's fallback (decision 20). Public machine surface arrives via the
// provide channel above; the keyboard command face and the stop/retry
@@ -334,17 +345,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

@@ -0,0 +1,161 @@
/* Expanded context bodies: one code-block surface shared by every form, so the
disclosure keeps the Figma 10:2482 geometry whichever form renders inside. */
.text {
margin: 0;
color: var(--dsw-alias-label-secondary);
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Provenance beneath the text: dimmer than the content it describes. */
.fields {
display: flex;
flex-direction: column;
gap: 2px;
margin: 8px 0 0;
padding-top: 8px;
border-top: 1px solid var(--dsw-alias-line-secondary);
}
.field {
display: flex;
gap: 8px;
min-width: 0;
}
.fieldKey {
flex: none;
min-width: 96px;
color: var(--dsw-alias-label-caption);
}
.fieldValue {
flex: 1 1 auto;
min-width: 0;
margin: 0;
color: var(--dsw-alias-label-tertiary);
overflow-wrap: anywhere;
}
/* instructions: the reconciled files, above their text. */
.files {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.file {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.filePath {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.fileAction {
color: var(--dsw-alias-label-caption);
}
/* catalog: a replacement notice above one row per published entry. */
.catalogNotice {
margin: 0 0 6px;
color: var(--dsw-alias-label-caption);
}
.entries {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: flex;
gap: 8px;
min-width: 0;
}
.entryName {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.entryDescription {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
/* snapshot: one titled block per contributing subsystem. */
.sections {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
}
.section {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sectionName {
color: var(--dsw-alias-label-caption);
}
.sectionText {
margin: 0;
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* relay: who sent this, above what they said. */
.relaySender {
margin: 0 0 6px;
color: var(--dsw-alias-label-caption);
overflow-wrap: anywhere;
}
/* recall: one row per source session, with how much of it survived. */
.recalls {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.recall {
display: flex;
gap: 8px;
min-width: 0;
}
.recallLabel {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.recallCounts {
flex: none;
color: var(--dsw-alias-label-caption);
}

View File

@@ -0,0 +1,591 @@
// Expanded bodies for the context disclosure, one per durable context form.
// The producer declares the form; this module only chooses a presentation for
// it. Every form falls back to OpaqueBody, which is the documented default for
// an absent, unknown, or malformed form — a resumed or foreign log must render
// even when this UI version has never seen its producer.
import type { ReactNode } from 'react'
import type { ContextMessageNode, KnownContextForm } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './ContextBody.module.css'
/** Model-facing text stays bounded at the disclosure, not at the producer. */
const MAX_CHARS = 20_000
/** Rows a list body materializes before summarizing the remainder. */
const MAX_ENTRIES = 200
type Translate = ChatViewSlotProps['t']
/** One durable source narrowed to the readable-record shape; null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/** One run of the model-facing content: adjacent text, or one unknown block. */
type ContentRun = { text: string } | { block: unknown }
/**
* The content blocks as runs, IN THE ORDER the model received them.
*
* Adjacent text blocks join with no separator, matching how provider adapters
* flatten them — inserting a line break would show the reader a line the model
* never saw. An unknown block breaks the run and keeps its own fallback rather
* than being hoisted past the text around it or vanishing; the block union is
* merge-extensible, so a foreign log may interleave shapes this build does not
* know.
*/
function contentRuns(content: ContextMessageNode['content']): ContentRun[] {
const runs: ContentRun[] = []
for (const block of content) {
if (block.type !== 'text') {
runs.push({ block })
continue
}
const last = runs[runs.length - 1]
if (last !== undefined && 'text' in last) last.text += block.text
else runs.push({ text: block.text })
}
return runs
}
/** Only the blocks this UI version does not know, for bodies that replace the text. */
function unknownBlocks(content: ContextMessageNode['content']): unknown[] {
return contentRuns(content).flatMap(run => 'block' in run ? [run.block] : [])
}
/** The model-facing text, truncated to the display bound. */
function boundedText(text: string, t: Translate): string {
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}
/**
* One source field rendered as a value row; nested shapes stay compact JSON.
* Bounded on its own, because provenance is as unbounded as the text: an unknown
* producer may record an arbitrarily large string or array.
*/
function fieldValue(value: unknown, t: Translate): string {
const text = typeof value === 'string'
? value
: typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value)
return boundedText(text, t)
}
/**
* Provenance fields as a key/value list. `kind` is always omitted because the
* row header already names the producer. `form` is omitted only when a
* dedicated body rendered for it — then the presentation the reader is looking
* at IS that value. On the opaque fallback the declaration is kept, because
* that is the one place a form this version cannot present would otherwise
* disappear from the UI entirely.
*/
function SourceFields({ source, formRendered, t }: {
source: unknown
formRendered: boolean
t: Translate
}): ReactNode {
const record = asRecord(source)
if (record === null) return null
const hidden = formRendered ? ['kind', 'form'] : ['kind']
const rows = Object.entries(record).filter(([key]) => !hidden.includes(key))
if (rows.length === 0) return null
return (
<dl className={css.fields} data-context-fields>
{rows.map(([key, value]) => (
<div key={key} className={css.field}>
<dt className={css.fieldKey}>{key}</dt>
<dd className={css.fieldValue}>{fieldValue(value, t)}</dd>
</div>
))}
</dl>
)
}
/**
* Content blocks this UI version does not know, kept visible rather than
* dropped: the block union is merge-extensible, so a newer or foreign log may
* carry a shape this build has no presentation for.
* @param props - The unrecognized blocks and the locale seat.
* @returns One generic JSON block per unknown entry.
*/
function UnknownBlocks({ blocks, t }: { blocks: readonly unknown[]; t: Translate }): ReactNode {
return (
<>
{blocks.map((block, index) => (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={block}
truncatedLabel={total => t('json.truncated', { total })}
/>
))}
</>
)
}
/**
* The model-facing content of one context, shared by every form that shows it:
* the text with its real line breaks, then any block this UI version does not
* know, which keeps its own fallback rather than vanishing.
* @param props - Durable content and the locale seat.
* @returns The content blocks as the model received them.
*/
function ModelFacingContent({ content, t }: {
content: ContextMessageNode['content']
t: Translate
}): ReactNode {
return (
<>
{contentRuns(content).map((run, index) => ('text' in run
? run.text !== '' && (
<pre key={index} className={css.text} data-context-text>{boundedText(run.text, t)}</pre>
)
: (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={run.block}
truncatedLabel={total => t('json.truncated', { total })}
/>
)))}
</>
)
}
/**
* Default presentation: the model-facing text as text, with its real line
* breaks, and the remaining provenance beneath it. This is what every form
* this UI version does not recognize renders as.
* @param props - Durable content, its source, and the locale seat.
* @returns The opaque context body.
*/
export function OpaqueBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
return (
<>
<ModelFacingContent content={content} t={t} />
<SourceFields source={source} formRendered={false} t={t} />
</>
)
}
/** One reconciled instruction file, as the durable source records it. */
interface InstructionChange {
action: 'set' | 'replace' | 'remove'
path: string
digest?: string
}
/**
* Instruction changes read off the source, or null when the record is not a
* usable instruction list.
*
* The read is all-or-nothing: silently dropping one unreadable entry would show
* a confident, incomplete file list for a log this version cannot fully read.
* Paths are deduplicated in first-seen order, matching how the header label is
* derived from the same array.
*/
function instructionChanges(source: unknown): InstructionChange[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['changes']
if (!Array.isArray(list)) return null
const changes: InstructionChange[] = []
const seen = new Set<string>()
for (const entry of list as readonly unknown[]) {
const change = asRecord(entry)
if (change === null) return null
const path = change['path']
if (typeof path !== 'string' || path === '') return null
const action = change['action']
// The action decides which word the row shows, so an unrecognized one is
// not a readable change — it would be presented as loaded or updated.
if (action !== 'set' && action !== 'replace' && action !== 'remove') return null
const digest = change['digest']
if (seen.has(path)) continue
seen.add(path)
changes.push({ action, path, ...typeof digest === 'string' ? { digest } : {} })
}
return changes.length === 0 ? null : changes
}
/**
* Locale key for one reconciled file. The baseline loads a file; a later delta
* distinguishes a newly reconciled path from a rewritten one, which `set` and
* `replace` already separate at the producer.
* @param action - the durable change action.
* @param baseline - whether this context is the startup/resume baseline.
* @returns the key naming what happened to that file.
*/
function instructionAction(
action: InstructionChange['action'],
baseline: boolean,
): 'message.context.instructions.removed' | 'message.context.instructions.loaded'
| 'message.context.instructions.added' | 'message.context.instructions.updated' {
if (action === 'remove') return 'message.context.instructions.removed'
if (baseline) return 'message.context.instructions.loaded'
return action === 'set' ? 'message.context.instructions.added' : 'message.context.instructions.updated'
}
/**
* `instructions` form: the files this context reconciled, then their text.
*
* The text keeps its `<system-reminder>` framing verbatim — the framing is part
* of what the model read, so hiding it would misreport the request.
* @param props - Durable content, its source, and the locale seat.
* @returns The instructions context body, or the opaque body when the change
* list is unreadable.
*/
export function InstructionsBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const changes = instructionChanges(source)
if (changes === null) return <OpaqueBody content={content} source={source} t={t} />
const baseline = asRecord(source)?.['baseline'] === true
return (
<>
<ul className={css.files} data-context-files>
{changes.map(change => (
<li key={change.path} className={css.file} title={change.digest}>
<span className={css.filePath}>{change.path}</span>
<span className={css.fileAction}>
{t(instructionAction(change.action, baseline))}
</span>
</li>
))}
</ul>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** One catalog entry, as the durable source records it. */
interface CatalogEntry {
name: string
description: string
}
/**
* Catalog entries read off the source, or null when the record is not a usable
* catalog. All-or-nothing for the same reason as the instruction list: this body
* replaces the model-facing text, so a partial list would hide the only complete
* account of what the model read.
*/
function catalogEntries(source: unknown): CatalogEntry[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['entries']
if (!Array.isArray(list)) return null
const entries: CatalogEntry[] = []
for (const item of list as readonly unknown[]) {
const entry = asRecord(item)
if (entry === null) return null
const name = entry['name']
const description = entry['description']
if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null
entries.push({ name, description })
}
// An empty list is a real catalog: a replacement with no entries retires
// every earlier name. Only an unreadable shape falls back.
return entries
}
/**
* `catalog` form: the published entries as a list, read from the source rather
* than re-parsed out of the model-facing prose.
*
* A catalog whose source carries no usable entries falls through to the opaque
* body, so an older or hand-edited log still shows its text.
* @param props - Durable content, its source, and the locale seat.
* @returns The catalog context body, or the opaque body when the entry list is
* unreadable.
*/
export function CatalogBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const entries = catalogEntries(source)
if (entries === null) return <OpaqueBody content={content} source={source} t={t} />
const update = asRecord(source)?.['update'] === true
// Entry count is unbounded (a provider may publish any number of skills), and
// the scrollport bounds height, not node count — so the list bounds itself.
const shown = entries.slice(0, MAX_ENTRIES)
const rest = unknownBlocks(content)
return (
<>
{update && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
<ul className={css.entries} data-context-entries>
{shown.map((entry, index) => (
// Index key: a hand-edited or foreign log may repeat a name, and a
// duplicate React key would drop a row the model did see.
<li key={index} className={css.entry}>
<code className={css.entryName}>{entry.name}</code>
<span className={css.entryDescription}>{entry.description}</span>
</li>
))}
</ul>
{shown.length < entries.length && (
<p className={css.catalogNotice} data-context-entries-truncated>
{t('message.context.catalog.more', { count: entries.length - shown.length })}
</p>
)}
{/* The block union is merge-extensible: a catalog message carrying an
unknown block still shows it rather than dropping model-visible content. */}
<UnknownBlocks blocks={rest} t={t} />
</>
)
}
/** One named contribution to a runtime snapshot, as the durable source records it. */
interface SnapshotSection {
name: string
text: string
}
/** Snapshot sections read off the source, or null when the record is unusable. */
function snapshotSections(source: unknown): SnapshotSection[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['sections']
if (!Array.isArray(list)) return null
const sections: SnapshotSection[] = []
for (const item of list as readonly unknown[]) {
const section = asRecord(item)
if (section === null) return null
const name = section['name']
const text = section['text']
if (typeof name !== 'string' || name === '' || typeof text !== 'string') return null
sections.push({ name, text })
}
return sections.length === 0 ? null : sections
}
/**
* `snapshot` form: the named contributions this snapshot assembled, in order.
*
* The sections are the same bytes the model read, split at the boundaries the
* producer assembled them on, so a reader sees which subsystem contributed
* which state instead of one undifferentiated wall.
*
* One sentence of the model-facing text is NOT in any section: the producer's
* framing line declaring that this snapshot supersedes earlier ones. Unlike the
* `<system-reminder>` wrapper an instruction context carries — which wraps
* content and cannot be separated from it — that line states the form's own
* semantics, so the body states them as a caption instead of reprinting the
* joined prose beside the sections it was split from.
* @param props - Durable content, its source, and the locale seat.
* @returns The snapshot context body, or the opaque body when unreadable.
*/
export function SnapshotBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sections = snapshotSections(source)
/* v8 ignore next -- contextBody reads the sections before choosing this body. */
if (sections === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.catalogNotice} data-context-snapshot-supersedes>
{t('message.context.snapshot.supersedes')}
</p>
<dl className={css.sections} data-context-sections>
{sections.map((section, index) => (
<div key={index} className={css.section}>
<dt className={css.sectionName}>{section.name}</dt>
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
</div>
))}
</dl>
</>
)
}
/**
* `notice` form: what just happened, with the model-facing text beneath it.
*
* The one-line account also rides the collapsed row ({@link contextBody}), so a
* notice is usually readable without expanding at all.
* @param props - Durable content, its source, and the locale seat.
* @returns The notice context body.
*/
export function NoticeBody({ content, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
return <ModelFacingContent content={content} t={t} />
}
/**
* `relay` form: which agent sent this, then what it said.
*
* The sender is an opaque session id; it is shown as provenance rather than a
* label, because this client cannot resolve it to a title.
* @param props - Durable content, its source, and the locale seat.
* @returns The relay context body.
*/
export function RelayBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sender = relaySender(source)
/* v8 ignore next -- contextBody resolves the sender before choosing this body. */
if (sender === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.relaySender} data-context-relay-sender>
{t('message.context.relay.from', { session: sender })}
</p>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** The sending agent's session id, or null when the record does not name one. */
function relaySender(source: unknown): string | null {
const sender = asRecord(source)?.['senderSessionId']
return typeof sender === 'string' && sender !== '' ? sender : null
}
/** One recalled session, as the durable source records it. */
interface RecalledSession {
label: string
retained: number
omitted: number
truncated: boolean
}
/** Recalled sessions read off the source, or null when the record is unusable. */
function recalledSessions(source: unknown): RecalledSession[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['references']
if (!Array.isArray(list)) return null
const sessions: RecalledSession[] = []
for (const item of list as readonly unknown[]) {
const reference = asRecord(item)
if (reference === null) return null
const label = reference['label']
const retained = reference['retainedMessages']
const omitted = reference['omittedMessages']
const truncated = reference['truncated']
// Completeness is the fact this card exists to report, so a reference that
// cannot state it is not a readable recall — showing the label alone would
// present a confident card over unknown loss.
if (typeof label !== 'string' || label === ''
|| typeof retained !== 'number' || typeof omitted !== 'number'
|| typeof truncated !== 'boolean') return null
sessions.push({ label, retained, omitted, truncated })
}
return sessions.length === 0 ? null : sessions
}
/**
* `recall` form: which sessions this material came from and how much of each
* survived the read, then the material itself.
*
* Completeness is the fact a reader needs first: recalled context is bounded on
* the way in, so a card that hid the omitted count would overstate what the
* model received.
* @param props - Durable content, its source, and the locale seat.
* @returns The recall context body, or the opaque body when unreadable.
*/
export function RecallBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sessions = recalledSessions(source)
if (sessions === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<ul className={css.recalls} data-context-recalls>
{sessions.map((session, index) => (
<li key={index} className={css.recall}>
<span className={css.recallLabel}>{session.label}</span>
<span className={css.recallCounts}>
{t('message.context.recall.counts', {
retained: session.retained,
omitted: session.omitted,
})}
</span>
{session.truncated && (
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
)}
</li>
))}
</ul>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** The one-line account a `notice` puts on its collapsed row, when it records one. */
function noticeSummary(source: unknown): string | null {
const summary = asRecord(source)?.['summary']
return typeof summary === 'string' && summary !== '' ? summary : null
}
/**
* Choose the body for one context node.
*
* Returns the form the body actually rendered as, which is not always the
* declared one: a declared form whose fields are unreadable falls back to
* opaque, and the caller labels the row with what it really shows.
* `summary` is the collapsed row's one-line account, which only a `notice`
* records: its whole point is being readable without expanding.
* @param form - the producer-declared form projected onto the node.
* @param props - durable content, its source, and the locale seat.
* @returns the rendered form (null for opaque), its collapsed summary, and its body.
*/
export function contextBody(
form: ContextMessageNode['form'],
props: { content: ContextMessageNode['content']; source: unknown; t: Translate },
): { rendered: KnownContextForm | null; summary: string | null; body: ReactNode } {
const opaque = { rendered: null, summary: null, body: <OpaqueBody {...props} /> }
switch (form) {
case 'instructions':
return instructionChanges(props.source) === null
? opaque
: { rendered: 'instructions', summary: null, body: <InstructionsBody {...props} /> }
case 'catalog':
return catalogEntries(props.source) === null
? opaque
: { rendered: 'catalog', summary: null, body: <CatalogBody {...props} /> }
case 'snapshot':
return snapshotSections(props.source) === null
? opaque
: { rendered: 'snapshot', summary: null, body: <SnapshotBody {...props} /> }
case 'notice': {
const summary = noticeSummary(props.source)
return summary === null
? opaque
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
}
case 'relay':
return relaySender(props.source) === null
? opaque
: { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
case 'recall':
return recalledSessions(props.source) === null
? opaque
: { rendered: 'recall', summary: null, body: <RecallBody {...props} /> }
case null:
return opaque
/* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new
KnownContextForm here rather than letting it degrade to opaque silently. */
default: {
const unreachable: never = form
throw new Error(`unreachable context form: ${String(unreachable)}`)
}
}
}

View File

@@ -12,6 +12,40 @@
color: var(--dsw-alias-label-secondary);
}
/* Separator and producer name beside the role title: ToolRow's summary geometry,
so the two disclosure rows keep one 24px rhythm and one separator shape. */
.sep {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.source {
flex: none;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A notice's one-line account: the reason it rarely needs expanding. */
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
box-sizing: border-box;
width: calc(100% - 22px);
@@ -23,7 +57,6 @@
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
/* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */
font: 400 11px/16px var(--ds-font-family-code);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@@ -1,84 +1,70 @@
import { useMemo, useState } from 'react'
import { useState } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import { contextBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
const MAX_CHARS = 20_000
function inlineJson(payload: unknown): string {
const raw = JSON.stringify(payload)
let formatted = ''
let quoted = false
let escaped = false
for (let index = 0; index < raw.length; index++) {
const char = raw.charAt(index)
if (quoted) {
formatted += char
if (escaped) escaped = false
else if (char === '\\') escaped = true
else if (char === '"') quoted = false
continue
}
if (char === '"') {
quoted = true
formatted += char
continue
}
if (char === '{' || char === '[') {
formatted += char
const close = char === '{' ? '}' : ']'
if (raw[index + 1] !== close) formatted += ' '
continue
}
if (char === '}' || char === ']') {
const open = char === '}' ? '{' : '['
if (raw[index - 1] !== open) formatted += ' '
formatted += char
continue
}
formatted += char === ':' || char === ',' ? `${char} ` : char
}
return formatted
}
/** Props for the logged non-user message presentation. */
export interface ContextInjectionRowProps {
content: ContextMessageNode['content']
source: ContextMessageNode['source']
/** Role and producer name projected from the durable source. */
provenance: ContextMessageNode['provenance']
/** Producer-declared information form; null renders the opaque body. */
form: ContextMessageNode['form']
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
/**
* Render logged context with the Tool calls disclosure chrome from Figma.
* @param props - Durable content and source provenance.
* @returns A collapsed context row with a bounded JSON body.
*
* The header names the role the context plays and, beside it, the producer the
* durable source identifies, so a reader can tell an injected skill catalog
* from a workspace instruction file or a recalled session without expanding.
* The expanded body follows the producer-declared form; an absent or unknown
* form renders the opaque body.
* @param props - Durable content, its projected provenance and form, and the locale seat.
* @returns A collapsed context row with a bounded, form-specific body.
*/
export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) {
export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => {
if (!open) return ''
const text = inlineJson({ content, source })
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}, [content, open, source, t])
// Resolved rather than declared: a form whose fields are unreadable renders
// the opaque body, and the marker must say what the row actually shows.
const { rendered, summary, body } = contextBody(form, { content, source, t })
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title={t('message.contextInjection')}
title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
collapsedContent={provenance.label === null ? undefined : (
/* ToolRow's separator shape: an aria-hidden dot, so the accessible name
stays the two readable parts and the two disclosure rows expose one
name shape. A source that names no producer drops the dot with it. */
<>
<span className={css.sep} aria-hidden />
<span className={css.source} data-context-source>{provenance.label}</span>
{summary !== null && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary} data-context-summary>{summary}</span>
</>
)}
</>
)}
keepContentWhenOpen
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<pre className={css.body} data-context-injection-body>{body}</pre>
<div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
{body}
</div>
</DisclosureRow>
)
}

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

@@ -8,6 +8,15 @@
gap: 6px;
}
/* Steering caption above the bubble: mid-turn interjections carry the same
bubble as a turn-opening prompt, so the transcript names which one this is. */
.steeringMark {
padding-right: 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 16px;
}
.bubble {
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
max-width: min(525px, 82%);

View File

@@ -1,12 +1,13 @@
// MessageItem: simple chat nodes — user bubbles
// (right-aligned, with clock + copy / branch IconActions), pending steering
// (copy only), context injection, compaction marker, retry disclosure, and
// unknown-surface JSON rows.
// MessageItem: simple chat nodes — user and consumed-steering bubbles
// (right-aligned, with clock + copy / branch IconActions; steering adds the
// interjection caption that names it), pending steering (caption + copy only),
// context injection, compaction marker, retry disclosure, and unknown-surface
// JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
CompactionSummaryNode, ContextMessageNode, ModelRetryNode,
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -19,6 +20,7 @@ import css from './MessageItem.module.css'
export interface MessageItemProps {
node:
| UserMessageNode
| SteeringMessageNode
| ContextMessageNode
| CompactionSummaryNode
| ModelRetryNode
@@ -170,19 +172,22 @@ function projectUserText(text: string): ReactNode {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, actions, pending = false, t,
content, actions, pending = false, steering = false, t,
}: {
content: readonly unknown[]
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
/** Whether this is the Host-authoritative pre-admission steering projection. */
pending?: boolean
/** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */
steering?: boolean
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, rest } = contentText(content)
const truncated = (total: number): string => t('json.truncated', { total })
return (
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
{steering && <span className={css.steeringMark} data-steering-mark>{t('message.steering')}</span>}
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
@@ -206,6 +211,7 @@ export function PendingSteeringBubble({ content, t }: {
<UserStyleBubble
content={content}
pending
steering
t={t}
actions={text => (
<MessageIconActions
@@ -226,9 +232,11 @@ export const MessageItem = memo(function MessageItem({
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user':
case 'steering':
return (
<UserStyleBubble
content={node.content}
steering={node.kind === 'steering'}
t={t}
actions={text => (
<MessageIconActions
@@ -245,7 +253,13 @@ export const MessageItem = memo(function MessageItem({
)
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />
<ContextInjectionRow
content={node.content}
source={node.source}
provenance={node.provenance}
form={node.form}
t={t}
/>
)
case 'compaction':
return <CompactionItem node={node} t={t} />

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

@@ -86,7 +86,7 @@ export function messageBranchSeqs(
tail = candidate
nodeIndex++
}
if (tail?.kind === 'user'
if (tail?.kind === 'user' || tail?.kind === 'steering'
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
result.add(tail.seq)
}

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

@@ -13,19 +13,20 @@ import type { CallId, SelectionTarget, ViewTab } from './views.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* Strict-session content inside the resident conversation shell. This
* subtree owns the per-session chat store, header, and view ring and is
* remounted when the current session id changes.
* Strict-session body inside the resident conversation scrollport. It
* owns the per-session draft mirror and active view ring.
*/
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
'conversation.session': { kind: 'single'; scope: 'session' }
/** Strict-session header above the resident conversation scrollport. */
'conversation.session.header': { kind: 'single'; scope: 'session' }
/** Session-header actions contributed by feature plugins. */
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
* ConversationRoot via `only: <active id>`. Declared by this package's
* 'conversation' entry (declaring is claiming). Session scope: views read
* the conversation snapshot through the standard kit.
* the session body via `only: <active id>`. Declared by this package's
* body entry (declaring is claiming). Session scope: views read the
* conversation snapshot through the standard kit.
*/
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
/**
@@ -122,22 +123,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
/**
* Wrap the view ring in the transcript scrollport that also hosts the
* sticky composer seat (whole `'conversation.composer'` chain output).
* Supplied for every real session (hero/settling/active) so the composer
* keeps one tree seat across the blank → active flip; the header stays
* outside that wrapper as ordinary column chrome (`flex: none`), while
* active CSS sticks the seat to the bottom of the same scrollport so wheel
* over the footer scrolls the flow.
* @param view - the session view-ring content (null while blank chrome is hidden).
* @returns the scrollport containing `view` and the sticky composer seat.
*/
wrapActiveBody?: (view: ReactNode) => ReactNode
}
/** Header actions derive their state from the standard session/global kit. */
export interface ConversationHeaderActionOwnerProps {}
@@ -228,7 +213,7 @@ export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
*/
export type ConvViewProps = PropsRuntime<'conversation.view'>
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
/** The shared chat store handle type declared by the Session header/body, details, and chat-view registrations. */
export type ChatStore = ReturnType<typeof createChatStore>
/** Business callbacks injected into the conversation slot. */
@@ -240,7 +225,7 @@ export interface ConversationInjected {
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
}
/** Business callbacks injected into the strict session content seat. */
/** Business callbacks injected into the strict Session body seat. */
export interface ConversationSessionInjected {
/** Views projected from the `conversation.view` slot ledger. */
views: {
@@ -250,6 +235,16 @@ export interface ConversationSessionInjected {
}
/** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror: (write: (text: string) => void) => () => void
}
/** Business callbacks injected into the strict session header seat. */
export interface ConversationSessionHeaderInjected {
/** Views projected from the `conversation.view` slot ledger. */
views: {
list: () => readonly ViewTab[]
subscribe: (fn: () => void) => () => void
version: () => number
}
/** Select a real Session through the runtime navigation owner. */
open: (sessionId: SessionId) => void
}
@@ -354,7 +349,8 @@ export interface ComposerChainProps {
*/
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<
| 'conversation.session' | 'conversation.composer' | 'conversation.composer.bar'
| 'conversation.session' | 'conversation.session.header'
| 'conversation.composer' | 'conversation.composer.bar'
| 'conversation.input.overlay'
| 'conversation.input.dock' | 'conversation.composer.dock'
| 'conversation.input.left' | 'conversation.input.right'
@@ -363,12 +359,19 @@ export type ConversationSlotProps =
& ConversationInjected
& PropsLocale<'conversation'>
/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */
/** Full strict-session body props: per-session store, view ring, and draft mirror. */
export type ConversationSessionSlotProps =
PropsRuntime<'conversation.session'>
& PropsRenderSlots<'conversation.view' | 'conversation.session.header.actions'>
& PropsRenderSlots<'conversation.view'>
& PropsStore<ChatStore>
& ConversationSessionInjected
/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */
export type ConversationSessionHeaderSlotProps =
PropsRuntime<'conversation.session.header'>
& PropsRenderSlots<'conversation.session.header.actions'>
& PropsStore<ChatStore>
& ConversationSessionHeaderInjected
& PropsLocale<'conversation'>
/** The pending approval carrier the owner dispatches into the composer chain. */

View File

@@ -15,7 +15,8 @@ export type { ConversationKey } from './locales.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,
ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

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': '排队发送',
@@ -33,6 +45,7 @@ export const zh = {
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'details.title': '详情',
@@ -54,6 +67,18 @@ export const zh = {
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.contextInjection': '上下文注入',
'message.contextRecall': '跨会话召回',
'message.context.instructions.loaded': '已载入',
'message.context.instructions.added': '已新增',
'message.context.instructions.updated': '已更新',
'message.context.instructions.removed': '已移除',
'message.context.catalog.replaced': '替换目录',
'message.context.catalog.more': '…还有 {count} 条',
'message.context.snapshot.supersedes': '取代先前的快照',
'message.context.relay.from': '来自会话 {session}',
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
'message.context.recall.truncated': '已截断',
'message.steering': '插话',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
'message.compaction.unavailable': '压缩摘要不可用',
@@ -71,6 +96,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 +163,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',
@@ -146,6 +185,7 @@ export const en = {
'access.confirm.cancel': 'Cancel',
'access.confirm.enable': 'Enable Full access',
'hero.headline': 'Let\'s start building',
'hero.preview': 'Preview',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'details.title': 'Details',
@@ -167,6 +207,18 @@ export const en = {
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.contextInjection': 'Context injection',
'message.contextRecall': 'Session recall',
'message.context.instructions.loaded': 'loaded',
'message.context.instructions.added': 'added',
'message.context.instructions.updated': 'updated',
'message.context.instructions.removed': 'removed',
'message.context.catalog.replaced': 'Replacement catalog',
'message.context.catalog.more': '… {count} more',
'message.context.snapshot.supersedes': 'Supersedes earlier snapshots',
'message.context.relay.from': 'From session {session}',
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
'message.context.recall.truncated': 'truncated',
'message.steering': 'Interjection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',
'message.compaction.unavailable': 'Compaction summary unavailable',
@@ -184,6 +236,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

@@ -31,8 +31,8 @@
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* Blank hero/settling: keep the header node mounted (stable Session tree for
the wrapActiveBody composer) without taking column space. */
/* Blank hero/settling: keep the strict Session header mounted without taking
column space; the root-owned scrollport and composer remain below it. */
.headerHidden {
display: none;
}

View File

@@ -2,7 +2,7 @@
// chain, AND the composer bar (session-maybe slot) stay mounted across
// no-session/session transitions — the bar renders inert via owner props.
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
@@ -31,9 +31,8 @@ export function ConversationRoot({
// Publishes the seat's live height as --dsh-composer-height on the scroll
// body so floating controls (ChatView back-to-bottom) clear the composer as
// it grows. Callback ref, not an effect: the seat remounts when the tree
// moves between the no-session and session paths. Stable identity so React
// reattaches only on those remounts, not on every render.
// it grows. Callback ref, not an effect; stable identity prevents observer
// churn while the first blank session fills the resident body outlet.
const seatObserver = useRef<ResizeObserver | null>(null)
const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
seatObserver.current?.disconnect()
@@ -167,28 +166,13 @@ export function ConversationRoot({
</div>
)
// Header stays column chrome above this scrollport; the sticky composer
// seat lives inside it with the transcript. Always wrap while a session
// exists (hero/settling/active) so the composer keeps one tree seat across
// the blank → active flip — relocating it only in active remounted the textarea.
const wrapActiveBody = (view: ReactNode): ReactNode => (
<div className={css.scrollBody} data-conversation-scroll="">
{view}
{composerSeat}
</div>
)
return (
<div className={css.root} data-phase={phase}>
{/* Mounted for every real session, hero included: ConversationSession
keeps a chrome-hidden shell while blank and owns the draft-
persistence mirror bind — unmounting it in the hero would lose
pre-first-send text on a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot(
'conversation.session',
{ wrapActiveBody },
)}
{sessionId === undefined ? wrapActiveBody(null) : null}
{renderSlot('conversation.session.header', {})}
<div className={css.scrollBody} data-conversation-scroll="">
{renderSlot('conversation.session', {})}
{composerSeat}
</div>
</div>
)
}

View File

@@ -1,14 +1,19 @@
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
/** Strict per-session header/body content inserted into the resident conversation layout. */
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
import { useEffect, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSessionSlotProps } from '../contract/slots.ts'
import type {
ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
} from '../contract/slots.ts'
import css from './ConversationRoot.module.css'
/** Full props composed from the strict session slot contract. */
/** Full props composed from the strict session body contract. */
export type ConversationSessionProps = ConversationSessionSlotProps
/** Full props composed from the strict session header contract. */
export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps
interface Breadcrumb {
readonly id: SessionId
readonly displayTitle: string
@@ -38,10 +43,15 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
})
}
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
}: ConversationSessionProps) {
/**
* Renders Session header chrome above the resident conversation scrollport.
* @param props - Strict Session store, view ledger, navigation, render, and locale shares.
* @returns the hidden blank-session header or visible title and tabs.
*/
export function ConversationSessionHeader({
sessionId, useSession, useSessions, useStore, actions,
renderSlot, views, open, t,
}: ConversationSessionHeaderProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
@@ -49,6 +59,77 @@ export function ConversationSession({
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const hideChrome = blank && composerPhase === 'blank'
return (
<header
className={clsx(css.header, hideChrome && css.headerHidden)}
aria-hidden={hideChrome || undefined}
>
{!hideChrome && (
<>
<div className={css.titleRow}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
</div>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
role="tab"
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(viewTab.id) }}
>
{viewTab.label}
</button>
))}
</div>
)}
</>
)}
</header>
)
}
/**
* Renders the active Session view inside the resident scrollport and keeps
* the input draft mirrored while blank Hero chrome is visible.
* @param props - Strict Session input/store, view ledger, and render shares.
* @returns the active view area, or null while the Session remains blank.
*/
export function ConversationSession({
useSession, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
const storedDraft = useStore(s => s.draft)
// `?? null`: persisted snapshots from before the inspect field rehydrate without it.
@@ -62,13 +143,8 @@ export function ConversationSession({
// the machine mirror, not this seed effect.
}, [inputActions])
// Blank hero/settling: keep the same header + body tree shape so a
// wrapActiveBody-hosted composer keeps its DOM identity across the first
// send (hero → active). Chrome is hidden; the draft-persistence mirror
// still runs because this component stays mounted.
const hideChrome = blank && composerPhase === 'blank'
const view: ReactNode = hideChrome ? null : (
if (blank && composerPhase === 'blank') return null
return (
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {
inspect,
@@ -76,59 +152,4 @@ export function ConversationSession({
}, { only: active.id })}
</div>
)
return (
<>
<header
className={clsx(css.header, hideChrome && css.headerHidden)}
aria-hidden={hideChrome || undefined}
>
{!hideChrome && (
<>
<div className={css.titleRow}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
</div>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
role="tab"
aria-selected={viewTab.id === active?.id}
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(viewTab.id) }}
>
{viewTab.label}
</button>
))}
</div>
)}
</>
)}
</header>
{wrapActiveBody !== undefined ? wrapActiveBody(view) : view}
</>
)
}

View File

@@ -119,12 +119,13 @@ export function HeroShell({ t, children }: HeroShellProps) {
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
{t('hero.headline')}
<span className={css.headlineText}>{t('hero.headline')}</span>
<span className={css.previewBadge}>{t('hero.preview')}</span>
</div>
<div className={css.body}>
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
workspace row rides the stack above the card) is CSS-centered in
the session scroll body during hero — see
{/* The resident composer (ConversationRoot's root-owned scrollport;
the workspace row rides the stack above the card) is CSS-centered
in that scroll body during hero — see
ConversationRoot.module.css [data-phase='hero']. */}
</div>
</div>

View File

@@ -23,21 +23,44 @@
overflow: visible;
}
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview
badge is a product addition outside that source and aligns to the title. */
.headline {
display: flex;
display: grid;
grid-template-columns: 34px auto;
column-gap: 10px;
row-gap: 4px;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 26px;
line-height: 32px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.headlineText {
grid-row: 1;
grid-column: 2;
}
.previewBadge {
grid-row: 2;
grid-column: 2;
justify-self: start;
padding: 0 4px;
border-radius: 4px;
background: var(--dsw-alias-state-business-tertiary);
color: var(--dsw-alias-label-primary);
font-size: 12px;
line-height: 18px;
font-weight: 500;
white-space: nowrap;
}
/* figma fish fill rides business blue. */
.fish {
flex: none;
grid-row: 1;
grid-column: 1;
color: var(--dsw-alias-state-business-primary);
}

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

@@ -21,7 +21,8 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
ConversationSessionInjected, DetailsInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
@@ -70,7 +71,7 @@ async function bench() {
// The host face (store resolution) exists only inside the installed
// renderer, so materialize it the way the shell does.
runtime.renderRoot()
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
runtime.slots.entries(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
@@ -80,6 +81,13 @@ async function bench() {
id, instance.actions)
return { instance, injected }
}
const conversationHeaderSurface = (id: SessionId) => {
const entry = entryOf('conversation.session.header')
const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
id, instance.actions)
return { instance, injected }
}
const residentSurface = (id: SessionId | undefined) => {
const entry = entryOf('conversation')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
@@ -111,7 +119,7 @@ async function bench() {
}
return {
runtime, feature, slots: runtime.slots, entryOf,
conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
conversationSurface, conversationHeaderSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
sessionFake, layoutFake,
}
}

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

@@ -21,11 +21,12 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
@@ -83,6 +84,16 @@ const LAYOUT_CHILDREN = {
'details': { kind: 'single', scope: 'session' },
} as const
/** Stateful occupant proving the root-scoped Hero workspace outlet is not rebuilt. */
function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
const [count, setCount] = useState(0)
return (
<button data-testid="workspace-probe" onClick={() => { setCount(value => value + 1) }}>
{String(open)}:{count}
</button>
)
}
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
@@ -188,6 +199,49 @@ describe('resident composer', () => {
await runtime.dispose()
})
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.workspaces.update((draft) => {
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
})
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe)
const view = runtime.renderRoot()
const root = view.container.querySelector('[data-phase="hero"]')!
const scrollBody = view.container.querySelector('[data-conversation-scroll]')!
const composerSeat = view.container.querySelector('[data-composer-seat]')!
const textarea = view.container.querySelector('textarea')!
const workspaceChip = view.getByRole('button', { name: '选择工作区' })
const workspaceProbe = view.getByTestId('workspace-probe')
expect(textarea.disabled).toBe(true)
fireEvent.click(workspaceChip)
fireEvent.click(workspaceProbe)
expect(workspaceProbe.textContent).toBe('true:1')
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj', blank: true },
snapshot: { blank: true, composerPhase: 'blank' },
})
expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
expect(view.container.querySelector('[data-conversation-scroll]')).toBe(scrollBody)
expect(view.container.querySelector('[data-composer-seat]')).toBe(composerSeat)
expect(view.container.querySelector('textarea')).toBe(textarea)
expect(view.getByRole('button', { name: '选择工作区' })).toBe(workspaceChip)
expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe)
expect(workspaceProbe.textContent).toBe('true:1')
expect(textarea.disabled).toBe(false)
await runtime.dispose()
})
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
const runtime = await bench([], { blank: true })

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.
@@ -45,7 +45,7 @@ async function bench() {
}
/** First stored entry for a key (inject/store live directly on StoredEntry). */
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') {
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
}
@@ -73,6 +73,7 @@ describe('apply wiring', () => {
const b = await bench()
const conversation = renderEntryOf(b.slots, 'conversation')
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
const conversationHeader = renderEntryOf(b.slots, 'conversation.session.header')
const chatView = renderEntryOf(b.slots, 'conversation.view')
const details = renderEntryOf(b.slots, 'details')
expect(conversation?.inject).toBeTypeOf('function')
@@ -81,6 +82,7 @@ describe('apply wiring', () => {
// The shared handle: one apply-built store value on ALL session entries
// (the session-maybe 'conversation' shell carries no store by design).
expect(conversationSession?.store).toBeDefined()
expect(conversationHeader?.store).toBe(conversationSession?.store)
expect(details?.store).toBe(conversationSession?.store)
expect(chatView?.store).toBe(conversationSession?.store)
// The hero workspace picker hole rides the conversation entry's children
@@ -90,10 +92,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).
@@ -203,47 +212,504 @@ describe('MessageItem arms', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const fork = vi.fn()
const view = render(
<MessageItem t={t} node={{
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
onFork={fork}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('steer!')
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
expect(fork).toHaveBeenCalledWith(2)
})
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
const ctxView = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
content: [{ type: 'text', text: 'line one\n\nline two' }],
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
provenance: { role: 'inject', label: 'fixture' },
form: null,
} as never}
/>,
)
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
expect(ctxView.container.querySelector('svg')).not.toBeNull()
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
)
// An unknown form renders the opaque body: the model-facing text keeps its
// real line breaks instead of being escaped into one JSON line, and the
// remaining provenance follows it as fields.
expect(ctxView.container.querySelector('[data-context-text]')?.textContent)
.toBe('line one\n\nline two')
const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
expect(fields).toEqual(['plugin', 'empty', 'list'])
fireEvent.keyDown(disclosure, { key: ' ' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
})
it('context preserves the bounded JSON truncation contract', () => {
it('the instructions form names the files it reconciled above their text', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
content: [{ type: 'text', text: '<system-reminder>\nInstructions from: AGENTS.md\n</system-reminder>' }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
changes: [
{ action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' },
{ action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' },
{ action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' },
],
},
provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ }))
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除'])
// The `<system-reminder>` framing is part of what the model read, so the
// body keeps it verbatim rather than presenting a cleaned-up excerpt.
expect(view.container.querySelector('[data-context-text]')?.textContent)
.toContain('<system-reminder>')
})
it('a delta distinguishes a newly reconciled file from a rewritten one', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'delta' }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
changes: [
{ action: 'set', scope: 'a', path: 'new/AGENTS.md' },
{ action: 'replace', scope: 'b', path: 'old/AGENTS.md' },
],
},
provenance: { role: 'inject', label: 'new/AGENTS.md, old/AGENTS.md' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ }))
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新'])
})
it('keeps an interleaved unknown block in the order the model received it', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [
{ type: 'text', text: 'before' },
{ type: 'future-block', payload: 1 },
{ type: 'text', text: 'after' },
],
source: null,
provenance: { role: 'inject', label: null },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent)
expect(texts).toEqual(['before', 'after'])
expect(view.getByText(/未知内容块/)).toBeTruthy()
})
it('the catalog form lists its durable entries instead of the model-facing prose', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: '<system-reminder>\n<available_skills>\n- `a`: A\n</available_skills>' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }],
},
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent)
expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B'])
expect(view.container.querySelector('[data-context-text]')).toBeNull()
expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull()
})
it('a replacement catalog says so above its entries', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
update: true,
entries: [{ name: 'a-skill', description: 'Does A' }],
},
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
})
it('a partially unreadable catalog falls back whole rather than showing a short list', () => {
// All-or-nothing: a body that replaces the model-facing text must not show
// a confident, incomplete account of what the model read.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill' }],
},
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
// The marker reports what rendered, not what was declared.
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBeNull()
})
it('an unreadable instruction list falls back to the opaque body with its fields', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'instruction prose' }],
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'set' }] },
provenance: { role: 'inject', label: 'workspace-instructions' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
expect(view.container.querySelector('[data-context-files]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
})
it('joins adjacent text blocks the way a provider adapter flattens them', () => {
// No invented separator: showing a line break the model never saw would
// misreport the request.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
source: null,
provenance: { role: 'inject', label: null },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond')
})
it('bounds an oversized provenance field, not only the model-facing text', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'short' }],
source: { kind: 'plugin', note: 'y'.repeat(21_000) },
provenance: { role: 'inject', label: 'plugin' },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
expect(view.container.querySelector('[data-context-fields] dd')?.textContent)
.toMatch(/… 已截断,共 \d+ 字符$/)
})
it('an empty replacement catalog stays a catalog: it retires every earlier name', () => {
// `renderCatalogUpdate` legitimately publishes zero entries when the last
// skill disappears; falling back would hide that the catalog was cleared.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: { kind: 'skill-catalog', form: 'catalog', update: true, entries: [] },
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0)
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBe('catalog')
})
it('a catalog whose entries are unreadable falls back to the opaque body', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: { kind: 'skill-catalog', form: 'catalog', entries: 'not-a-list' },
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
})
it('bounds a large catalog and says how many rows it withheld', () => {
const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' }))
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'catalog prose' }],
source: { kind: 'skill-catalog', form: 'catalog', entries },
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200)
expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条')
})
it('a catalog keeps a content block this version does not know', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'prose' }, { type: 'future-block', payload: 1 }],
source: { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'a', description: 'b' }] },
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.getByText(/未知内容块/)).toBeTruthy()
})
it('an instruction change with an unrecognized action falls back whole', () => {
// The action decides the word the row shows, so an unknown one cannot be
// presented as loaded or updated.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'instruction prose' }],
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'merge', path: 'A.md' }] },
provenance: { role: 'inject', label: 'workspace-instructions' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
expect(view.container.querySelector('[data-context-files]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
})
it('the opaque fallback keeps a form declaration this version cannot present', () => {
// Otherwise a newer or foreign log's declared shape vanishes from the UI.
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }],
source: { kind: 'plugin', plugin: 'later', form: 'a-later-form' },
provenance: { role: 'inject', label: 'later' },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ }))
const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
expect(fields).toEqual(['plugin', 'form'])
})
it('the snapshot form attributes each part to the subsystem that produced it', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'Current runtime context.\n\nsandbox\n\nworkspace' }],
source: {
kind: 'plugin',
plugin: '@deepseek-ai/dsh-system-prompt',
form: 'snapshot',
sections: [{ name: 'sandbox:policy', text: 'workspace-write' }, { name: 'workspace', text: '/repo' }],
},
provenance: { role: 'inject', label: '@deepseek-ai/dsh-system-prompt' },
form: 'snapshot',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ }))
const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent)
expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo'])
})
it('a notice puts its account on the collapsed row', () => {
// The whole point of the form: readable without expanding.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'background task bash-1 finished.' }],
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash pnpm test [status: completed]' },
provenance: { role: 'inject', label: 'tool-tasks' },
form: 'notice',
} as never}
/>,
)
expect(view.container.querySelector('[data-context-summary]')?.textContent)
.toBe('bash pnpm test [status: completed]')
expect(view.container.querySelector('[data-context-injection-body]')).toBeNull()
})
it('a notice without its account falls back to the opaque body', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'notice prose' }],
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice' },
provenance: { role: 'inject', label: 'tool-tasks' },
form: 'notice',
} as never}
/>,
)
expect(view.container.querySelector('[data-context-summary]')).toBeNull()
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-tasks$/ }))
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
})
it('each form falls back to the opaque body when its required facts are unreadable', () => {
// The fallback chain is the load-bearing wall: every dedicated form must
// reach it, and the row marker must not claim a form that did not render.
const cases = [
{ form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' },
{ form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' },
{ form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' },
] as const
for (const { form, source, label } of cases) {
cleanup()
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }],
source, provenance: { role: 'inject', label }, form,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) }))
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`)
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBeNull()
}
})
it('a snapshot states the supersession its framing line carries', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }],
source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] },
provenance: { role: 'inject', label: 'plugin' },
form: 'snapshot',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent)
.toBe('取代先前的快照')
})
it('a relay names the agent that sent it above what it said', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'child report body' }],
source: { kind: 'subagent-report', form: 'relay', senderSessionId: 'child-7' },
provenance: { role: 'inject', label: 'subagent-report' },
form: 'relay',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*subagent-report$/ }))
expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7')
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body')
})
it('a recall reports how much of each source session survived the read', () => {
// Recalled context is bounded on the way in, so hiding the omitted count
// would overstate what the model received.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'recalled material' }],
source: {
kind: 'session-reference',
form: 'recall',
version: 1,
references: [
{ label: '重构 loader', retainedMessages: 18, omittedMessages: 42, truncated: true },
{ label: '修 CI', retainedMessages: 3, omittedMessages: 0, truncated: false },
],
},
provenance: { role: 'recall', label: '重构 loader, 修 CI' },
form: 'recall',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ }))
const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent)
expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条'])
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material')
})
it('unknown nodes retain the generic JSON row', () => {
const unknownView = render(
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
@@ -545,12 +1011,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

@@ -376,6 +376,9 @@ describe('ChatView', () => {
expect(view.queryByText('later')).toBeNull()
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
expect(pendingBubble).not.toBeNull()
// Pending and durable steering carry the same interjection caption, so the
// hand-off does not change what the row says it is.
expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy()
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('interrupt now')
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
@@ -388,7 +391,8 @@ describe('ChatView', () => {
nodes: [
assistant(1, 'working'),
{
kind: 'user', seq: 2, time: 2_000,
kind: 'steering', messageId: pending.messageId,
seq: 2, time: 2_000,
content: [{ type: 'text', text: 'interrupt now' }], source: null,
},
],
@@ -396,6 +400,7 @@ describe('ChatView', () => {
})
expect(view.getAllByText('interrupt now')).toHaveLength(1)
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
expect(view.getAllByText('插话')).toHaveLength(1)
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
@@ -441,6 +446,8 @@ describe('ChatView', () => {
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
const context = {
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
provenance: { role: 'inject', label: null },
form: null,
} as const satisfies ConversationNode
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
const view = render(<h.ChatView {...h.props} />)
@@ -534,6 +541,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

@@ -15,16 +15,17 @@ type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
async function bench() {
const runtime = await SlotTestRuntime.create()
const chat = createChatStore()
// The apply.ts shape: one shared handle across both strict-session slot
// registrations ('conversation.session'/'details'); the session-maybe
// 'conversation' shell carries no store by design. The slots must first
// exist in the ledger — the test root declares them (the AppFrame role).
// The apply.ts shape: one shared handle across the strict Session header,
// body, and details registrations; the session-maybe 'conversation' shell
// carries no store by design. The slots must first exist in the ledger.
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.session.header': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
}, (_p: { renderSlot?: unknown }) => null)
runtime.slots.register({ name: 'conversation.session', store: chat }, () => null)
runtime.slots.register({ name: 'conversation.session.header', store: chat }, () => null)
runtime.slots.register({ name: 'details', store: chat }, () => null)
runtime.renderRoot() // materializes the host face storeOf resolves through
return { runtime, chat }

View File

@@ -12,12 +12,14 @@ import type {
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
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 { createChatStore } from '../src/client/stores.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { zh } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import type {
@@ -120,6 +122,33 @@ function mount(
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
slotCalls.push(key)
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
if (key === 'conversation.session.header') {
return (
<ConversationSessionHeader
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={props.useSessions}
useWorkspaces={props.useWorkspaces}
useProjection={(() => undefined)}
useInput={useInput}
inputActions={inputActions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as never}
views={{
list: () => [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
],
subscribe: () => () => {},
version: () => 1,
}}
open={open}
t={t}
/>
)
}
if (key === 'conversation.session') {
return (
<ConversationSession
@@ -143,9 +172,6 @@ function mount(
version: () => 1,
}}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
t={t}
{...owner}
/>
)
}
@@ -213,6 +239,14 @@ function mount(
}
}
describe('Hero chrome', () => {
it('renders the English preview badge through the hero locale seat', () => {
const view = render(<HeroShell t={makeTranslate(en, commonEn)} />)
expect(view.getByText('Let\'s start building')).toBeTruthy()
expect(view.getByText('Preview')).toBeTruthy()
})
})
describe('ConversationRoot resident composer', () => {
it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
const b = mount(conversationSnapshot())
@@ -273,6 +307,7 @@ describe('ConversationRoot resident composer', () => {
expect(host).not.toBeNull()
expect(header?.getAttribute('aria-hidden')).toBe('true')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByText('预览版')).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
@@ -329,7 +364,7 @@ describe('ConversationRoot resident composer', () => {
const before = b.view.getByRole('textbox')
fireEvent.change(before, { target: { value: 'kept across flip' } })
// First message landed: content exists, phase leaves blank. Composer
// already sat in the Session scrollport during hero, so the textarea
// already sat in the resident scrollport during hero, so the textarea
// node and InputHub draft both survive.
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
b.rerender()

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

@@ -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-models/README.md
README.md: c578ecfc9163245e8666cb6d2d327efdaccccf89
README.zh.md: 40da5b52f681071cb5b833866270db7b37fb0957
README.md: b55914197e472edec8a8b6d4d3e02036d1697728
README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec

View File

@@ -4,12 +4,20 @@ English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
## Model list and endpoint interrogation
A pi-ai profile's `models` list is edited on the card: one row per model showing its id and display name, with the context window and output cap behind a per-row disclosure and two label-free actions — expand and delete — on the right. An empty list means "serve this route's built-in catalog", so a row is only ever added deliberately; clearing a capacity drops it rather than storing a value the schema would reject, and the adapter's route-level fallbacks size whatever configuration leaves out — an empty capacity shows those fallbacks' magnitude as its placeholder, a hint rather than a mirror, since the field counts `K` as 1000 and a deployment may override them. A capacity that is not a positive integer is simply not stored.
**Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand.
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts.
## Model Experience
None, as the section renders a browser configuration UI; nothing here reaches a model request.
@@ -22,4 +30,6 @@ None; this package neither assembles nor sends a provider request.
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that listing shape, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.

View File

@@ -4,12 +4,20 @@
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另有 `reasoningEffort`deepseek`reasoning`pi-ai以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base而且必须先在本地化对话框中确认页面才会提交这次破坏性的 unset。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另有 `reasoningEffort`deepseek`reasoning`pi-ai以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base而且必须先在本地化对话框中确认页面才会提交这次破坏性的 unset。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置或凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型列表与端点询问
pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,行上显示 id 与显示名称,上下文窗口与输出上限收在该行的展开区内,右侧是两个无文字的操作——展开与删除。空列表意味着「使用该路由的内置 catalog」因此每一行都只会被刻意添加清空容量会丢弃它而不是存入一个 schema 会拒绝的值,配置留空的部分由适配器的路由级回退值定尺寸——留空的容量以这些回退值的量级作为占位符,那只是提示而非镜像:该字段按 1000 计 `K`,且部署可以覆盖这些回退值。不是正整数的容量根本不会被存下。
**获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema而非某个协议字段或常量因此它们不会与适配器实际接受的集合发生漂移。
## 模型体验
无。该分区渲染浏览器配置 UI这里没有任何内容进入模型请求。
@@ -22,4 +30,6 @@
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)。DeepSeek 公开 `baseURL``reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`pi-ai 公开 `baseURL``reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile却刻意不清除那条派生凭据重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。

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