Merge remote-tracking branch 'origin/master' into codex/responsive-queue-panel

# Conflicts:
#	packages/client/ui-conversation/src/client/queue/QueueDock.module.css
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
#	packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
#	packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css
This commit is contained in:
kingwl
2026-07-31 11:10:08 +08:00
1315 changed files with 45242 additions and 9111 deletions

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/README.md
README.md: 3f467641bbc9eae14a94aa2d3bff0402116a9d3f
README.zh.md: c9d11bbf6239b4239a4e037dac63b05d3a9a58f7
README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b
README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462

View File

@@ -39,6 +39,8 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface |
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |

View File

@@ -39,6 +39,8 @@
| [`session-projection/`](session-projection/README.md) | 投影 seam领域折叠单元供给全量值 | 产品:稳定表面 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 |
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |

View File

@@ -76,8 +76,8 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
1. **Every GUI code change**`pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key).
3. **Before a PR**`pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
2. **Any change that can alter the assembled browser or visible conversation/UI output** (client components or copy, `apps/web`, Vite, `dsh-host-webserver`, connection/handler/SSE) — additionally `DSH_SNAPSHOT=replay pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios. Linux PR CI uses the same read-only replay mode. Use `DSH_SNAPSHOT=refresh` only after confirming an intentional output change, or `DSH_SNAPSHOT=record` with a key to re-record fixtures.
3. **Before a PR**use [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md) to select the narrow checks for the outgoing diff; there is no repo-wide pre-push aggregate.
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
@@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
@@ -97,5 +97,5 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the l
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
4. Tokens only in CSS; Chinese product copy; English comments.
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`.
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.

View File

@@ -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/connection/README.md
README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6
README.zh.md: ca5da643db443956c25399f07c8b460900942ad4
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## /api browser-trust fence
@@ -10,7 +10,7 @@ The node half guards every request under `/api` before bridging (`src/api-reques
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
## /api 浏览器信任栅栏
@@ -10,7 +10,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token短语行为并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
## 模型体验

View File

@@ -1,12 +1,12 @@
// Central contract re-export point: every contract import inside
// web-runtime goes through this single file.
// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
// The ./api and ./client subpath exports are the browser-safe channels added for this.
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -14,6 +14,8 @@ export type {
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {
@@ -23,7 +25,11 @@ export type {
// transportError moved down to the apiproxy api layer (it belongs beside
// RpcResult, its subject); re-exported here so connection consumers keep one
// contract entry point.
export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
export {
RpcId,
SESSION_SEARCH_RESULT_LIMIT,
transportError,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'

View File

@@ -26,13 +26,14 @@ 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 type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
/** The fake carrier mints like a real one (business code never mints). */
function rpcRequest<P>(payload: P): RpcRequest<P> {
@@ -155,6 +156,35 @@ const OPENAI_REASONING = {
defaultEffort: 'medium',
}
/** Catalog served by `session.models` and `llm.models` alike (fresh copies per call). */
function fixtureModelGroups(): ModelProviderGroup[] {
return [
{
id: 'deepseek-official',
name: 'DeepSeek',
models: [
{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
description: '快速响应',
reasoning: DEEPSEEK_REASONING,
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek-V4-Pro',
description: '复杂任务',
reasoning: DEEPSEEK_REASONING,
},
],
},
{
id: 'openai',
name: 'OpenAI',
models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }],
},
]
}
function sid(id: string): SessionId {
return id as SessionId
}
@@ -546,6 +576,144 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Fixture mirror of first-party message extraction used by session-query. */
function searchBlockText(block: ContentBlock): string[] {
switch (block.type) {
case 'text':
return [block.text]
case 'reasoning':
return []
case 'tool-call':
return [block.name, block.arguments]
case 'tool-result':
return block.content.flatMap(searchBlockText)
default:
return []
}
}
/** One current-surface user/assistant/steering document, if searchable. */
function searchEventText(event: SessionEvent): string {
const content = event.type === 'user/message'
? event.data.content
: event.type === 'assistant/message' || event.type === 'steering/message'
? event.data.message.content
: undefined
if (content === undefined) return ''
return content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n')
}
interface FixtureSearchToken {
value: string
/** Inclusive code-point offset in the whitespace-normalized display text. */
start: number
/** Exclusive code-point offset in the whitespace-normalized display text. */
end: number
}
/**
* Browser-safe approximation of SQLite FTS5 unicode61 token boundaries.
* Keeping phrase matching token-based prevents the development fixture from
* promising arbitrary within-token substring behavior that production lacks.
*/
function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } {
const text = value.replace(/\s+/gu, ' ').trim()
const characters = Array.from(text)
const tokens: FixtureSearchToken[] = []
let start: number | undefined
let raw = ''
const flush = (end: number): void => {
if (start !== undefined) {
const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase()
if (folded !== '') tokens.push({ value: folded, start, end })
}
start = undefined
raw = ''
}
for (let index = 0; index < characters.length; index++) {
const character = characters[index] as string
const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '')
if (tokenBase === '') {
if (start !== undefined) raw += character
continue
}
if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) {
start ??= index
raw += character
} else {
flush(index)
}
}
flush(characters.length)
return { text, tokens }
}
interface FixturePhraseMatch {
count: number
start: number
end: number
}
/** Count exact contiguous token-phrase occurrences and retain the first display span. */
function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch {
if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 }
let count = 0
let firstStart = 0
let firstEnd = 0
for (let start = 0; start <= document.length - phrase.length; start++) {
if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue
count++
if (count === 1) {
firstStart = document[start]?.start ?? 0
firstEnd = document[start + phrase.length - 1]?.end ?? firstStart
}
}
return { count, start: firstStart, end: firstEnd }
}
/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */
function searchSnippet(value: string, matchStart: number, matchEnd: number): string {
const characters = Array.from(value)
if (characters.length <= 120) return value
const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1)
const boundedEnd = Math.min(
characters.length,
Math.max(boundedStart + 1, matchEnd),
)
const center = Math.floor((boundedStart + boundedEnd) / 2)
let start = Math.min(
characters.length - 118,
Math.max(0, center - Math.floor(118 / 2)),
)
let end = start + 118
if (start === 0) {
end = 119
} else if (end === characters.length) {
start = characters.length - 119
}
return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}`
}
interface FixtureSearchCandidate {
sessionId: SessionId
seq: number
time: number
text: string
matchCount: number
matchStart: number
matchEnd: number
documentLength: number
}
/** Mirrors `packages/session-query/session-query-sqlite/src/index.ts`; update both together. */
function compareSearchCandidates(a: FixtureSearchCandidate, b: FixtureSearchCandidate): number {
if (a.matchCount !== b.matchCount) return b.matchCount - a.matchCount
if (a.documentLength !== b.documentLength) return a.documentLength - b.documentLength
if (a.time !== b.time) return b.time - a.time
if (a.sessionId !== b.sessionId) return a.sessionId < b.sessionId ? -1 : 1
return b.seq - a.seq
}
/**
* Current plan projection over the full log (host parallel: latest todo/write
* with no later turn/start; a new turn retires the previous plan).
@@ -684,8 +852,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
session.sessionId,
{ provider: 'deepseek', model: 'deepseek-v4-flash' },
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
]))
/** Credential store double: set/unset flip the describe badge, values never read back. */
const fixtureCredentials = new Map<string, true>([
// The assembled fixture represents an already-configured shipped
// DeepSeek route so unrelated GUI journeys do not enter first-run setup.
['DEEPSEEK_API_KEY', true],
])
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
let nextSession = 1
let nextRpc = 1
@@ -952,6 +1126,45 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return {
sessions: {
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
search: (request, signal) => {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'fixture session search was aborted',
details: {},
})
}
const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value)
const matches = sessions.flatMap((summary) => {
const log = logs.get(summary.sessionId) ?? []
const current = new Set(foldSurface(log).nodes)
const best = log.flatMap((event): FixtureSearchCandidate[] => {
if (!current.has(event.seq)) return []
const eventText = searchEventText(event)
const document = searchTokenSpans(eventText)
const match = phraseMatch(document.tokens, query)
if (match.count === 0) return []
return [{
sessionId: summary.sessionId,
seq: event.seq,
time: event.time,
text: document.text,
matchCount: match.count,
matchStart: match.start,
matchEnd: match.end,
documentLength: Array.from(eventText).length,
}]
}).sort(compareSearchCandidates)[0]
return best === undefined ? [] : [best]
}).sort(compareSearchCandidates)
return ok(request, {
items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({
sessionId: match.sessionId,
snippet: searchSnippet(match.text, match.matchStart, match.matchEnd),
})),
hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT,
})
},
create: async (request) => {
const workspace = request.payload.workspaceId === undefined
? undefined
@@ -1001,7 +1214,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
}
sessions.push(created)
modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' })
modelTargets.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
attachedSessions += 1
const emitSession = (): void => {
// Mirrors the host: the frame fires at creation, so blank is constantly true.
@@ -1042,6 +1255,56 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const appended = logOf(sessionId).at(-1) as SessionEvent
return ok(request, { title: normalized, seq: appended.seq })
},
fork: (request) => {
const { sessionId, atSeq } = request.payload
const source = summaryOf(sessionId)
if (source === undefined) {
return err(request, {
code: 'session-not-found',
message: `no session ${sessionId}`,
details: { sessionId },
})
}
const log = logs.get(sessionId) ?? []
const lastSeq = log.at(-1)?.seq ?? -1
const anchoredBoundary = atSeq === undefined
? undefined
: log.find(e => e.type === 'turn/end' && e.seq >= atSeq)
const boundary = anchoredBoundary
?? (atSeq === undefined || atSeq > lastSeq
? log.findLast(e => e.type === 'turn/end')
: undefined)
if (boundary === undefined) {
return err(request, {
code: 'fork-unavailable',
message: atSeq !== undefined && atSeq <= lastSeq
? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}`
: `session ${sessionId} has no completed turn`,
details: { sessionId },
})
}
let cut = boundary.seq + 1
while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
const child: SessionSummary = {
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
parentSessionId: sessionId,
...source.cwd === undefined ? {} : { cwd: source.cwd },
}
logs.set(child.sessionId, log.slice(0, cut))
sessions.push(child)
emitHost({
type: 'host/session-added', sessionId: child.sessionId, blank: false,
parentSessionId: sessionId,
...source.cwd === undefined ? {} : { cwd: source.cwd },
})
const workspace = workspaces.find(w => w.sessionIds.includes(sessionId))
if (workspace !== undefined) {
workspace.sessionIds = [child.sessionId, ...workspace.sessionIds]
workspace.updatedAt = new Date().toISOString()
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
}
return ok(request, { sessionId: child.sessionId })
},
history: async (request) => {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
@@ -1061,32 +1324,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
models: request => ok(request, {
current: modelTargets.get(request.payload.sessionId)
?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [
{
id: 'deepseek',
name: 'DeepSeek',
models: [
{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
description: '快速响应',
reasoning: DEEPSEEK_REASONING,
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek-V4-Pro',
description: '复杂任务',
reasoning: DEEPSEEK_REASONING,
},
],
},
{
id: 'openai',
name: 'OpenAI',
models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }],
},
],
?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
groups: fixtureModelGroups(),
failures: [],
}),
selectModel: (request) => {
@@ -1331,14 +1570,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const spec = PERMISSION_PRESETS[preset]
if (preset === '') {
const current = permissionSelectOf(logOf(id)).currentValue
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else if (spec === undefined) {
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else {
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `preset ${preset}` } })
}
return ok(request, { matched: true as const, commandId })
}
@@ -1522,6 +1761,64 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
}
},
},
settings: {
// Only the resolved DeepSeek address needed by first-run readiness is
// represented here. Fixture-backed journeys do not open its Models
// editor; real schema-driven forms ride the HTTP transport.
describe: request => ok(request, {
writable: true,
namespaces: [{
ns: 'llm-deepseek',
schema: {},
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
applies: 'live',
secrets: [{ path: ['apiKey'], set: false }],
revision: 0,
}],
}),
update: request => err(request, {
code: 'settings-rejected',
message: 'fixture: the minimal readiness settings descriptor is read-only',
details: { ns: request.payload.ns },
}),
replace: request => err(request, {
code: 'settings-rejected',
message: 'fixture: the minimal readiness settings descriptor is read-only',
details: { ns: request.payload.ns },
}),
mutate: request => err(request, {
code: 'settings-rejected',
message: 'fixture: no settings namespaces are registered',
details: { ns: request.payload.ns },
}),
},
credentials: {
describe: request => ok(request, {
credentials: Object.fromEntries(request.payload.refs.map(ref => [ref, {
configured: fixtureCredentials.has(ref),
...fixtureCredentials.has(ref) ? { source: 'file' } : {},
writable: true,
}])),
}),
set: (request) => {
fixtureCredentials.set(request.payload.ref, true)
return ok(request, {})
},
unset: (request) => {
fixtureCredentials.delete(request.payload.ref)
return ok(request, {})
},
},
llm: {
providers: request => ok(request, {
providers: [
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
],
}),
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Same routing discipline as the host: rpcId first, then the payload's
// audit correlation; a settled or unknown id is not-pending.
@@ -1572,25 +1869,36 @@ export class FixtureApiClient extends AbstractApiClient {
protected override async callUnary<K extends keyof RpcMethodMap>(
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
): Promise<RpcResponse<ResponseValue<K>>> {
const request = rpcRequest(payload)
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
this.onEnvelope(full)
const response = await this.dispatch(method, request as RpcRequest<never>) as RpcResponse<ResponseValue<K>>
const response = await this.dispatch(
method,
request as RpcRequest<never>,
signal ?? new AbortController().signal,
) as RpcResponse<ResponseValue<K>>
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
this.onEnvelope(fullResponse)
return response
}
/** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
private dispatch(method: keyof RpcMethodMap, request: RpcRequest<never>): Promise<RpcResponse<unknown>> {
private dispatch(
method: keyof RpcMethodMap,
request: RpcRequest<never>,
signal: AbortSignal,
): Promise<RpcResponse<unknown>> {
switch (method) {
case 'session.list': return this.api.sessions.list(request)
case 'session.search': return this.api.sessions.search(request, signal)
case 'session.create': return this.api.sessions.create(request)
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.rename': return this.api.sessions.rename(request)
case 'session.fork': return this.api.sessions.fork(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
case 'session.cancel': return this.api.sessions.cancel(request)
@@ -1605,8 +1913,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.delete': return this.api.workspace.delete(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'command.list': return this.api.commands.list(request)
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
case 'command.execute': return this.api.commands.execute(request, signal)
case 'skill.list': return this.api.skills.list(request)
case 'goal.create': return this.api.goals.create(request)
case 'goal.edit': return this.api.goals.edit(request)
@@ -1614,6 +1921,15 @@ export class FixtureApiClient extends AbstractApiClient {
case 'goal.resume': return this.api.goals.resume(request)
case 'goal.complete': return this.api.goals.complete(request)
case 'goal.clear': return this.api.goals.clear(request)
case 'settings.describe': return this.api.settings.describe(request)
case 'settings.update': return this.api.settings.update(request)
case 'settings.replace': return this.api.settings.replace(request)
case 'settings.mutate': return this.api.settings.mutate(request)
case 'credentials.describe': return this.api.credentials.describe(request)
case 'credentials.set': return this.api.credentials.set(request)
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)
}
}

View File

@@ -11,7 +11,7 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -22,8 +22,14 @@ export type {
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
} from './api.ts'
export {
RpcId,
AbstractApiClient,
transportError,
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.

View File

@@ -33,10 +33,38 @@ export const Config: z<ConnectionConfig> = z.object({
trustedHosts: z.array(String).default([]),
})
/**
* Methods gated to loopback even on a trusted-host deployment. Native dialogs
* act on the host machine; the settings and credential domains mutate the
* user's configuration and secret store, and READING them is equally
* privileged — `settings.describe` returns every exposed namespace's
* configuration and `credentials.describe` reports whether an arbitrary
* environment-variable name is configured and where from, which is
* 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.
*/
const PRIVILEGED_METHODS = new Set([
'host.pickDirectory',
'host.openPath',
'settings.describe',
'settings.update',
'settings.replace',
'credentials.describe',
'credentials.set',
'credentials.unset',
])
/**
* Mounts the API gateway under the browser transport prefix. Every request on
* the prefix passes the browser-trust fence first (DNS-rebinding and
* cross-site defense — [api-request-trust](./api-request-trust.ts)).
* cross-site defense — [api-request-trust](./api-request-trust.ts));
* privileged methods additionally pass it with an empty trust list, which
* pins them to loopback.
* @param ctx - Host plugin context.
* @param config - resolved plugin config (schema defaults applied).
*/
@@ -51,7 +79,14 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
kind: 'prefix',
path: API_PATH,
handler: async (req, res) => {
if (!isTrustedApiRequest(req, trustedHosts)) {
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const method = pathname.startsWith(`${API_PATH}/`)
? pathname.slice(API_PATH.length + 1)
: undefined
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
? isTrustedApiRequest(req, [])
: isTrustedApiRequest(req, trustedHosts)
if (!allowed) {
res.writeHead(403)
res.end('forbidden')
return

View File

@@ -4,7 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -44,18 +44,21 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: { provider: 'deepseek', model: 'deepseek-chat' },
current: { provider: 'deepseek-official', model: 'deepseek-chat' },
groups: [],
failures: [],
}))
@@ -86,12 +89,17 @@ export class FakeApiClient implements IApiClient {
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
lastSearchSignal: AbortSignal | undefined
// Parameter annotations below are local structural types on purpose: the CI
// lint lane runs without built artifacts, where IApiClient's wire types
// (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
search: (payload: unknown, signal?: AbortSignal) => {
this.lastSearchSignal = signal
return this.record('session.search', payload, this.onSearch(payload))
},
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
@@ -99,6 +107,7 @@ export class FakeApiClient implements IApiClient {
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
@@ -154,6 +163,24 @@ export class FakeApiClient implements IApiClient {
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
}
readonly credentials: IApiClient['credentials'] = {
describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))),
set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))),
unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))),
}
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: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -48,6 +48,59 @@ describe('createFixtureApi', () => {
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
})
it('searches current message text with literal unicode61-style token phrases', async () => {
const api = createFixtureApi()
const signal = new AbortController().signal
const phrase = await api.sessions.search(req({ query: 'FIXTURE 历史消息' }), signal)
expect(phrase.result).toMatchObject({
ok: true,
value: {
items: [{ sessionId: 'fx-alpha' }],
hasMore: false,
},
})
if (!phrase.result.ok) throw new Error('search failed')
expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息')
timing().appendUser(
'fx-alpha',
`${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`,
)
const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal)
if (!late.result.ok) throw new Error('late search failed')
const lateSnippet = late.result.value.items[0]?.snippet ?? ''
expect(lateSnippet).toContain('late café token')
expect(lateSnippet.startsWith('…')).toBe(true)
expect(lateSnippet.endsWith('…')).toBe(true)
expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120)
timing().appendUser('fx-alpha', 'Greek final sigma: ος')
const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal)
if (!finalSigma.result.ok) throw new Error('final sigma search failed')
expect(finalSigma.result.value.items[0]?.snippet).toContain('ος')
const substring = await api.sessions.search(req({ query: 'ixtur' }), signal)
expect(substring.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const punctuationOnly = await api.sessions.search(req({ query: '*' }), signal)
expect(punctuationOnly.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const reasoningOnly = await api.sessions.search(req({ query: '思考过程' }), signal)
expect(reasoningOnly.result).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
const aborted = new AbortController()
aborted.abort()
await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal))
.resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } })
})
it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
const api = createFixtureApi()
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
@@ -119,6 +172,36 @@ describe('createFixtureApi', () => {
expect(JSON.stringify(after.result.value.events)).toContain('openai/gpt-5')
})
it('serves configured DeepSeek readiness and keeps credential values write-only', async () => {
const api = createFixtureApi()
const settings = await api.settings.describe(req({}))
if (!settings.result.ok) throw new Error('settings describe failed')
expect(settings.result.value.namespaces).toMatchObject([{
ns: 'llm-deepseek',
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
secrets: [{ path: ['apiKey'], set: false }],
}])
const initial = await api.credentials.describe(req({ refs: ['DEEPSEEK_API_KEY', 'TEST_API_KEY'] }))
if (!initial.result.ok) throw new Error('credential describe failed')
expect(initial.result.value.credentials).toEqual({
DEEPSEEK_API_KEY: { configured: true, source: 'file', writable: true },
TEST_API_KEY: { configured: false, writable: true },
})
await api.credentials.set(req({ ref: 'TEST_API_KEY', value: 'write-only-fixture-secret' }))
const configured = await api.credentials.describe(req({ refs: ['TEST_API_KEY'] }))
if (!configured.result.ok) throw new Error('credential describe failed')
expect(configured.result.value.credentials.TEST_API_KEY).toEqual({
configured: true,
source: 'file',
writable: true,
})
await api.credentials.unset(req({ ref: 'TEST_API_KEY' }))
const cleared = await api.credentials.describe(req({ refs: ['TEST_API_KEY'] }))
if (!cleared.result.ok) throw new Error('credential describe failed')
expect(cleared.result.value.credentials.TEST_API_KEY).toEqual({ configured: false, writable: true })
})
it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => {
const api = createFixtureApi()
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
@@ -789,6 +872,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
it('covers the whole unary dispatch table', async () => {
const client = new FixtureApiClient()
expect((await client.sessions.search(
{ query: 'fixture' },
new AbortController().signal,
)).result.ok).toBe(true)
const created = await client.sessions.create({})
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId

View File

@@ -1,8 +1,10 @@
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { EventEmitter } from 'node:events'
import { createServer, request as httpRequest } from 'node:http'
import { Readable } from 'node:stream'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
@@ -21,9 +23,9 @@ function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register'
}
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
function fakeRequest(headers: Record<string, string>): IncomingMessage {
function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session.list`): IncomingMessage {
const request = Readable.from([]) as unknown as IncomingMessage
Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers })
Object.assign(request, { url, method: 'GET', headers })
return request
}
@@ -97,6 +99,31 @@ describe('connection node half', () => {
await dispose()
})
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
// 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.update', 'settings.replace',
'credentials.describe', 'credentials.set', 'credentials.unset',
]) {
const denied = fakeResponse()
await routes[0]!.handler(
fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`),
denied.response,
)
expect(denied.state.status).toBe(403)
expect(denied.state.body).toBe('forbidden')
}
const read = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: 'harness.example' }), read.response)
expect(read.state.status).not.toBe(403)
await dispose()
})
it('passes loopback and declared-authority requests through to the bridge', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
// Loopback, no browser markers (curl shape): the fence passes; the carrier
@@ -118,3 +145,69 @@ describe('connection node half', () => {
await dispose()
})
})
describe('connection node half over a real HTTP server', () => {
/** Serve the registered prefix route from a real server and return its port. */
async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
const server = createServer((request, response) => {
void routes[0]!.handler(request, response)
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as AddressInfo
return {
port: address.port,
close: () => new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error === undefined || error === null) resolve()
else reject(error)
})
}),
}
}
/** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
function call(port: number, method: string, host: string): Promise<number> {
return new Promise((resolve, reject) => {
const request = httpRequest(
{ host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
(response) => {
response.resume()
response.on('end', () => { resolve(response.statusCode ?? 0) })
},
)
request.on('error', reject)
request.end()
})
}
it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
// The fence's input is a real IncomingMessage parsed by Node from the
// wire, not a hand-assembled object: the Host header a LAN browser sends
// is exactly what decides loopback-only here, so the boundary is asserted
// against the parse the server actually performs.
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
const { port, close } = await serve(routes)
try {
// Reads are as privileged as writes: describe returns the exposed
// configuration, and credentials.describe probes arbitrary env-var names.
for (const method of [
'settings.describe', 'settings.update', 'settings.replace',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
]) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
}
// The model catalog stays reachable for the same authority: a LAN
// client's model picker needs it, and it carries no key or endpoint
// state (404 is the empty proxy's carrier answer — the fence passed).
for (const method of ['llm.providers', 'llm.models']) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
}
// Loopback reaches everything, configuration included.
expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
} finally {
await close()
await dispose()
}
})
})

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/modules/README.md
README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72
README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f

View File

@@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
## Model Experience
None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.

View File

@@ -8,6 +8,8 @@
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR热模块替换钩子。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
## 模型体验
无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。

View File

@@ -58,6 +58,47 @@ interface PkgMeta {
immediately: boolean
}
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
/** Missing built client export, retained as structured data for activation-error grouping. */
class MissingClientBundleError extends Error {
constructor(
readonly packageName: string,
readonly clientPath: string,
cause: unknown,
) {
super(
[
`client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
` package: ${packageName}`,
` path: ${clientPath}`,
].join('\n'),
{ cause },
)
}
}
/** Activation failures grouped by actionable package-build errors and unrelated failures. */
class ClientPackageCompositionError extends AggregateError {
constructor(failures: Error[]) {
const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
const packageNoun = failures.length === 1 ? 'package' : 'packages'
const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
if (missingBundles.length > 0) {
lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
for (const error of missingBundles) {
lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`)
}
}
if (otherFailures.length > 0) {
lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`))
}
super(failures, lines.join('\n'))
}
}
/** One composed table row: the wire entry plus its bundle path. */
interface WebPluginRecord {
entry: WebBootEntry
@@ -138,7 +179,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
* + bundle route + index tap. Construction runs the activation scan
* synchronously — a malformed declaration or missing bundle among the
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
* boot sweep reports it).
* boot activation audit reports it).
*/
export class ClientModuleHostService extends Service {
static inject = ['httpServer', 'loader']
@@ -194,10 +235,7 @@ export class ClientModuleHostService extends Service {
const failures: Error[] = []
this.flush(err => failures.push(err))
if (failures.length > 0) {
throw new AggregateError(
failures,
`client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`,
)
throw new ClientPackageCompositionError(failures)
}
ctx.effect(
@@ -322,6 +360,22 @@ export class ClientModuleHostService extends Service {
return meta
}
/**
* Read the activation-time bundle revision.
* @param pkgName - package that declares the client bundle.
* @param clientPath - absolute path of the built client artifact.
* @returns the bundle content's short hash for use as its revision.
* @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
*/
private initialBundleRevision(pkgName: string, clientPath: string): string {
try {
return shortHash(readFileSync(clientPath))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
throw new MissingClientBundleError(pkgName, clientPath, error)
}
}
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
private processOne(entryName: string): boolean {
let qualifies = false
@@ -337,7 +391,7 @@ export class ClientModuleHostService extends Service {
if (meta === null) return false
// The rev rides the row from here on: a fiber restart reuses the row (and
// its rev) untouched; only rebuilt() re-reads the bundle.
const rev = shortHash(readFileSync(meta.clientPath))
const rev = this.initialBundleRevision(entryName, meta.clientPath)
this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
return true
}

View File

@@ -0,0 +1,87 @@
/** Node-half composition diagnostics for package metadata and built client bundles. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import { ClientModuleHostService } from '../src/index.ts'
let root: string | undefined
afterEach(() => {
if (root !== undefined) rmSync(root, { recursive: true, force: true })
root = undefined
})
/** Create a resolvable dshClient package whose client export points at the returned path. */
function writePackage(packageName: string): string {
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
const clientPath = join(pkgRoot, 'lib', 'client.js')
mkdirSync(pkgRoot, { recursive: true })
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
name: packageName,
exports: {
'./client': './lib/client.js',
'./package.json': './package.json',
},
dshClient: { platform: 'web' },
}))
return clientPath
}
/** Construct the node-half service over the enabled fixture entries. */
function construct(packageNames: string[]): ClientModuleHostService {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(root!).href + '/'
ctx.provide('loader', {
*entries() {
for (const packageName of packageNames) {
yield { options: { name: packageName }, fiber: {}, disabled: false }
}
},
})
const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
port: 0,
register: () => () => {},
tapIndex: () => () => {},
}
ctx.provide('httpServer', httpServer as HttpServerService)
return new ClientModuleHostService(ctx)
}
describe('client bundle activation', () => {
it('groups missing bundles under one source-build instruction with a package/path list', () => {
const firstName = '@fixture/missing-first'
const secondName = '@fixture/missing-second'
const firstPath = writePackage(firstName)
const secondPath = writePackage(secondName)
expect(() => construct([firstName, secondName])).toThrow([
'client-modules: 2 client packages failed to compose:',
' client bundles not found; run `pnpm run build` before launch:',
` - package: ${firstName}`,
` path: ${firstPath}`,
` - package: ${secondName}`,
` path: ${secondPath}`,
].join('\n'))
})
it('does not report other bundle read failures as missing builds', () => {
const packageName = '@fixture/unreadable-client'
const clientPath = writePackage(packageName)
mkdirSync(clientPath, { recursive: true })
let thrown: unknown
try {
construct([packageName])
} catch (error) {
thrown = error
}
expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
expect(String(thrown)).toContain(' other failures:')
expect(String(thrown)).toContain('EISDIR')
expect(String(thrown)).not.toContain('pnpm run build')
})
})

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: 766d8516225cd46cb1a3a80c832d1cf55e816140
README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692
README.md: 12023868c577ebcae6898d13358a2456295496c2
README.zh.md: 7ef4c93d36b3f0b32c0bfcf8a38892260240c74f

View File

@@ -2,7 +2,7 @@
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; 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. 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`.
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; 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`.
## Workspace and Session lists
@@ -12,6 +12,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
## New Session and the blank mirror
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
@@ -28,6 +30,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
## Session forking
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `N` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.
## Session model selection
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端会话一律由 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`
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本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`
## Workspace 与 Session 列表
@@ -12,6 +12,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit``SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
## New Session 与 blank 镜像
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表界面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
@@ -28,6 +30,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值直到打开或恢复会话促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
## 会话 fork
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd`blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)``N` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。
## 会话模型选择
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle``loading``ready``selecting``error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。

View File

@@ -8,8 +8,9 @@
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionSearchResultItem } from '../sessions/manager.ts'
import type {
SessionBinding, SessionListState, SessionProvideDescriptor,
} from '../sessions/service.ts'
@@ -22,6 +23,12 @@ export interface ISessions {
readonly list: ObservableSnapshot<SessionListState>
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* The `session.search` result bound the wire schema fixes, exposed to
* presentation as injected data. Not per-connection state: every transport
* (fixture included) reports the same number.
*/
readonly searchResultLimit: number
/**
* Select a session as current.
* @param id - session id (must exist in the list; unknown ids fail loud).
@@ -29,6 +36,28 @@ export interface ISessions {
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* Search the Host's visible message-content index. Results stay
* request-local; the list snapshot remains the metadata authority.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search.
* @returns bounded results, or a business/transport error.
*/
search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
*/
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
/**
* Register a per-session standard-props provider (hooks become `use<Name>`
* selector hooks on the render side; props spread verbatim).

View File

@@ -31,7 +31,7 @@ export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase } from './sessions/manager.ts'
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {
@@ -122,6 +122,28 @@ declare module 'cordis' {
* @mode emit
*/
'commands/changed'(): void
/**
* One settings namespace's resolved value changed on the host
* (host/settings-changed passthrough). Subscribers refetch
* `settings.describe`; the frame carries no values.
* @mode emit
* @param ns - the namespace whose resolved value changed.
*/
'settings/changed'(ns: string): void
/**
* One credential reference's state changed on the host
* (host/credentials-changed passthrough). The ref is an
* environment-variable NAME — never a value.
* @mode emit
* @param ref - the reference whose configured state changed.
*/
'credentials/changed'(ref: string): void
/**
* The host provider topology changed (host/models-changed passthrough).
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
* @mode emit
*/
'models/changed'(): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull (commands directory; the queue
@@ -170,8 +192,13 @@ export function apply(ctx: Context): void {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches) subscribe on ctx.
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
// session routing); consumers (command directory caches, the settings
// and model surfaces) subscribe on ctx.
const frame = envelope.payload
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
try {
sessionHistory.handleHostEnvelope(envelope)
} catch (error) {

View File

@@ -1,12 +1,14 @@
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50
@@ -33,6 +35,9 @@ export class SessionHistorySource implements SessionHistoryFace {
entries: readonly HistoryEntry[]
value: SessionHistorySnapshot['inspection']
} | null = null
private streamPublishToken: object | null = null
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
@@ -125,7 +130,7 @@ export class SessionHistorySource implements SessionHistoryFace {
if (this.state !== 'cold') {
this.state = 'cold'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
}
}
@@ -143,7 +148,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.hasMore = false
this.state = 'cold'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
void this.loadForConsumers()
}
@@ -155,6 +160,9 @@ export class SessionHistorySource implements SessionHistoryFace {
this.openPromise = null
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
}
private open(): Promise<void> {
@@ -188,7 +196,7 @@ export class SessionHistorySource implements SessionHistoryFace {
private async doOpen(generation: number): Promise<void> {
this.state = 'loading'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
try {
let { result } = await this.api.sessions.history({
sessionId: this.sessionId,
@@ -222,7 +230,7 @@ export class SessionHistorySource implements SessionHistoryFace {
/* v8 ignore next -- transportError always returns the error branch. */
this.error = folded.ok ? null : folded.error
} finally {
if (generation === this.generation) this.notifier.markDirty()
if (generation === this.generation) this.publishDirtyNow()
}
}
@@ -261,7 +269,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const settled = operation.finally(() => {
if (this.olderPromise !== settled) return
this.olderPromise = null
this.notifier.markDirty()
this.publishDirtyNow()
})
this.olderPromise = settled
return settled
@@ -286,7 +294,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
this.notifier.markDirty()
this.publishDirtyNow()
}
private acceptLive(entry: HistoryEntry): void {
@@ -301,8 +309,16 @@ export class SessionHistorySource implements SessionHistoryFace {
void this.repairGap()
return
}
if (
entry.event.type === 'assistant/chunk'
&& entry.event.data.chunk.type !== 'usage'
) {
if (!this.appendIncrementalChunk(entry, entry.event)) return
this.publishStreamDirty()
return
}
this.appendLive(entry)
this.notifier.markDirty()
this.publishDirtyNow()
}
private appendLive(entry: HistoryEntry): void {
@@ -311,6 +327,66 @@ export class SessionHistorySource implements SessionHistoryFace {
this.entries = [...this.entries, entry]
}
/** Append a chunk against the cached finalized projection; false means no visible publish. */
private appendIncrementalChunk(
entry: HistoryEntry,
event: SessionEvent<'assistant/chunk'>,
): boolean {
const { turn, step, chunk } = event.data
if (!isVisibleAssistantChunk(chunk.type)) {
const inspection = this.currentInspection()
this.appendLive(entry)
this.inspectionCache = { entries: this.entries, value: inspection }
return false
}
const base = this.streamBaseInspection ?? this.currentInspection()
this.streamBaseInspection = base
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
|| this.streamPartial.step !== step
) {
const current = base.partial
this.streamPartial = new PartialAccumulator(
turn,
step,
current?.turn === turn && current.step === step ? current.blocks : [],
)
}
this.streamPartial.push(chunk)
this.appendLive(entry)
this.inspectionCache = {
entries: this.entries,
value: { ...base, partial: this.streamPartial.toPartial() },
}
return true
}
/** Coalesce token-stream projection and rendering work to one publish per browser frame. */
private publishStreamDirty(): void {
if (this.streamPublishToken !== null) return
const token = {}
this.streamPublishToken = token
const publish = () => {
if (this.streamPublishToken !== token) return
this.streamPublishToken = null
this.notifier.markDirty()
}
if (typeof globalThis.requestAnimationFrame === 'function') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
this.notifier.markDirty()
}
private async repairGap(): Promise<void> {
if (this.stitching) return
this.stitching = true
@@ -335,6 +411,16 @@ export class SessionHistorySource implements SessionHistoryFace {
}
private buildSnapshot(): SessionHistorySnapshot {
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.currentInspection(),
}
}
/** Inspection pinned to the source's current immutable entry array. */
private currentInspection(): SessionHistorySnapshot['inspection'] {
if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries
this.inspectionCache = {
@@ -342,11 +428,14 @@ export class SessionHistorySource implements SessionHistoryFace {
value: createHistoryInspection(() => entries),
}
}
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.inspectionCache.value,
}
return this.inspectionCache.value
}
}
function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}

View File

@@ -2,7 +2,10 @@
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -27,6 +30,12 @@ import { Session } from './session.ts'
*/
export type SessionListPhase = 'pending' | 'ready'
/** Request-local content hit returned to sidebar search consumers. */
export interface SessionSearchResultItem {
sessionId: SessionId
snippet: string
}
/** Immutable session-list snapshot for useSessionList. */
export interface SessionListSnapshot {
items: readonly SessionListEntry[]
@@ -248,6 +257,24 @@ export class SessionManager {
return this.listInflight
}
/**
* Search visible session message content without adding transient query
* state to the list snapshot.
* @param query - non-blank literal phrase.
* @param signal - cancellation for superseded UI queries.
* @returns the Host result or a folded transport error.
*/
async search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
try {
return (await this.api.sessions.search({ query }, signal)).result
} catch (error: unknown) {
return transportError(error)
}
}
/**
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh). A created session is blank by definition
@@ -289,6 +316,40 @@ export class SessionManager {
}
}
/**
* Contract session.fork; on success merge the child into summaries
* immediately (same synchronous-addressability guarantee as create). The
* child carries the source's history, so it is never blank; lineage rides
* parentSessionId so the list nests it under its source. A child published
* before Workspace attachment fails is also reconciled into the list.
* @param opts - source session and the optional seq anchoring the cut.
* @returns the fork result (the child session id).
*/
async fork(
opts: { sessionId: SessionId; atSeq?: number },
): Promise<RpcResult<{ sessionId: SessionId }>> {
try {
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
const { result } = await this.api.sessions.fork({
sessionId: opts.sessionId,
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
})
const childId = result.ok
? result.value.sessionId
: workspaceAttachSessionId(result.error)
if (childId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
parentSessionId: opts.sessionId,
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
} })
}
return result
} catch (error) {
return transportError(error)
}
}
/**
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
* existing entry only gains fields it lacks (the session-added frame and the

View File

@@ -13,8 +13,18 @@ export class PartialAccumulator {
private changed = true
private snapshot: PartialAssistant
constructor(readonly turn: number, readonly step: number) {
this.snapshot = { turn, step, blocks: [] }
/**
* @param turn - Owning agent turn.
* @param step - Owning model step.
* @param initialBlocks - Materialized prefix when accumulation begins after history replay.
*/
constructor(
readonly turn: number,
readonly step: number,
initialBlocks: readonly AssistantBlock[] = [],
) {
this.blocks = [...initialBlocks]
this.snapshot = { turn, step, blocks: initialBlocks }
}
/**

View File

@@ -16,7 +16,12 @@
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -26,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
import type { ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
@@ -81,6 +86,22 @@ export class SessionCreateError extends Error {
}
}
/** Structured session-fork failure. */
export class SessionForkError extends Error {
override readonly name = 'SessionForkError'
/**
* @param rpcError - Host business or folded transport error.
* @param sourceSessionId - the session the fork was cut from.
*/
constructor(
readonly rpcError: RpcError,
readonly sourceSessionId: SessionId,
) {
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
readonly sessionId: SessionId
@@ -121,6 +142,24 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id:
return id
}
/**
* Increment a trailing fork number while preserving its half-width or
* full-width parentheses; an unnumbered title starts with ` (1)`.
* @param title - source session's durable title.
* @returns the title assigned to the fork child.
*/
function increasedForkTitle(title: string): string {
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
}
const fullWidth = /^(.*?)(\d+)$/u.exec(title)
if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) {
return `${fullWidth[1]}${BigInt(fullWidth[2]) + 1n}`
}
return `${title} (1)`
}
interface ScopeRecord {
fiber: Fiber
ctx: Context
@@ -155,6 +194,13 @@ export interface SessionProvideDescriptor {
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService implements ISessions {
/**
* The wire schema's own result bound, re-exposed for presentation plugins as
* injected data. Not per-connection state: the `session.search` response
* schema caps `items` at this constant, so every transport (fixture included)
* reports the same number.
*/
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry. */
@@ -194,7 +240,10 @@ export class SessionsService implements ISessions {
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
constructor(
private readonly rootCtx: Context,
api: IApiClient,
) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
@@ -273,6 +322,20 @@ export class SessionsService implements ISessions {
return this.manager.refreshList()
}
/**
* Search the Host's visible message-content index. Results stay
* request-local; the list snapshot remains the metadata authority.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search.
* @returns bounded results or a business/transport error.
*/
search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
return this.manager.search(query, signal)
}
/**
* Route a mux stream envelope into the Session object layer.
* @param envelope - validated mux stream envelope.
@@ -317,6 +380,42 @@ export class SessionsService implements ISessions {
return result.value.sessionId
}
/**
* Fork a session from a completed-turn prefix of the source (same
* synchronous-addressability guarantee as {@link SessionsService.create}:
* on resolution the child is in the list store and open() can target it).
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
*/
async fork(opts: {
sessionId: SessionId
atSeq?: number
increaseTitle?: boolean
}): Promise<SessionId> {
const sourceTitle = opts.increaseTitle
? this.list.getSnapshot().byId[opts.sessionId]?.title
: undefined
const result = await this.manager.fork({
sessionId: opts.sessionId,
...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
const childId = result.value.sessionId
if (sourceTitle !== undefined) {
const child = this.binding(childId)?.session
if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`)
const renamed = await child.rename(increasedForkTitle(sourceTitle))
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
}
return childId
}
/**
* Resolve an Agent-scoped context view (use-and-discard).
* @param id - session id (the agent identity — 1:1 same axis).

View File

@@ -7,6 +7,7 @@ import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import * as RuntimeClient from '../src/client/index.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
@@ -50,6 +51,8 @@ describe('runtime client apply', () => {
const workspaces = bench.ctx.get('workspaces')
expect(sessions !== undefined).toBe(true)
expect(workspaces !== undefined).toBe(true)
// The bound the wire schema enforces, not a per-connection negotiation.
expect((sessions as SessionsService).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
expect(bench.sinks).toBeDefined()

View File

@@ -4,7 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -61,9 +61,12 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
@@ -71,7 +74,7 @@ export class FakeApiClient implements IApiClient {
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: this.defaultModel,
groups: [{
id: 'deepseek',
id: 'deepseek-official',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
}],
@@ -105,12 +108,17 @@ export class FakeApiClient implements IApiClient {
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
lastSearchSignal: AbortSignal | undefined
// Parameters carry local structural annotations: the CI lint lane runs
// without built lib/, so IApiClient's indexed-access types collapse to any
// and inferred parameters would trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
search: (payload: unknown, signal?: AbortSignal) => {
this.lastSearchSignal = signal
return this.record('session.search', payload, this.onSearch(payload))
},
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
@@ -118,6 +126,7 @@ export class FakeApiClient implements IApiClient {
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
@@ -181,6 +190,24 @@ export class FakeApiClient implements IApiClient {
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
}
readonly credentials: IApiClient['credentials'] = {
describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))),
set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))),
unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))),
}
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: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -206,6 +206,49 @@ describe('list lifecycle', () => {
})
})
describe('search', () => {
it('returns bounded Host results and forwards the caller signal', async () => {
const api = new FakeApiClient()
api.onSearch = () => Promise.resolve(ok({
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
hasMore: true,
}))
const manager = new SessionManager(api)
const signal = new AbortController().signal
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
ok: true,
value: {
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
hasMore: true,
},
})
expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }])
expect(api.lastSearchSignal).toBe(signal)
})
it('preserves business errors and folds transport failures', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
api.onSearch = () => Promise.resolve(err({
code: 'internal',
message: 'index unavailable',
details: {},
}))
const signal = new AbortController().signal
await expect(manager.search('first', signal)).resolves.toMatchObject({
ok: false,
error: { code: 'internal', message: 'index unavailable' },
})
api.onSearch = () => Promise.reject(new Error('wire down'))
await expect(manager.search('second', signal)).resolves.toMatchObject({
ok: false,
error: { code: 'internal', message: 'wire down' },
})
})
})
describe('host frame routing', () => {
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
const api = new FakeApiClient()
@@ -277,6 +320,23 @@ describe('remaining branches', () => {
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
})
it('reconciles a fork child published before workspace attachment fails', async () => {
const api = new FakeApiClient()
api.onFork = () => Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'forked but unattached',
details: { sessionId: S2, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api)
const result = await manager.fork({ sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
sessionId: S2,
parentSessionId: S1,
blank: false,
})])
})
it('reconciles a preallocated id after an ordinary transport failure', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.reject(new Error('response lost'))
@@ -361,7 +421,7 @@ describe('connected generation', () => {
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
const manager = new SessionManager(api)
const openedSession = manager.get(S1)

View File

@@ -41,6 +41,12 @@ describe('PartialAccumulator', () => {
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
})
it('continues from a materialized history prefix', () => {
const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }])
acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }])
})
it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionHistorySource } from '../src/client/session-history/source.ts'
@@ -7,6 +7,10 @@ import { entries, ev, plainTurn } from './event-script.ts'
const SID = 'history-s1' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
@@ -52,6 +56,71 @@ describe('SessionHistorySource', () => {
.toEqual([1, 3, 6])
})
it('publishes multiple assistant chunks once per browser frame', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const unsubscribe = source.subscribe(() => { notifications++ })
const before = source.getSnapshot().inspection
const finalizedNodes = before.eventNodes
const requests = before.requests
const contexts = before.contexts
for (const event of [
ev.chunkStart(6, 1),
ev.chunkText(7, 1, 'stream '),
ev.chunkText(8, 1, 'content'),
]) {
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event,
})
}
expect(frames).toHaveLength(1)
expect(notifications).toBe(0)
frames[0]?.(0)
await Promise.resolve()
expect(notifications).toBe(1)
const streamed = source.getSnapshot().inspection
expect(streamed.eventNodes).toBe(finalizedNodes)
expect(streamed.requests).toBe(requests)
expect(streamed.contexts).toBe(contexts)
expect(streamed.partial?.blocks).toEqual([
{ kind: 'text', text: 'stream content' },
])
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.chunkText(9, 1, ' then final'),
})
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.assistant(10, 1, 'stream content then final'),
})
await Promise.resolve()
expect(notifications).toBe(2)
const finalized = source.getSnapshot().inspection
expect(finalized.eventNodes).not.toBe(finalizedNodes)
expect(finalized.partial).toBeNull()
frames[1]?.(0)
await Promise.resolve()
expect(notifications).toBe(2)
unsubscribe()
})
it('stops loading when an older page fails to advance', async () => {
const api = new FakeApiClient()
api.onHistory = payload => payload.beforeSeq === undefined

View File

@@ -78,7 +78,7 @@ describe('open', () => {
gate.resolve(ok({
events: entries(page) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await opening
const seqs = session.getSnapshot().nodes.map(n => n.seq)
@@ -135,7 +135,7 @@ describe('live event path', () => {
expect(session.getSnapshot().composerPhase).toBe('blank')
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access'))
const snapshot = session.getSnapshot()
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
expect(snapshot.composerPhase).toBe('blank')
@@ -255,7 +255,7 @@ describe('paging', () => {
gate.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await Promise.all([first, second])
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
@@ -571,7 +571,7 @@ describe('remaining branches', () => {
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
modelTarget: { provider: 'deepseek-official', model: 'stale' },
})) // success, but its generation is gone
await Promise.all([opening, resynced])
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
@@ -594,7 +594,7 @@ describe('remaining branches', () => {
secondPull.resolve(ok({
events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
modelTarget: { provider: 'deepseek-official', model: 'stale' },
}))
await Promise.all([opening, resynced])
expect(session.getSnapshot().openState).toBe('open')
@@ -612,7 +612,7 @@ describe('remaining branches', () => {
repairPull.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
modelTarget: { provider: 'deepseek-official', model: 'stale' },
})) // repair result: stale, dropped
await resynced
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
@@ -657,7 +657,7 @@ describe('remaining branches', () => {
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
] as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await session.open()
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({

View File

@@ -10,7 +10,7 @@ import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
@@ -69,6 +69,29 @@ describe('list store projection', () => {
})
})
describe('search', () => {
it('delegates transient content search without changing the list snapshot', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const before = b.svc.list.getSnapshot()
b.api.onSearch = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }],
hasMore: false,
}))
const signal = new AbortController().signal
await expect(b.svc.search('needle', signal)).resolves.toEqual({
ok: true,
value: {
items: [{ sessionId: 's1', snippet: 'matching excerpt' }],
hasMore: false,
},
})
expect(b.api.lastSearchSignal).toBe(signal)
expect(b.svc.list.getSnapshot()).toBe(before)
})
})
describe('scope tree', () => {
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
const b = bench()
@@ -399,6 +422,69 @@ describe('create', () => {
})
})
describe('fork', () => {
it.each([
['Roadmap', 'Roadmap (1)'],
['Roadmap (1)', 'Roadmap (2)'],
['计划1', '计划2'],
['计划 9', '计划 10'],
])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => {
const b = bench()
b.svc.handleMuxEnvelope({
rpcId: 'source-title' as never,
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never,
})
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
b.api.onRename = (payload) => {
const { title } = payload as { title: string }
return Promise.resolve(ok({ title, seq: 3 }))
}
await expect(b.svc.fork({
sessionId: sid('source'), atSeq: 7, increaseTitle: true,
})).resolves.toBe('child')
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }])
expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }])
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
title: childTitle,
displayTitle: childTitle,
parentId: 'source',
})
})
it('does not rename without the title policy or a durable source title', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child')
expect(b.api.callsOf('session.rename')).toEqual([])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') }))
await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2')
expect(b.api.callsOf('session.rename')).toEqual([])
})
it('rejects when child rename fails while keeping the published child addressable', async () => {
const b = bench()
b.svc.handleMuxEnvelope({
rpcId: 'source-title' as never,
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never,
})
await feedList(b, [{ id: 'source' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
b.api.onRename = () => Promise.resolve(err({
code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') },
}))
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
.rejects.toThrow('fork child rename failed: title-invalid: rejected')
expect(b.svc.binding(sid('child'))).toBeDefined()
})
})
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
const b = bench()

View File

@@ -44,6 +44,22 @@ describe('wire event bridge', () => {
expect(changed).toBe(1)
})
it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => {
const bench = await mount()
const seen: unknown[][] = []
bench.ctx.on('settings/changed', ns => seen.push(['settings', ns]))
bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref]))
bench.ctx.on('models/changed', () => seen.push(['models']))
bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } })
expect(seen).toEqual([
['settings', 'llm-pi-ai'],
['credentials', 'OPENAI_API_KEY'],
['models'],
])
})
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
const bench = await mount()
let resets = 0

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/schema-form/README.md
README.md: 5dcef89cbffc8b03c3f2d874e870fa9767360d3c
README.zh.md: a82acb7d85005da25858fb17cf42b49f06ae59db

View File

@@ -0,0 +1,23 @@
# @deepseek-ai/dsh-client-schema-form
English | [中文](README.zh.md)
Schema/draft model layer for settings editors. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `rehydrateSchema` turns it back into a live validator with `new Schema(json)` — the same schema object that validates a section on the host validates drafts in the browser, so client-side validation never drifts from the seam's. Editors render their own controls (the Models page hand-writes its card around the fields it probes here); this package owns no React and no rendering.
## Contract
The unit of editing is a **draft user section**: a plain object edited immutably (`setPath` materializes intermediates, `deletePath` is the per-field reset — dropping the key falls the resolved value back to the composition base and schema defaults). A field's presence in the draft marks it **overridden** (`hasPath`) — presence semantics, not value comparison, exactly mirroring the settings seam's layering. `nodeAtPath` resolves the schema node addressed by a configurable-provider directory `settingsPath` (object properties by name, dict entries through `inner`), so an editor can probe which fields a provider's profile carries (and their `meta.role`) before deciding what to render; an unresolvable path returns `undefined` so the caller degrades loudly instead of rendering a wrong subtree. `validateDraft(schema, draft)` runs the rehydrated validator and returns its failure message, letting pages reject an invalid draft before writing.
## Model Experience
None, as this package backs browser configuration editors; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. That is acceptable only because the envelope comes from the same host that serves the page; a browser schema protocol should carry a description the client cannot execute, which is deferred with the settings seam's [wire-boundary work](../../settings/settings/README.md#known-limitations-and-deferred-work).
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); per-field error mapping is deferred until a consumer needs it.
- **No generic renderer** — a schema-driven form component was built and then replaced by the hand-written Models editor ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); if a future page needs to edit arbitrary sections, it starts from these helpers, not from a resurrected generic renderer, unless the note's trade-off changes.

View File

@@ -0,0 +1,23 @@
# @deepseek-ai/dsh-client-schema-form
[English](README.md) | 中文
面向 settings 编辑器的 schema草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema`schema.toJSON()` 的 ref 信封);`rehydrateSchema``new Schema(json)` 将其还原rehydrate为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件Models 页围绕它在此探测到的字段手写自己的卡片该包package不含任何 React也不做任何渲染。
## 契约
编辑的单元是**用户分节草稿**:一个以不可变方式编辑的普通对象(`setPath` 会物化中间对象,`deletePath` 即逐字段重置——去掉该键,解析值便回退到组合 base 与 schema 默认值)。字段只要出现在草稿中就被标记为**已覆盖**`hasPath`)——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。`nodeAtPath` 解析可配置提供方目录 `settingsPath` 所寻址的 schema 节点object 属性按名称解析dict 条目经由 `inner`),编辑器因此可以在决定渲染什么之前,先探测某提供方的 profile 携带哪些字段(及其 `meta.role`);无法解析的路径返回 `undefined`,调用方因此会大声降级,而不是渲染出错误的子树。`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以在写入前拒绝无效草稿。
## Model Experience
无。该包支撑的是浏览器配置编辑器;这里没有任何内容进入模型请求。
#### KV Cache effect
无;该包既不组装也不发送提供方请求。
## Known Limitations and Deferred Work
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback因此 schema 信封是可执行内容,而非惰性数据。这只有在信封来自提供该页面的同一 host 时才可接受;面向浏览器的 schema 协议应当传递客户端无法执行的描述,此项与 settings seam 的[协议边界工作](../../settings/settings/README.md#known-limitations-and-deferred-work)一并暂缓。
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。

View File

@@ -0,0 +1,40 @@
{
"name": "@deepseek-ai/dsh-client-schema-form",
"description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,12 @@
/**
* Schema/draft model layer for settings editors: rehydrate the wire's
* serialized schemastery envelope, resolve nodes by settings path, validate
* drafts, and edit them immutably by path. Editors render their own controls
* (the Models page hand-writes its layout) on top of these helpers.
* @module @deepseek-ai/dsh-client-schema-form
*/
export {
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from './model.ts'
export type { SchemaNode } from './model.ts'

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-schema-form`.
* @module @deepseek-ai/dsh-client-schema-form/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-schema-form'
/** Cordis companion plugin name. */
export const name = 'client-schema-form-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a pure schema/draft helper library — it emits no
* cordis events and owns no cross-plugin mutable relation; draft
* immutability, schema rehydration, and path-edit round trips are asserted
* directly by this package's model specs.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,151 @@
/**
* Schema introspection and draft-editing helpers behind settings editors.
* The serialized schemastery envelope (`schema.toJSON()`) rehydrates into a
* live validator whose node relations (`dict`/`inner`) editors probe for
* field presence and roles; drafts are edited immutably by path.
* @module @deepseek-ai/dsh-client-schema-form/model
*/
import Schema from 'schemastery'
/** Live schemastery node; the renderer reads only its structural relations. */
export type SchemaNode = Schema
/**
* Rehydrate a serialized schema envelope into a live validator/node tree.
* @param serialized - `schema.toJSON()` output received over the wire.
* @returns the root schema node.
*/
export function rehydrateSchema(serialized: unknown): SchemaNode {
return new Schema(serialized as Schema)
}
/**
* Validate a draft against a rehydrated schema.
* @param schema - rehydrated root node.
* @param draft - candidate value.
* @returns the validation failure message, or `undefined` when the draft passes.
*/
export function validateDraft(schema: SchemaNode, draft: unknown): string | undefined {
try {
;(schema as unknown as (value: unknown) => unknown)(draft)
return undefined
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
/**
* Resolve the schema node at a settings path (the configurable-provider
* directory's `settingsPath` vocabulary): object properties by name, dict
* entries through `inner`. An unresolvable segment returns `undefined` so
* the caller falls back instead of rendering a wrong subtree.
* @param root - rehydrated section root node.
* @param path - key path from the section root.
* @returns the node describing that position, or `undefined`.
*/
export function nodeAtPath(root: SchemaNode, path: readonly string[]): SchemaNode | undefined {
let node: SchemaNode | undefined = root
for (const key of path) {
if (node === undefined) return undefined
if (node.type === 'object') node = (node.dict as Record<string, SchemaNode> | undefined)?.[key]
else if (node.type === 'dict' || node.type === 'array') node = node.inner as SchemaNode | undefined
else return undefined
}
return node
}
/**
* Read a nested value by path.
* @param value - root value (draft or fallback layer).
* @param path - key path from the root; array indexes as strings.
* @returns the value at the path, or `undefined` along a missing branch.
*/
export function getPath(value: unknown, path: readonly string[]): unknown {
let current: unknown = value
for (const key of path) {
if (Array.isArray(current)) {
current = current[Number(key)]
continue
}
if (typeof current !== 'object' || current === null) return undefined
current = (current as Record<string, unknown>)[key]
}
return current
}
/**
* Whether a draft explicitly carries the path (its presence marks a user
* override, independent of the value stored there).
* @param value - root value (draft or fallback layer).
* @param path - key path from the root; array indexes as strings.
* @returns whether the path's final key exists on its parent.
*/
export function hasPath(value: unknown, path: readonly string[]): boolean {
if (path.length === 0) return value !== undefined
const parent = getPath(value, path.slice(0, -1))
const key = path[path.length - 1] as string
if (Array.isArray(parent)) return Number(key) < parent.length
if (typeof parent !== 'object' || parent === null) return false
return key in parent
}
function cloneContainer(container: unknown, key: string): Record<string, unknown> | unknown[] {
if (Array.isArray(container)) return [...container as unknown[]]
if (typeof container === 'object' && container !== null) return { ...container as Record<string, unknown> }
// A missing intermediate materializes as the container the next key needs.
return /^\d+$/.test(key) ? [] : {}
}
/** Clone the container spine down to the leaf's parent, materializing missing intermediates. */
function cloneSpine(root: Record<string, unknown>, path: readonly string[]): {
result: Record<string, unknown>
parent: Record<string, unknown> | unknown[]
leaf: string
} {
const result = { ...root }
let target: Record<string, unknown> | unknown[] = result
for (let i = 0; i < path.length - 1; i++) {
const key = path[i] as string
const child = cloneContainer(
Array.isArray(target) ? target[Number(key)] : (target)[key],
path[i + 1] as string,
)
if (Array.isArray(target)) target[Number(key)] = child
else (target)[key] = child
target = child
}
return { result, parent: target, leaf: path[path.length - 1] as string }
}
/**
* Immutably set a nested value, materializing missing intermediate containers.
* @param root - draft root (never mutated).
* @param path - non-empty key path.
* @param value - value to store at the path.
* @returns the new draft root.
*/
export function setPath(root: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
if (path.length === 0) throw new Error('schema-form: setPath needs a non-empty path')
const { result, parent, leaf } = cloneSpine(root, path)
if (Array.isArray(parent)) parent[Number(leaf)] = value
else parent[leaf] = value
return result
}
/**
* Immutably remove a nested key (the per-field reset: the resolved value
* falls back to the composition base and schema defaults). Removing along a
* missing branch returns the root unchanged.
* @param root - draft root (never mutated).
* @param path - non-empty key path.
* @returns the new draft root.
*/
export function deletePath(root: Record<string, unknown>, path: readonly string[]): Record<string, unknown> {
if (path.length === 0) throw new Error('schema-form: deletePath needs a non-empty path')
if (!hasPath(root, path)) return root
const { result, parent, leaf } = cloneSpine(root, path)
if (Array.isArray(parent)) parent.splice(Number(leaf), 1)
else Reflect.deleteProperty(parent, leaf)
return result
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as SchemaFormInvariant from '@deepseek-ai/dsh-client-schema-form/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(SchemaFormInvariant).await()).resolves.toBeDefined()
})
})

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import Schema from 'schemastery'
import {
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '../src/model.ts'
const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
describe('rehydration and validation', () => {
it('rehydrates a serialized envelope into a working validator', () => {
const root = rehydrateSchema(Wire(Schema.object({ name: Schema.string().required() })))
expect(validateDraft(root, { name: 'ok' })).toBeUndefined()
expect(validateDraft(root, { name: 42 })).toContain('name')
})
it('stringifies non-Error validation throws', () => {
const hostile = (() => {
throw 'plain-string failure'
}) as unknown as Parameters<typeof validateDraft>[0]
expect(validateDraft(hostile, {})).toBe('plain-string failure')
})
})
describe('path helpers', () => {
const root = { providers: { openai: { baseURL: 'https://x' } }, models: [{ id: 'a' }] }
it('reads nested object and array paths', () => {
expect(getPath(root, [])).toBe(root)
expect(getPath(root, ['providers', 'openai', 'baseURL'])).toBe('https://x')
expect(getPath(root, ['models', '0', 'id'])).toBe('a')
expect(getPath(root, ['providers', 'missing', 'x'])).toBeUndefined()
expect(getPath(root, ['providers', 'openai', 'baseURL', 'deep'])).toBeUndefined()
})
it('reports draft presence by key existence, not value truthiness', () => {
expect(hasPath({ flag: false }, ['flag'])).toBe(true)
expect(hasPath({ nested: { key: undefined } }, ['nested', 'key'])).toBe(true)
expect(hasPath({}, ['missing'])).toBe(false)
expect(hasPath({ leaf: 'x' }, ['leaf', 'deeper'])).toBe(false)
expect(hasPath({ models: ['a'] }, ['models', '0'])).toBe(true)
expect(hasPath({ models: ['a'] }, ['models', '1'])).toBe(false)
expect(hasPath({ root: true }, [])).toBe(true)
expect(hasPath(undefined, [])).toBe(false)
})
it('sets nested paths immutably, materializing containers by key shape', () => {
const draft = {}
const next = setPath(draft, ['providers', 'openai', 'baseURL'], 'https://y')
expect(draft).toEqual({})
expect(next).toEqual({ providers: { openai: { baseURL: 'https://y' } } })
const withArray = setPath(next, ['models', '0'], { id: 'a' })
expect(withArray).toEqual({ providers: { openai: { baseURL: 'https://y' } }, models: [{ id: 'a' }] })
const replaced = setPath(withArray, ['models', '0', 'id'], 'b')
expect(replaced.models).toEqual([{ id: 'b' }])
expect((withArray as { models: unknown[] }).models).toEqual([{ id: 'a' }])
expect(() => setPath({}, [], 'x')).toThrow(/non-empty path/)
})
it('deletes nested paths immutably and splices array indexes', () => {
const draft = { providers: { openai: { baseURL: 'https://x', apiKey: 'k' } }, models: ['a', 'b'] }
const withoutKey = deletePath(draft, ['providers', 'openai', 'apiKey'])
expect(withoutKey).toEqual({ providers: { openai: { baseURL: 'https://x' } }, models: ['a', 'b'] })
expect(draft.providers.openai.apiKey).toBe('k')
const withoutModel = deletePath(withoutKey, ['models', '0'])
expect(withoutModel.models).toEqual(['b'])
expect(deletePath(draft, ['providers', 'missing', 'x'])).toBe(draft)
expect(() => deletePath({}, [])).toThrow(/non-empty path/)
})
it('deletes keys through array intermediates immutably', () => {
const draft = { models: [{ id: 'a', contextWindow: 1 }] }
const next = deletePath(draft, ['models', '0', 'contextWindow'])
expect(next).toEqual({ models: [{ id: 'a' }] })
expect(draft.models[0]).toEqual({ id: 'a', contextWindow: 1 })
})
})
describe('nodeAtPath', () => {
const Root = Schema.object({
providers: Schema.dict(Schema.object({ baseURL: Schema.string() })),
models: Schema.array(Schema.object({ id: Schema.string() })),
leaf: Schema.string(),
})
it('resolves object, dict, and array positions', () => {
const root = rehydrateSchema(Wire(Root))
expect(nodeAtPath(root, [])).toBe(root)
expect(nodeAtPath(root, ['providers', 'openai'])?.type).toBe('object')
expect(nodeAtPath(root, ['providers', 'openai', 'baseURL'])?.type).toBe('string')
expect(nodeAtPath(root, ['models', '0', 'id'])?.type).toBe('string')
expect(nodeAtPath(root, ['missing'])).toBeUndefined()
expect(nodeAtPath(root, ['missing', 'deeper'])).toBeUndefined()
expect(nodeAtPath(root, ['leaf', 'below'])).toBeUndefined()
})
it('tolerates structural nodes missing their relation maps', () => {
expect(nodeAtPath({ type: 'object' } as never, ['x'])).toBeUndefined()
expect(nodeAtPath({ type: 'dict' } as never, ['x'])).toBeUndefined()
})
})

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -28,6 +28,7 @@
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-host-apiproxy": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
@@ -37,6 +38,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",

View File

@@ -37,6 +37,7 @@ export { FixtureSession, TestSessions } from './sessions.ts'
export { TestWorkspaces } from './workspaces.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
export { makeTranslate } from './translate.ts'
/** Erased register face for the internal root call (the public declare seam holds the typing). */
type ErasedRegister = (options: object, component: unknown) => () => void

View File

@@ -4,8 +4,11 @@ import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-cl
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
// The double reports the wire schema's own search bound, like the production
// service — a transport-varying limit would be a fiction no client can see.
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import { conversationSnapshot } from './fixtures.ts'
import type { SessionFixture, Stabilizer } from './fixtures.ts'
@@ -151,8 +154,8 @@ export interface TestSessionBinding {
*
* Implements the same ISessions face features receive as `ctx.sessions`, so
* a production face change breaks this double at compile time; the extra
* members (add/updateSnapshot/setCurrent/remove/behavior/calls and the
* legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
* members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and
* the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
*/
export class TestSessions implements ISessions {
/** The useSessions standard feed (list rows + current selection). */
@@ -168,8 +171,14 @@ export class TestSessions implements ISessions {
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
private readonly channel: SessionProvideChannel
/** Calls observed on the service-level face (open/clear), newest last. */
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
/** Calls observed on the service-level face (open/clear/search/fork), newest last. */
readonly calls: { method: 'open' | 'clear' | 'search' | 'fork'; args: unknown[] }[] = []
/** The wire schema's `session.search` result bound (production parity). */
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined
/**
* @param stabilize - the owning runtime's act wrapper.
@@ -392,6 +401,38 @@ export class TestSessions implements ISessions {
this.list.update((draft) => { draft.current = undefined })
}
/**
* Replace the sidebar-search result page (the call is still recorded).
* @param impl - hits for a query, as the Host would rank them.
*/
stubSearch(impl: (query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }): void {
this.searchStub = impl
}
/**
* Content search over the fixture corpus (recorded). The default answers an
* empty page: content ranking is Host behavior, so a scenario that asserts
* hits declares them through {@link TestSessions.stubSearch}.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search (recorded and forwarded).
* @returns the stubbed or empty result page.
*/
search(query: string, signal: AbortSignal): ReturnType<ISessions['search']> {
this.calls.push({ method: 'search', args: [query, signal] })
return Promise.resolve({ ok: true, value: this.searchStub?.(query, signal) ?? { items: [], hasMore: false } })
}
/**
* Recorded fork stub: no child materializes (benches asserting the full
* fork flow drive the production service; this face only proves the call).
* @param opts - source session id, optional cut anchor, and client title policy.
* @returns the source id (no child record is created).
*/
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId> {
this.calls.push({ method: 'fork', args: [opts] })
return Promise.resolve(opts.sessionId)
}
/**
* The session face of a fixture (typed view for assertions; fixture
* behavior methods are grafted onto it).

View File

@@ -0,0 +1,32 @@
/**
* Test double of the locale lookup chain: a translate stub over plain
* dictionaries, mirroring LocaleService's resolution order (first dictionary
* that owns the key wins, then the key itself stays visible) and its
* `{name}` template interpolation. Specs stub the framework-injected `t`
* seat with `makeTranslate(zh, commonZh)` instead of re-implementing the
* chain per suite.
*/
/**
* Build a translate stub resolving through `dicts` in order (namespace
* first, then the shared common vocabulary), falling back to the key.
* @param dicts - dictionaries consulted in order.
* @returns the translate function (assignable to any `XxxProps['t']` seat).
*/
export function makeTranslate(
...dicts: readonly Record<string, string>[]
): (key: string, params?: Record<string, unknown>) => string {
return (key, params) => {
let template = key
for (const dict of dicts) {
const hit = dict[key]
if (hit !== undefined) {
template = hit
break
}
}
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
}

View File

@@ -201,7 +201,7 @@ describe('sessions', () => {
await runtime.dispose()
})
it('records service-face calls; open() moves the selection and clear() empties it', async () => {
it('records service-face calls; open() moves selection, clear() empties it, and fork() echoes the source', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' })
@@ -211,9 +211,35 @@ describe('sessions', () => {
runtime.sessions.clear()
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
await expect(runtime.sessions.fork({
sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true,
})).resolves.toBe('s1')
expect(runtime.sessions.calls).toEqual([
{ method: 'open', args: ['s1'] },
{ method: 'clear', args: [] },
{ method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] },
])
await runtime.dispose()
})
it('answers search with an empty page until a scenario declares hits, recording every call', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
const signal = new AbortController().signal
expect(runtime.sessions.searchResultLimit).toBeGreaterThan(0)
await expect(runtime.sessions.search('marker', signal))
.resolves.toEqual({ ok: true, value: { items: [], hasMore: false } })
runtime.sessions.stubSearch(query => ({
items: [{ sessionId: 's1' as SessionId, snippet: `hit: ${query}` }],
hasMore: true,
}))
await expect(runtime.sessions.search('marker', signal)).resolves.toEqual({
ok: true,
value: { items: [{ sessionId: 's1', snippet: 'hit: marker' }], hasMore: true },
})
expect(runtime.sessions.calls).toEqual([
{ method: 'search', args: ['marker', signal] },
{ method: 'search', args: ['marker', signal] },
])
await runtime.dispose()
})

View File

@@ -22,6 +22,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../host/apiproxy"
}
]
}

View File

@@ -25,6 +25,7 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slash",
"@deepseek-ai/dsh-client-ui-conversation"
],
@@ -40,6 +41,7 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -51,7 +53,9 @@
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",

View File

@@ -13,6 +13,7 @@ import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
import css from './PopupSelectView.module.css'
@@ -26,12 +27,15 @@ export interface PopupSelectInjected {
popup: PopupSelectController
}
/** Full shell props: injected face + the locale seat. */
export type PopupSelectViewProps = PopupSelectInjected & PropsLocale<'command'>
/**
* Render the popupSelect shell overlay entry.
* @param props - injected face: the session's shell controller.
* @param props - injected face: the session's shell controller; `t` rides the standard locale seat.
* @returns the select card while open; null while closed.
*/
export function PopupSelectView({ popup }: PopupSelectInjected) {
export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
const state = useSyncExternalStore(
fn => popup.state.subscribe(fn),
() => popup.state.getSnapshot(),
@@ -103,15 +107,15 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={`/${String(state.command)} options`}
aria-label={t('overlay.aria', { command: String(state.command) })}
onKeyDown={onKeyDown}
>
<input
ref={searchRef}
className={css.search}
type="text"
placeholder="Search…"
aria-label="Filter options"
placeholder={t('search.placeholder')}
aria-label={t('search.aria')}
value={state.search}
readOnly={state.submitting}
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
@@ -120,15 +124,15 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
<div className={css.error} role="alert">
<span className={css.errorText}>{state.error}</span>
{state.status === 'failed' && (
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>Retry</button>
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
)}
</div>
)}
{state.status === 'pending' && <div className={css.status}>Loading options</div>}
{state.submitting && <div className={css.status}>Applying</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}

View File

@@ -10,19 +10,23 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// key's owner) into this program so the overlay registration below typechecks
// against the real declaration — no runtime edge to ui-conversation.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { CommandService } from './service.ts'
import type { PopupSelectInjected } from './PopupSelectView.tsx'
import { PopupSelectView } from './PopupSelectView.tsx'
import { en, zh, type CommandKey } from './locales.ts'
export { CommandService } from './service.ts'
export { CommandDirectory } from './directory.ts'
export type { CommandDescriptor, DirectoryStatus } from './directory.ts'
export { filterOptions, PopupSelectController } from './popup.ts'
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
export type { PopupSelectInjected } from './PopupSelectView.tsx'
export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
export type {
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
} from './contract.ts'
export type { CommandKey } from './locales.ts'
declare module 'cordis' {
interface Context {
@@ -30,8 +34,18 @@ declare module 'cordis' {
}
}
/** Required services: the '/' source registry plus the scope + wire faces the service reads. */
export const inject = ['slash', 'sessions', 'connection']
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The popupSelect shell's copy. */
command: CommandKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'command'
/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
export const inject = ['slash', 'sessions', 'connection', 'locale']
/**
* Client plugin body: mount the service, then register the popupSelect shell
@@ -39,6 +53,7 @@ export const inject = ['slash', 'sessions', 'connection']
* @param ctx - client root context.
*/
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
@@ -51,6 +66,7 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.input.overlay',
id: 'command-popup',
order: 1,
locale: NS,
inject: (sessionId): PopupSelectInjected => {
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`)

View File

@@ -0,0 +1,26 @@
/** `command` namespace dictionaries (the popupSelect shell's copy). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'search.placeholder': '搜索…',
'search.aria': '筛选选项',
'status.loading': '正在加载选项…',
'status.applying': '正在应用…',
'status.empty': '无选项',
'overlay.aria': '/{command} 选项',
'listbox.aria': '/{command} 匹配项',
} satisfies Record<string, string>
/** The command namespace key union. */
export type CommandKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'search.placeholder': 'Search…',
'search.aria': 'Filter options',
'status.loading': 'Loading options…',
'status.applying': 'Applying…',
'status.empty': 'No options',
'overlay.aria': '/{command} options',
'listbox.aria': '/{command} matches',
} satisfies Record<CommandKey, string>

View File

@@ -13,6 +13,7 @@ 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'
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, CommandService, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
@@ -41,6 +42,7 @@ async function bench() {
},
})
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const mint = (key: string) => {
@@ -53,7 +55,7 @@ async function bench() {
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions', 'connection'])
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale'])
})
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {

View File

@@ -14,6 +14,12 @@ import type { SelectOption } from '../src/client/contract.ts'
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
import { PopupSelectController } from '../src/client/popup.ts'
import { PopupSelectView } from '../src/client/PopupSelectView.tsx'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { zh } from '../src/client/locales.ts'
// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
const t: Parameters<typeof PopupSelectView>[0]['t'] = makeTranslate(zh, commonZh)
// jsdom has no scrollIntoView; the view calls it on the highlighted row.
const scrollIntoView = vi.fn()
@@ -47,12 +53,12 @@ async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResu
const consume = vi.fn((_segment: TokenSegment) => consumeResult)
const focusComposer = vi.fn()
const popup = new PopupSelectController<string>({ consume, focusComposer })
const view = render(<PopupSelectView popup={popup} />)
const view = render(<PopupSelectView popup={popup} t={t} />)
await act(async () => {
popup.open('theme', spec(overrides), 'ctx-A', SEGMENT)
await Promise.resolve()
})
return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: 'Filter options' }) }
return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: '筛选选项' }) }
}
function rowLabels(): string[] {
@@ -62,13 +68,13 @@ function rowLabels(): string[] {
describe('PopupSelectView', () => {
it('renders null while closed, opens with focus in the search input', async () => {
const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
const view = render(<PopupSelectView popup={popup} />)
const view = render(<PopupSelectView popup={popup} t={t} />)
expect(view.container.childElementCount).toBe(0)
await act(async () => {
popup.open('theme', spec(), 'ctx-A', SEGMENT)
await Promise.resolve()
})
const search = screen.getByRole('textbox', { name: 'Filter options' })
const search = screen.getByRole('textbox', { name: '筛选选项' })
expect(document.activeElement).toBe(search)
expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
})
@@ -82,7 +88,7 @@ describe('PopupSelectView', () => {
expect(options).toHaveBeenCalledTimes(1)
act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) })
expect(screen.queryByRole('option')).toBeNull()
expect(screen.queryByText('No options')).not.toBeNull()
expect(screen.queryByText('无选项')).not.toBeNull()
})
it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => {
@@ -110,13 +116,13 @@ describe('PopupSelectView', () => {
it('caps the card height at the design maximum when the composer sits low enough', async () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
await mountOpen()
expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('320px')
expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('320px')
})
it('clamps the card height to the space above the composer minus the safe margin', async () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
await mountOpen()
expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('188px')
expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('188px')
})
it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
@@ -148,7 +154,7 @@ describe('PopupSelectView', () => {
const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
const { search, consume } = await mountOpen({ onSelect })
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
expect(screen.queryByText('Applying…')).not.toBeNull()
expect(screen.queryByText('正在应用…')).not.toBeNull()
expect((search as HTMLInputElement).readOnly).toBe(true)
await act(async () => {
fireEvent.keyDown(search, { key: 'Enter' })
@@ -162,7 +168,7 @@ describe('PopupSelectView', () => {
expect(consume).toHaveBeenCalledTimes(1)
})
it('a failed options load shows the error with a Retry button that reloads', async () => {
it('a failed options load shows the error with a retry button that reloads', async () => {
let attempts = 0
await mountOpen({
options: () => {
@@ -172,7 +178,7 @@ describe('PopupSelectView', () => {
})
expect(screen.getByRole('alert').textContent).toContain('directory down')
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await Promise.resolve()
})
expect(attempts).toBe(2)
@@ -183,7 +189,7 @@ describe('PopupSelectView', () => {
const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) })
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
expect(screen.getByRole('alert').textContent).toContain('host rejected')
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
expect(consume).not.toHaveBeenCalled()
expect(screen.getAllByRole('option').length).toBe(3)
})

View File

@@ -14,6 +14,9 @@
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},

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: 75c181f2e1240f753d1f8c30152d2978151b059f
README.zh.md: 6dc167c63af27dfbc37c51b9a55087cf2c50dc7f
README.md: b4b1e5653705c76bac3e0227e6df77143a11cbbe
README.zh.md: 74e0f3dc0ebaf74e2e065c6b88f3a30fce94b391

View File

@@ -10,6 +10,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati
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 sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. 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 (the `/permission` popup's display transform twin), and a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
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 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
@@ -18,9 +20,11 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to
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: -1` — above the queue rows — 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 row remains a single-line preview with its exact-occurrence edit and delete actions.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
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. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers 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 resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
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. 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.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
@@ -34,9 +38,9 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
- **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.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.

View File

@@ -8,6 +8,8 @@
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开后的 141px 滚动区会以内联 JSON 的形式有界展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放通用工具的内容仍然只在面板中呈现[决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
@@ -18,9 +20,11 @@
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是计划条它经 `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 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `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 在会话存在之前保持为空
`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/*` 子路径获取它们)。
@@ -34,9 +38,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
## 已知限制与暂缓事项
- **统计行没有耗时区段**assistant `usage` 只携带 token 计数;耗时需要主机数据源
- **统计行的耗时只覆盖窗口内消息流**LLM大语言模型与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入
- **详情面板是最小形态,且当前没有入口**以原始形式显示已选择调用的参数结果Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。

View File

@@ -1,6 +1,6 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import 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).
@@ -10,6 +10,7 @@ import type {
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
@@ -21,15 +22,37 @@ import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
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 { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { en, NS, zh, type ConversationKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The conversation surfaces' copy (skeleton, chat view, toolviews, docks). */
conversation: ConversationKey
}
}
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
// Static no-session sources for the composer-bar hooks compartment: module
// constants so the render side's per-source hook cache (observableHook) keeps
// one identity across every no-session render.
const ABSENT_NOTICES = {
getSnapshot: (): InputNotice | null => null,
subscribe: () => () => {},
}
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
const ABSENT_LEXICON = {
getSnapshot: () => EMPTY_LEXICON,
subscribe: () => () => {},
}
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id)
@@ -53,42 +76,27 @@ export function apply(ctx: Context): void {
const layout = ctx.layout
const slots = ctx.slots
// Command hint locale: friendly placeholder text for claimed commands. The
// claimed /plan hint and the plan-mode textarea placeholder share one
// string: both describe the same next action.
const HINT_NS = 'command.hint'
const PLAN_HINT_ZH = '描述你的任务以生成计划'
const PLAN_HINT_EN = 'describe your task to generate plan'
ctx.effect(() => {
const disposers = [
ctx.locale.register(HINT_NS, 'zh', {
plan: PLAN_HINT_ZH,
goal: '输入目标,智能体将持续执行',
'goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
'placeholder.plan': PLAN_HINT_ZH,
'placeholder.default': '给智能体发消息',
}),
ctx.locale.register(HINT_NS, 'en', {
plan: PLAN_HINT_EN,
goal: 'describe the objective for a long-running task',
'goal.active': 'goal active — edit / pause / resume / clear',
'placeholder.plan': PLAN_HINT_EN,
'placeholder.default': 'Message the agent',
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-conversation: command hint dictionaries')
const translateHint = ctx.locale.bind(HINT_NS)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
// Registration-time text (the view tab label) reads through the bound
// translate as a thunk, so it follows the active locale without
// re-registration; components read the standard `t` seat instead.
const t = ctx.locale.bind(NS)
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
// Chat scroll offsets by session, surviving view switches (the chat view
// unmounts under the tab ring). Deliberately not persisted: a fresh page
// load should keep the open-jump-to-bottom default.
const chatScrollTops = new Map<SessionId, number>()
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) {
/* v8 ignore next -- unreachable: list registration validates id at load. */
if (entry.options.id === undefined) continue
tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id })
tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id })
}
return tabs
}
@@ -117,10 +125,11 @@ export function apply(ctx: Context): void {
// frame while strict session slots fill only their session-bound regions.
slots.register({
name: 'conversation',
locale: NS,
children: {
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.composer.dock': { kind: 'list', scope: 'session' },
@@ -148,6 +157,7 @@ export function apply(ctx: Context): void {
// the resident parent keeps Hero and composer layout identity stable.
slots.register({
name: 'conversation.session',
locale: NS,
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
@@ -165,8 +175,12 @@ export function apply(ctx: Context): void {
// chain's fallback (decision 20). Public machine surface arrives via the
// provide channel above; the keyboard command face and the stop/retry
// verbs ride this inject (package-internal — hub and bar are one plugin).
// Session-maybe: with no current session the machine faces are absent and
// the hooks compartment binds static empty sources (module constants, so
// observableHook caching and hook order stay stable across transitions).
slots.register({
name: 'conversation.composer.bar',
locale: NS,
// The two named control seats in the bar's tool row (plan beside the
// access control, model right); empty until their owning plugins
// register (B ruling).
@@ -174,7 +188,15 @@ export function apply(ctx: Context): void {
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
},
inject: (sessionId: SessionId): ComposerBarInjected => {
inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
if (sessionId === undefined) {
return {
keyboard: undefined,
stop: undefined,
command: undefined,
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
}
}
const shell = inputHub.shell(sessionId)
return {
keyboard: shell,
@@ -189,7 +211,6 @@ export function apply(ctx: Context): void {
const result = await session.command(line)
return result.ok && result.value.matched
},
translateHint,
hooks: { notices: shell.notices, lexicon: shell.lexicon },
}
},
@@ -203,7 +224,7 @@ export function apply(ctx: Context): void {
// pending — a question is a conversation the model is waiting on, while an
// approval only blocks one tool call; answering the question first cannot
// strand the approval (it re-elects the moment the question resolves).
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
// The chat view: first entry of the ring this package just declared.
// Declaring the keyed toolview hole here is claiming it: ChatView is the
@@ -214,7 +235,8 @@ export function apply(ctx: Context): void {
name: 'conversation.view',
id: 'chat',
order: 0,
label: 'Chat',
label: () => t('view.chat'),
locale: NS,
children: {
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
@@ -235,6 +257,26 @@ export function apply(ctx: Context): void {
})
},
loadOlder: () => { void scoped.loadOlder() },
// Unregistered 'trajectory' id is safe: the tab ring falls back to
// the first view, and the untouched inspect target stays inert.
inspectCall: (callId) => {
actions.setInspect({ callId })
actions.setView('trajectory')
},
chatScroll: {
save: (top) => {
if (top === null) chatScrollTops.delete(sessionId)
else chatScrollTops.set(sessionId, top)
},
read: () => chatScrollTops.get(sessionId) ?? null,
},
forkAt: (seq) => {
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
.then((childId) => { sessions.open(childId) })
.catch(() => {
// Fork or child-rename failure keeps the source view untouched.
})
},
}
},
}, ChatView)
@@ -257,6 +299,9 @@ export function apply(ctx: Context): void {
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
// The ask_user_question row: waiting/answered/cancelled interaction outcome.
ctx.plugin(askQuestionToolview)
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)
@@ -266,6 +311,7 @@ export function apply(ctx: Context): void {
slots.register({
name: 'details',
locale: NS,
store: chatStore,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },

View File

@@ -4,14 +4,16 @@
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized content (text) nodes append IconActions once streaming ends;
// Think / tool-head-only nodes stay chrome-free.
// Finalized turn-tail content (text) nodes append IconActions once streaming
// ends (`time` is omitted for mid-turn narration); Think / tool-head-only
// nodes stay chrome-free.
import { memo } from 'react'
import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -19,10 +21,17 @@ import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps {
blocks: readonly AssistantBlock[]
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
interrupted?: boolean | undefined
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
* the parent withholds chrome (mid-turn content assistants). */
time?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through the turn containing this finalized message. */
onFork?: ((seq: number) => void) | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
function firstLine(text: string): string {
@@ -45,23 +54,26 @@ function hasContentText(blocks: readonly AssistantBlock[]): boolean {
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */
function ThinkRow({ text, running }: { text: string; running: boolean }) {
function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) {
return (
<ToolRow
t={t}
variant="think"
icon={<IconThinkOutline14 size={14} />}
title="Think"
summary={firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
expandOnRowClick
/>
)
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time,
blocks, streaming, interrupted, time, seq, onFork, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
// a node that is only those heads (or empty) would paint an empty root
@@ -77,21 +89,32 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
case 'text': return (
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
)
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} t={t} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
default: return (
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>
)
}
})}
{interrupted && <span className={css.stopped}></span>}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
{showActions && (
<MessageIconActions
text={copyText(blocks)}
time={time}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
className={css.actions}
t={t}
/>
)}
</div>

View File

@@ -144,8 +144,11 @@
}
:global([data-conversation-scroll]) .toBottomSlot {
/* Clears the sticky composer stack (stats + docks + input card). */
bottom: 168px;
/* Clears the sticky composer stack (docks + input card + stats): the live
height rides --dsh-composer-height (ConversationRoot's seat observer) so
the control follows a growing textarea; the fallback covers the first
paint before the observer fires. */
bottom: calc(var(--dsh-composer-height, 152px) + 16px);
}
.toBottom {

View File

@@ -30,7 +30,7 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -46,6 +46,8 @@ function scrollerOf(from: HTMLElement): HTMLElement {
type OpenFile = (path: string) => void
type InspectCall = (callId: string) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
@@ -57,23 +59,26 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall, t }: {
renderSlot: RenderToolRow
node: CodeSubCall
openFile: OpenFile
selected: boolean
cwd: string | undefined
inspectCall: InspectCall
t: ChatViewSlotProps['t']
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const owner = useMemo(() => ({
callId: node.callId, toolName, block: node, openFile, cwd,
}), [node, toolName, openFile, cwd])
inspect: () => { inspectCall(node.callId) },
}), [node, toolName, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
fallback: <GenericToolCard {...owner} t={t} />,
})}
</div>
)
@@ -85,7 +90,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall, t,
}: {
renderSlot: RenderToolRow
callId: string
@@ -100,15 +105,18 @@ const CallRow = memo(function CallRow({
selectedCallId?: string | undefined
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
inspectCall: InspectCall
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({
callId, toolName, block, openFile, cwd,
}), [callId, toolName, block, openFile, cwd])
inspect: () => { inspectCall(callId) },
}), [callId, toolName, block, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
fallback: <GenericToolCard {...owner} t={t} />,
})}
{subCalls !== undefined && subCalls.length > 0 && (
<div className={css.subCalls} data-subcalls>
@@ -120,6 +128,8 @@ const CallRow = memo(function CallRow({
openFile={openFile}
selected={node.callId === selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
))}
</div>
@@ -129,7 +139,7 @@ const CallRow = memo(function CallRow({
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall, t }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
openFile: OpenFile
@@ -139,6 +149,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
inspectCall: InspectCall
t: ChatViewSlotProps['t']
}) {
return (
<div className={css.toolGroup}>
@@ -154,6 +166,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
))}
</div>
@@ -163,16 +177,17 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
/** One command lifecycle row: keyed dispatch on the command name with the
* generic card as the render-site fallback (zero registration required). A
* run-less cross-window node has no name and always lands on the fallback. */
const CommandRow = memo(function CommandRow({ renderSlot, node }: {
const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
renderSlot: RenderToolRow
node: CommandNode
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({ node }), [node])
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
fallback: <GenericCommandCard {...owner} />,
fallback: <GenericCommandCard {...owner} t={t} />,
})}
</div>
)
@@ -214,23 +229,26 @@ function TurnDots() {
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow }: {
function StreamingTail({ useSession, onGrow, t }: {
useSession: UseConversation
onGrow: () => void
t: ChatViewSlotProps['t']
}) {
const partial = useSession(s => s.partial)
useLayoutEffect(() => {
onGrow()
})
if (partial === null) return null
return <AssistantMarkdown blocks={partial.blocks} streaming />
return <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
}
/**
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -238,12 +256,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const openState = useSession(s => s.openState)
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const openError = useSession(s => s.openError)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
@@ -274,10 +295,20 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (local === null) return
const el = scrollerOf(local)
// Open completed: jump to the bottom once.
// Open completed: jump to the bottom once — unless a scroll position
// survives from a previous mount (view-tab switch away and back), which
// is restored instead of snapping the reader back to the floor.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
const saved = chatScroll.read()
if (saved === null) {
toBottom(el)
} else {
el.scrollTop = saved
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
followSigRef.current = followSig
@@ -315,6 +346,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
// Continuous save (unmount happens after ref detach, so saving there is
// too late); pinned-to-bottom clears so a remount keeps following.
chatScroll.save(isAtBottom ? null : el.scrollTop)
}
// Bind scroll to the resolved scrollport (host or local) once per mount.
@@ -365,6 +399,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
)
}
@@ -376,33 +412,40 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
time={node.time}
time={actionSeqs.has(node.seq) ? node.time : undefined}
seq={node.seq}
onFork={forkAt}
t={t}
/>
)
}
if (node.kind === 'command') {
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} t={t} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
return <MessageItem key={item.key} node={node} onFork={forkAt} t={t} />
}
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
{openState === 'error' && openError !== null && (
<div className={css.openError}>
{t('chat.loadError', { message: openError.message, code: openError.code })}
</div>
)}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
{loadingOlder ? '加载中…' : '加载更早'}
{loadingOlder ? t('loading') : t('chat.loadOlder')}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
<StreamingTail useSession={useSession} onGrow={onGrow} t={t} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (
@@ -417,6 +460,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
t={t}
/>
))}
</div>
@@ -433,7 +478,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
aria-label={t('chat.toBottom')}
onClick={() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */

View File

@@ -0,0 +1,29 @@
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px clipped code block. */
.root {
min-width: 0;
}
.root[data-open] {
padding-bottom: 4px;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.body {
box-sizing: border-box;
width: calc(100% - 22px);
height: 141px;
margin: 4px 0 0 22px;
overflow: auto;
padding: 10px 16px 12px 12px;
border: none;
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
font: 400 11px/16px var(--ds-font-family-code);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@@ -0,0 +1,84 @@
import { useMemo, 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 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']
/** 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.
*/
export function ContextInjectionRow({ content, source, 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])
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title={t('message.contextInjection')}
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<pre className={css.body} data-context-injection-body>{body}</pre>
</DisclosureRow>
)
}

View File

@@ -0,0 +1,69 @@
/* Shared Tool calls disclosure header: [16px leading] gap 6 [title 14/24]. */
.root {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
}
.row {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
.row[data-expandable] {
cursor: pointer;
}
.leading {
position: relative;
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
padding: 0;
border: none;
background: none;
color: var(--dsw-alias-label-tertiary);
}
button.leading {
cursor: pointer;
}
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.row:hover .iconIdle {
opacity: 0;
}
.row:hover .chevronHover {
opacity: 1;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}

View File

@@ -0,0 +1,104 @@
import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './DisclosureRow.module.css'
/** Shared 24px disclosure chrome for conversation flow rows. */
export interface DisclosureRowProps {
icon: ReactNode
title: string
open: boolean
expandable: boolean
onToggle: () => void
/** Makes the complete title row the disclosure target. */
expandOnRowClick?: boolean | undefined
/** Replaces the collapsed icon with a chevron while the row is hovered. */
previewChevron?: boolean | undefined
/** Keeps `collapsedContent` inline while open (ToolRow's summary stays readable next to the expanded card). */
keepContentWhenOpen?: boolean | undefined
collapsedContent?: ReactNode
children?: ReactNode
className?: string | undefined
rowClassName?: string | undefined
leadingClassName?: string | undefined
chevronClassName?: string | undefined
titleClassName?: string | undefined
}
/**
* Render one disclosure header and its controlled expanded content.
* @param props - Visual content, controlled state, and interaction policy.
* @returns The disclosure row.
*/
export function DisclosureRow({
icon,
title,
open,
expandable,
onToggle,
expandOnRowClick = false,
previewChevron = expandable,
keepContentWhenOpen = false,
collapsedContent,
children,
className,
rowClassName,
leadingClassName,
chevronClassName,
titleClassName,
}: DisclosureRowProps) {
const rowExpands = expandable && expandOnRowClick
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
onToggle()
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
onToggle()
}
const collapsedLeading = previewChevron
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(chevronClassName, css.chevronHover)} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={chevronClassName} />
: collapsedLeading
return (
<div className={clsx(css.root, className)} data-open={open || undefined}>
<div
className={clsx(css.row, rowClassName)}
data-disclosure-row
data-expandable={rowExpands || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? onToggle : undefined}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable && !rowExpands ? (
<button
type="button"
className={clsx(css.leading, leadingClassName)}
aria-expanded={open}
onClick={toggleFromLeading}
>
{leading}
</button>
) : (
<span className={clsx(css.leading, leadingClassName)}>
{leading}
</span>
)}
<span className={clsx(css.title, titleClassName)}>{title}</span>
{(keepContentWhenOpen || !open) && collapsedContent}
</div>
{open && children}
</div>
)
}

View File

@@ -1,12 +1,12 @@
// GenericCommandCard: the default command row — a stripped-down
// GenericToolCard rendering the dispatched command line and the settlement
// text. Supplied by the chat view as the keyed commandview slot's render-site
// GenericToolCard rendering the command name and its settlement text.
// Supplied by the chat view as the keyed commandview slot's render-site
// fallback (an unregistered command name lands here); registrants may compose
// it as a base, feeding the same owner payload through.
import { ToolRow } from './ToolRow.tsx'
import type { ToolRowState } from '../contract/tool-call-model.ts'
import type { CommandRowOwnerProps } from '../contract/slots.ts'
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
/** Node state → row state semantic (running while unsettled; outcome kind after). */
@@ -15,19 +15,26 @@ function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState
return outcome.kind === 'error' ? 'error' : 'ok'
}
export function GenericCommandCard({ node }: CommandRowOwnerProps) {
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
export interface GenericCommandCardProps extends CommandRowOwnerProps {
t: ChatViewSlotProps['t']
}
export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
const text = node.outcome?.text
const summary = node.outcome === null
? '执行中…'
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
// Display line rebuilt from the structured payload (args carries its own
// separator whitespace verbatim); a cross-window node whose run page fell
// out of the window has neither.
const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
? t('command.running')
: text ?? (node.outcome.kind === 'error' ? t('command.failed') : t('command.done'))
// Title is the bare command name: the row already reads `name · outcome`,
// and the dispatched line's own `/` and arguments only restate what the
// settlement text says (`permission · preset workspace-write`). A
// cross-window node whose run page fell out of the window has no name.
const title = node.name ?? t('command.title')
return (
<ToolRow
t={t}
variant="others"
icon={<IconApiOutline14 size={16} />}
icon={<IconApiOutline14 size={14} />}
title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.

View File

@@ -9,8 +9,8 @@ import {
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowOwnerProps } from '../contract/slots.ts'
import { terminalCardModel } from '../contract/terminal-card-model.ts'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
@@ -26,12 +26,23 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
others: <IconSparkle16 size={14} />,
}
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
export interface GenericToolCardProps extends ToolRowOwnerProps {
t: ChatViewSlotProps['t']
}
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
? 'error'
: model.state
const singleFile = model.filePath !== undefined
return (
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}
@@ -39,12 +50,14 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
// A terminal presenter's description is the contract's above-card text, so
// it outranks the args-derived summary here exactly as it does in BashRow.
summary={terminal?.description ?? model.summary}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
body={model.body}
output={model.output}
errorSummary={model.errorSummary}
terminal={terminal}
state={model.state}
state={state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
inspect={inspect}
/>
)
}

View File

@@ -1,10 +1,12 @@
// Shared IconActions chrome for user and assistant messages: copy / branch
// live (branch still a stub), date-aware clock, optional edit stub.
// Shared IconActions chrome for user and assistant messages: copy live,
// branch wired through onBranch, date-aware clock,
// optional edit stub.
import { useCallback } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -18,17 +20,21 @@ export interface MessageIconActionsProps {
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Fork the session at this message. */
onBranch?: (() => void) | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
/**
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
* @param props - Copy text, event time, clock side, optional edit, className.
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, className,
text, time, clock, edit, onBranch, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
@@ -36,25 +42,25 @@ export function MessageIconActions({
}, [text])
const clockEl = (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, day)}
{formatMessageClock(time, t, day)}
</span>
)
return (
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
{clock === 'start' ? clockEl : null}
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<Tooltip label={t('copy')} side="bottom">
<button type="button" className={css.action} aria-label={t('copy')} onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<Tooltip label={t('message.branch')} side="bottom">
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
<IconBranchOutline16 />
</button>
</Tooltip>
{edit === true && (
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<Tooltip label={t('edit')} side="bottom">
<button type="button" className={css.action} aria-label={t('edit')}>
<IconEditOutline16 />
</button>
</Tooltip>

View File

@@ -10,11 +10,17 @@ import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
@@ -60,7 +66,8 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
export const MessageItem = memo(function MessageItem({ node, onFork, t }: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user': {
const { text, rest } = contentText(node.content)
@@ -68,14 +75,16 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
<MessageIconActions
text={text}
time={node.time}
clock="start"
edit
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
</div>
)
@@ -85,23 +94,21 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
return (
<div className={css.userRow}>
<div className={css.bubble}>
<span className={css.badge}></span>
<span className={css.badge}>{t('message.steering')}</span>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
</div>
)
}
case 'context':
return (
<div className={css.contextRow}>
<JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
</div>
<ContextInjectionRow content={node.content} source={node.source} t={t} />
)
default:
return (
<div className={css.contextRow}>
<JsonBlock label={`未知 surface 事件:${node.type}`} payload={node.data} />
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} />
</div>
)
}

View File

@@ -2,12 +2,22 @@
736px message column axis. */
.root {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
max-width: 736px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
padding: 4px 24px 8px;
padding: 4px 24px 0px;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
}
.sep {
color: var(--dsw-alias-separator-primary);
}

View File

@@ -2,7 +2,7 @@
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { memo, useMemo } from 'react'
import { Fragment, memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import css from './StatsLine.module.css'
@@ -10,7 +10,13 @@ import css from './StatsLine.module.css'
interface UsageTotals {
turns: number
steps: number
tokens: number
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Prompt-side tokens: inputTokens + cacheReadTokens. */
inputTokens: number
outputTokens: number
cacheHitPct: number | null
}
@@ -22,35 +28,72 @@ interface UsageLike {
}
/**
* Fold assistant nodes into display totals.
* Fold assistant and tool-result nodes into display totals.
* @param nodes - snapshot nodes.
* @returns totals; cacheHitPct null until any cache accounting arrives.
*/
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
const turns = new Set<number>()
let steps = 0
let tokens = 0
let llmMs = 0
let toolMs = 0
let input = 0
let output = 0
let cacheRead = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
continue
}
if (node.kind !== 'assistant') continue
turns.add(node.turn)
steps += 1
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const usage = node.usage as UsageLike | undefined
if (usage === undefined) continue
input += usage.inputTokens ?? 0
output += usage.outputTokens ?? 0
cacheRead += usage.cacheReadTokens ?? 0
tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0)
}
const denom = input + cacheRead
return {
turns: turns.size,
steps,
tokens,
llmMs,
toolMs,
inputTokens: input + cacheRead,
outputTokens: output,
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
}
}
/**
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
* @param n - token count.
* @returns display string.
*/
export function formatTokens(n: number): string {
const scaled = (v: number): string =>
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
if (n < 1_000) return String(n)
if (n < 1_000_000) return `${scaled(n / 1_000)}K`
return `${scaled(n / 1_000_000)}M`
}
/**
* Compact duration: 45.2s under a minute, 2m42s from there on.
* @param ms - duration in milliseconds.
* @returns display string.
*/
export function formatDuration(ms: number): string {
const s = ms / 1_000
if (s < 60) return `${Math.round(s * 10) / 10}s`
const whole = Math.round(s)
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
@@ -58,10 +101,22 @@ export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps)
const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
const parts: string[] = []
if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`)
parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`)
parts.push(`${stats.turns} turns`)
parts.push(`${stats.steps} steps`)
return <div className={css.root}>{parts.join(' · ')}</div>
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = [`${stats.turns} turns · ${stats.steps} 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 (durations.length > 0) groups.push(durations.join(' · '))
if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`)
groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`)
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>
)
})

View File

@@ -9,10 +9,6 @@
.row {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
@@ -41,30 +37,8 @@
90%, 100% { left: 100%; }
}
/* Expand-on-row (Think / code): pointer only — no row fill hover. */
.row[data-expandable] {
cursor: pointer;
}
.leading {
position: relative; /* .chevronHover overlay anchor */
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
padding: 0;
border: none;
background: none;
color: var(--dsw-alias-label-tertiary);
}
/* The others-variant sparkle glyph is one gray step darker than the icon
family in the source design. */
.root[data-variant='others'] .leading {
color: var(--dsw-alias-label-secondary);
flex-shrink: 0;
}
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
@@ -82,44 +56,12 @@
background: var(--dsw-alias-state-business-primary);
}
button.leading {
cursor: pointer;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.row:hover .iconIdle {
opacity: 0;
}
.row:hover .chevronHover {
opacity: 1;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
font-weight: 400;
}
.sep {
@@ -165,8 +107,65 @@ button.leading {
text-decoration: underline;
}
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
.body {
/* Error row's collapsed summary: the failure's first line in the error color. */
.errorSummary {
color: var(--dsw-alias-state-error-primary);
}
/* Expanded body + Inspect pill wrapper (sibling of .row: clicks never toggle). */
.bodyWrap {
display: flex;
flex-direction: column;
}
/* Hover-revealed jump to the trajectory record: a small pill in real flow
under the expanded body's bottom-left corner (it reserves its line, so
revealing never shifts layout); revealed by hovering anywhere on the tool
call — title row included — or by keyboard focus. */
.inspectButton {
display: inline-flex;
align-self: flex-start;
align-items: center;
gap: 4px;
margin: 4px 0 2px 4px;
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
/* Base background, not bg-overlay: the overlay token is a raised dark
surface and reads too heavy for a quiet in-flow affordance. */
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 16px;
cursor: pointer;
opacity: 0;
transition: opacity 100ms ease;
}
.root:hover .inspectButton,
.inspectButton:focus-visible {
opacity: 1;
}
/* Solid hover fill (a translucent token would let content bleed through). */
.inspectButton:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
color: var(--dsw-alias-label-primary);
}
/* Expanded-body scroll wrapper for the run_code CodeBlock; the IN/OUT card
and the terminal card scroll INSIDE their own surface instead, so the
scrollbar sits within the rounded card. */
.bodyScroll {
max-height: 260px;
overflow-y: auto;
}
/* Think expanded body: plain indented gray reasoning prose — no IN/OUT card
(the reasoning is not an input payload), pre-wrapped at the row's indent.
Uncapped: reasoning reads as message prose, so it flows with the page
instead of scrolling in a box. */
.thinkBody {
padding: 4px 0 4px 22px;
font-size: 14px;
line-height: 24px;
@@ -175,6 +174,78 @@ button.leading {
color: var(--dsw-alias-label-tertiary);
}
/* Expanded input/output card (figma 1249:35657): the code-block surface and
radius from the TerminalBlock/CodeBlock family. The card itself is a plain
column — the padding and the IN/OUT gutter-label grid live on each section
so the divider spans the full card width and each section scrolls alone. */
.ioCard {
display: flex;
flex-direction: column;
margin: 4px 0 4px 4px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 12px;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block-small);
}
/* One card section (IN or OUT): the gutter-label grid, capped and scrolling
independently so a long input never buries a short output (and vice versa). */
.ioSection {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 14px;
align-items: baseline;
padding: 12px 16px;
max-height: 150px;
overflow-y: auto;
}
/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so
it floats off the rounded card edge instead of hugging it (the terminal
card's own output scroller carries the same treatment in TerminalBlock). */
.ioSection::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
/* Track end-margins keep the thumb's travel out of the rounded corners. */
.ioSection::-webkit-scrollbar-track {
margin: 6px 0;
}
/* Caption (not tertiary): one step dimmer than the payload text so the
gutter labels read as labels, not as part of the content. Sticky against
the section's own scroll so the label stays readable while its payload
scrolls underneath (top 0 = the section's padding edge inside the
scrollport; start-aligned because sticky needs a block-start anchor). */
.ioLabel {
position: sticky;
top: 0;
align-self: start;
color: var(--dsw-alias-label-caption);
}
/* l2 hairline between the IN and OUT sections, spanning the full card width
(it sits between the padded sections, not inside their grid). */
.ioDivider {
flex: none;
height: 1px;
background: var(--dsw-alias-border-l2);
}
.ioText {
min-width: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--dsw-alias-label-secondary);
}
/* A failed call's OUT text shares the collapsed summary's error color. */
.ioText[data-error] {
color: var(--dsw-alias-state-error-primary);
}
/* The two block-shaped expanded bodies: the code variant's run_code program
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
command output through TerminalBlock. Both are drawn by the shared
@@ -183,15 +254,21 @@ button.leading {
flow's row rhythm. */
.codeBody,
.terminalBody {
margin: 4px 0 4px 22px;
margin: 4px 0 4px 4px;
}
/* Indented to the body's own column so the description reads as the card's
heading rather than as another summary row, and sits tight against the card
below it. Its own rule: grouping it with a body would put description
typography on a `CodeBlock` wrapper and change that body's spacing. */
.terminalDescription {
margin: 4px 0 0 22px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
/* In-row code renders at the smaller code size (12/18) via each primitive's
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
.codeBody {
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small);
}
/* The terminal card scrolls its OUTPUT inside its own surface (same l1
hairline as the IN/OUT card): the banner stays pinned and the scrollbar
never rides over it. 224px = the 260px card cap minus the ~36px banner. */
.terminalBody {
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
--dsl-terminal-line-height: 18px;
--dsl-terminal-output-max-height: 224px;
border: 1px solid var(--dsw-alias-border-l1);
}

View File

@@ -1,22 +1,33 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary. The collapsed row is always one
// line; the expanded body is indented gray text, the run_code program through
// CodeBlock, or — for a call whose render intent is a terminal card — the
// command's own output through TerminalBlock, capped at
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
// component-local view state. File-tool summaries are path links that open
// through the host; the row itself is not a details-panel control.
// separator dot + FILL-truncated summary, drawn through the shared
// DisclosureRow chrome with the whole row as the expand toggle (click /
// Enter / Space, icon→chevron hover preview). The collapsed row is always
// one line; every row with body, output, or terminal material is expandable;
// the summary stays inline while open, except Think, whose body opens with
// the same first line and would repeat it.
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
// text input/output, the run_code program through CodeBlock, or a terminal
// card's command output through TerminalBlock — lives in a max-height scroll
// container so a long payload scrolls internally instead of taking over the
// message flow; Think's prose is the exception and flows uncapped like
// message text. Expand state is component-local view state. File-tool
// summaries are path links that open through the host (stopPropagation keeps
// the two gestures independent); an error row's collapsed summary is the
// failure's first line in the error color.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ToolRow.module.css'
export interface ToolRowProps {
/** The render site's conversation locale seat (terminal/code body copy). */
t: TranslateNS<'conversation'>
variant: ToolRowVariant
/** Wire tool name for tool-owned styling layered over the generic variant. */
toolName?: string | undefined
@@ -24,18 +35,20 @@ export interface ToolRowProps {
icon: ReactNode
title: string
summary: string
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
/** Expanded-body input text; null = no input section. */
body: string | null
/** Flattened result text for the expanded Output section; null/absent = no output section. */
output?: string | null | undefined
/** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */
errorSummary?: string | null | undefined
/**
* Terminal-card material for a call whose render intent is a terminal card
* (derived by `terminalCardModel`); it replaces the text body when present.
* Null or absent leaves the text body, and a row with neither is not
* expandable (its leading slot never toggles).
* (derived by `terminalCardModel`); it replaces the text sections when
* present. A row with no body, no output, and no terminal material is not
* expandable.
*/
terminal?: TerminalCardModel | null | undefined
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
* renders as a hover-underline link that opens the host default app.
@@ -43,6 +56,21 @@ export interface ToolRowProps {
filePath?: string | undefined
/** Open the path with the host OS default application (already cwd-resolved). */
onOpenFile?: ((path: string) => void) | undefined
/**
* Jump to this call in the trajectory view: a hover-revealed Inspect pill
* over the expanded body. Absent = no affordance (rows without a call
* identity, like Think).
*/
inspect?: (() => void) | undefined
}
/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
function IconInspect() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
)
}
/** Leading-slot state substitution: the tool icon yields to the terminal state
@@ -57,89 +85,66 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
}
export function ToolRow({
t,
variant,
toolName,
icon,
title,
summary,
body,
output,
errorSummary,
terminal,
state,
expandOnRowClick = false,
filePath,
onOpenFile,
inspect,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
// A row that names a single file keeps one interaction (open that path);
// args expand is off whether or not the open callback is wired yet. Terminal
// material still expands: only the file variants carry a path, so a terminal
// card and a file link never land on the same row.
const singleFile = filePath !== undefined
const fileLink = singleFile && onOpenFile !== undefined
const expandable = (body !== null && !singleFile) || terminalBody !== null
// The text arms take the empty string for a null body: a row expandable
// only through its terminal material renders the terminal body instead, so
// this substitution never shows.
const text = body ?? ''
const outputText = output ?? null
const expandable = body !== null || outputText !== null || terminalBody !== null
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
// An error row's collapsed summary IS the failure: the first error line in
// the error color outranks both the args summary and a terminal description.
const failureLine = state === 'error' ? errorSummary ?? null : null
const summaryText = failureLine ?? summary
// The failure line is error prose, not the path: no open-file affordance.
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const toggleExpand = () => {
setExpanded(v => !v)
}
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
toggleExpand()
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
if (filePath !== undefined) onOpenFile?.(filePath)
}
// Expandable rows preview the toggle on hover: the tool icon yields to a
// down chevron (CSS swap on .row:hover); state dots still take precedence.
const collapsedIcon = expandable
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
: leadingFor(state, collapsedIcon)
// Think reasoning is prose, not an input payload: expanded, it renders as
// plain indented text (no IN/OUT card) and the inline summary — the body's
// own first line — yields to avoid repeating itself.
const isThink = variant === 'think'
// The code variant's program renders through CodeBlock (shiki), so only its
// output joins the IN/OUT card; every other variant's input does too.
const cardBody = variant === 'code' ? null : body
// The state substitution rides the idle icon slot, so an expandable error
// row keeps DisclosureRow's icon→chevron hover preview (its default) instead
// of losing it with the icon.
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
className={css.row}
data-expandable={rowExpands || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? toggleExpand : undefined}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable && !rowExpands ? (
<button
type="button"
className={css.leading}
aria-expanded={open}
onClick={toggleFromLeading}
>
{leading}
</button>
) : (
<span className={css.leading}>
{leading}
</span>
)}
<span className={css.title}>{title}</span>
{!open && (
<DisclosureRow
rowClassName={css.row}
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={leadingFor(state, icon)}
title={title}
open={open}
expandable={expandable}
expandOnRowClick
keepContentWhenOpen={!isThink}
onToggle={toggleExpand}
collapsedContent={summaryText !== '' && (
/* An empty summary drops the separator with it (a row that is only
its title shows no trailing dot). */
<>
<span className={css.sep} aria-hidden />
{fileLink ? (
@@ -148,25 +153,72 @@ export function ToolRow({
className={css.fileLink}
onClick={openFile}
>
{summary}
{summaryText}
</button>
) : (
<span className={css.summary}>{summary}</span>
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
{summaryText}
</span>
)}
</>
)}
</div>
{/* The terminal presenter's description belongs ABOVE the card per the
render-intent contract, so an expanded terminal row keeps showing it
even though the collapsed summary is hidden while open. */}
{open && terminalBody?.description !== undefined && (
<div className={css.terminalDescription}>{terminalBody.description}</div>
)}
{open && (terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>)}
>
{/* The wrapper (sibling of the header row, so clicks inside never
toggle it) carries the expanded body and the Inspect pill below. */}
<div className={css.bodyWrap}>
{terminalBody !== null
? (
<TerminalBlock
{...terminalBody.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
className={css.terminalBody}
/>
)
: isThink
? <div className={css.thinkBody}>{body}</div>
: (
<>
{variant === 'code' && body !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"
className={css.inspectButton}
onClick={inspect}
>
<IconInspect />
Inspect
</button>
)}
</div>
</DisclosureRow>
</div>
)
}

View File

@@ -3,15 +3,24 @@
* results group into consecutive-run tool groups (figma step-summary flow,
* VERTICAL gap10) alternating with narration; everything else passes through.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content.
* subscribe to keys only while rows subscribe to content. IconActions ownership
* (last content assistant per turn) is derived here too so ChatView and the
* flow share one gate.
*/
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type {
AssistantBlock, ConversationNode, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One renderable flow item; key is the React key and the parent's identity unit. */
export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
/** True when the node has model-visible text content worth IconActions chrome. */
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
}
/** An assistant node that renders nothing: only tool-call heads (rows render
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
@@ -22,6 +31,21 @@ function rendersNothing(node: ConversationNode): boolean {
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/**
* Seq set of assistants that own IconActions: the last content-text assistant
* in each turn. Mid-turn narration (text before tools) stays chrome-free.
* @param nodes - snapshot nodes (surface order).
* @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
*/
export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet<number> {
const lastByTurn = new Map<number, number>()
for (const node of nodes) {
if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue
lastByTurn.set(node.turn, node.seq)
}
return new Set(lastByTurn.values())
}
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).

View File

@@ -1,6 +1,11 @@
// Shared chrome helpers for user/assistant IconActions rows: clipboard write
// and the compact date+clock label from a session-event epoch.
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
/** The date-template share of the conversation dictionary the clock consumes. */
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
/**
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
* @param text - Plain text to place on the clipboard.
@@ -67,14 +72,16 @@ export function msUntilNextLocalMidnight(ms: number): number {
}
/**
* Compact local timestamp for message IconActions.
* Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`;
* other years → `YYYY年M月D日 HH:mm`.
* Compact local timestamp for message IconActions. Same calendar day →
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other
* years → the `clock.ymd` template + clock. Pure: the date templates arrive
* through the caller's locale seat.
* @param time - Unix epoch ms from the source session event.
* @param t - translate seat supplying the `clock.md` / `clock.ymd` templates.
* @param now - Reference instant for the day/year cut (defaults to wall clock).
* @returns Date-aware clock string (24-hour, zero-padded time).
*/
export function formatMessageClock(time: number, now: number = Date.now()): string {
export function formatMessageClock(time: number, t: ClockTranslate, now: number = Date.now()): string {
const d = new Date(time)
const n = new Date(now)
const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
@@ -85,7 +92,7 @@ export function formatMessageClock(time: number, now: number = Date.now()): stri
) {
return clock
}
const md = `${d.getMonth() + 1}${d.getDate()}`
if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}`
return `${d.getFullYear()}${md} ${clock}`
const params = { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() }
const md = d.getFullYear() === n.getFullYear() ? t('clock.md', params) : t('clock.ymd', params)
return `${md} ${clock}`
}

View File

@@ -1,7 +1,7 @@
/** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
@@ -67,7 +67,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* design §6 MIX evidence: entries coexist in fixed order).
*/
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** The composer top-edge band (stats line family). */
/** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** Tool-row left region inside the input card (existing chrome stays in place beside entries). */
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
@@ -77,11 +77,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* The default composer body: a single slot rendered as the composer
* chain's fallback (decision 20 — a real entry, not a chain rider, so a
* takeover election hides rather than unmounts it and the textarea DOM
* survives). InputBar registers here from this package's apply; its
* machine state arrives through the standard provide channel (useInput +
* inputActions), the keyboard command face through its own inject.
* survives). Session-maybe: the bar stays mounted across the
* no-session/session transition — the no-workspace hero renders the SAME
* textarea DOM disabled instead of a parallel inert tree — with the
* machine hooks absent until a session is current. InputBar registers
* here from this package's apply; its machine state arrives through the
* standard provide channel (useInput + inputActions), the keyboard
* command face through its own inject.
*/
'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps }
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
/**
* The Plan-mode status seat in the composer tool row (left group,
* right of the access-mode control). Declared by the composer-bar
@@ -143,13 +147,17 @@ export interface InputZone {
}
/**
* View-slot owner share: deliberately empty — ConversationRoot supplies
* nothing at its renderSlot site (sessionId and the snapshot hook arrive as
* View-slot owner share: the cross-view inspect handoff (otherwise views need
* nothing from the render site sessionId and the snapshot hook arrive as
* framework-standard props; tool rows go through each view's own declared
* toolview hole). Kept as the named owner seat so a future cross-view
* payload has a home.
* toolview hole).
*/
export interface ConvViewOwnerProps {}
export interface ConvViewOwnerProps {
/** One-shot inspect request from another view (chat's Inspect button); null when idle. */
inspect?: { callId: CallId } | null
/** Acknowledge the inspect request once applied (clears the store field). */
onInspectDone?: () => void
}
/**
* Owner share of a per-view toolview slot: the call material the rendering
@@ -172,6 +180,11 @@ export interface ToolRowOwnerProps {
* The chat view resolves relative paths against the session cwd.
*/
openFile: (path: string) => void
/**
* Jump to this call's record in the trajectory view (the expanded row's
* hover Inspect affordance). Undefined when no trajectory jump is wired.
*/
inspect?: (() => void) | undefined
}
/**
@@ -244,6 +257,12 @@ export interface ConversationSessionInjected {
export interface ComposerBarOwnerProps {
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
/**
* Inert no-workspace state: the bar renders its normal DOM fully disabled
* (textarea, add, send) so the workspace pick transitions in place instead
* of swapping component trees.
*/
disabled?: boolean
placeholder?: string
/** Optional content rendered above the textarea. */
accessory?: ReactNode
@@ -253,25 +272,30 @@ export interface ComposerBarOwnerProps {
leftItems?: ReactNode
/** input.right slot entries (tool row, before the primary button). */
rightItems?: ReactNode
/** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
footer?: ReactNode
onAdd?: () => void
addLabel?: string
}
/** Injected share of the composer-bar entry (package-internal faces). */
export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
keyboard: ComposerKeyboard
/** Cancel the in-flight turn. */
stop: () => void
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Cancel the in-flight turn; absent with the session. */
stop: (() => void) | undefined
/**
* Submit one slash-command line against this session's agent (the chrome
* controls' write path — the permission chip submits `/permission <preset>`).
* controls' write path — the permission chip submits `/permission <preset>`);
* absent with the session.
* Resolves admission: false = rejected/unmatched/transport failure.
*/
command: (line: string) => Promise<boolean>
/** Locale-aware hint translator for claimed command placeholders. */
translateHint: (key: string) => string
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
command: ((line: string) => Promise<boolean>) | undefined
/**
* Registrant hooks compartment: the renderer binds these to
* useNotices/useLexicon (static absent sources without a session — hook
* order stays constant).
*/
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot<InputNotice | null>
@@ -289,11 +313,12 @@ export interface InputControlOwnerProps {
locked: boolean
}
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
/** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& InjectFace<ComposerBarInjected>
& PropsLocale<'conversation'>
/**
* Composer chain currency: what ConversationRoot dispatches at its
@@ -308,7 +333,8 @@ export interface ComposerChainProps {
/**
* Full conversation-slot component props: runtime & child-render (view ring
* + composer chain/bar + input-region + hero picker slots) & store & injected shares.
* + composer chain/bar + input-region + hero picker slots) & store & injected
* shares & the locale seat.
*/
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<
@@ -319,13 +345,15 @@ export type ConversationSlotProps =
| 'conversation.hero.workspace'
>
& ConversationInjected
& PropsLocale<'conversation'>
/** Full strict-session content props: per-session store, view ring, and callbacks. */
/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */
export type ConversationSessionSlotProps =
PropsRuntime<'conversation.session'>
& PropsRenderSlots<'conversation.view'>
& PropsStore<ChatStore>
& ConversationSessionInjected
& PropsLocale<'conversation'>
/** The pending approval carrier the owner dispatches into the composer chain. */
export type ApprovalWait = PendingWait<'approval'>
@@ -383,11 +411,13 @@ export class PendingApproval {
/**
* Full approval-composer props: the framework runtime share (chain currency +
* session/global standard kit) plus the chain `matched` share — the entry's
* selector result, already narrowed to the approval carrier. No injected
* share: the carrier plus the domain face above carry the whole behavior
* surface; the paired command line derives from useSession in-component.
* selector result, already narrowed to the approval carrier — plus the
* standard locale seat. No injected share: the carrier plus the domain face
* above carry the whole behavior surface; the paired command line derives
* from useSession in-component.
*/
export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait }
export type ApprovalComposerProps =
PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'>
/**
* Injected share of the chat view entry: the two callbacks whose targets live
@@ -402,12 +432,27 @@ export interface ChatViewInjected {
*/
openFile: (path: string) => void
loadOlder: () => void
/** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */
inspectCall: (callId: CallId) => void
/**
* Per-session scroll memory surviving view switches (in-memory, never
* persisted): the view saves on every scroll and restores on remount; a
* fresh page load starts empty and keeps the open-jump-to-bottom default.
*/
chatScroll: {
/** Record the scroll offset; null clears it (pinned to bottom). */
save: (top: number | null) => void
/** Last recorded offset, or null when pinned or never recorded. */
read: () => number | null
}
/** Fork the session through the turn containing the message at `seq`, then open the child. */
forkAt: (seq: number) => void
}
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
& PropsStore<ChatStore> & ChatViewInjected
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/**
* Injected share of the details slot: the panel is otherwise a pure reader of
@@ -418,8 +463,8 @@ export interface DetailsInjected {
closeDetails: () => void
}
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
/** Full details-slot component props: selection rides the shared store, call material useSession; copy the locale seat. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected & PropsLocale<'conversation'>
/** Owner share common to the hero / New-Session Workspace pickers. */
export interface EmptyWorkspaceOwnerProps {

View File

@@ -8,19 +8,34 @@
* are derived once.
* @module
*/
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
/**
* Output lines the chat row's expanded terminal body shows before collapsing
* the middle — half the primitive's own default, which the details panel
* keeps. A chat row is a summary surface inside the message flow: the flow
* must stay scannable across many calls, while the details panel is the
* single-call reading surface. A design constant of this UI's row geometry,
* not a deployment choice, so it is fixed here rather than a plugin Config
* field.
* Build the TerminalBlock display copy from the conversation locale seat —
* the one place the primitive's label surface pairs with this package's
* dictionary, shared by every terminal render site (chat row, bash row,
* details panel).
* @param t - the render site's conversation locale seat.
* @returns the full label set for {@link TerminalBlockProps}'s `labels`.
*/
export const CHAT_TERMINAL_MAX_LINES = 8
export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlockLabels {
return {
signal: signal => t('terminal.signal', { signal }),
exitCode: code => t('terminal.exitCode', { code }),
running: t('terminal.running'),
failed: t('terminal.failed'),
done: t('terminal.done'),
copy: t('copy'),
copied: t('copied'),
noOutput: t('terminal.noOutput'),
collapseAria: t('terminal.collapseAria'),
collapse: t('collapse'),
expandAria: hidden => t('terminal.expandAria', { n: hidden }),
expand: hidden => t('terminal.expandRest', { n: hidden }),
}
}
/**
* The {@link TerminalBlock} props this derivation owns. Picked off the
@@ -44,6 +59,20 @@ export interface TerminalCardModel {
description: string | undefined
}
/**
* True when a settled terminal card reports a failing exit — a non-zero code
* or a terminating signal. The bash tool settles a failing command as a
* completed call (`isError` stays false: the exit status is result data), so
* this is the collapsed row's only failure signal; without it the red exit
* pill would be visible only after expanding the card.
* @param model - a derived terminal card.
* @returns whether the card's exit status is a failure.
*/
export function terminalFailed(model: TerminalCardModel): boolean {
const { exitCode, signal, running } = model.card
return running !== true && ((exitCode !== undefined && exitCode !== 0) || signal !== undefined)
}
/**
* Resolve a terminal view's working directory the way the render-intent
* contract assigns to the UI bridge: an absolute path is used as-is, a relative

View File

@@ -1,14 +1,15 @@
/**
* Pure row-model derivation for tool summary rows: variant classification,
* one-line summary and expanded-body text from the frozen call slice. This
* derivation reads the call ARGUMENTS only; a call whose render intent is a
* terminal card gets its expanded body from the views instead, through
* one-line summary, expanded-body text, and flattened result output from the
* frozen call slice. Input material comes from the call ARGUMENTS; output and
* error material from the settled result node. A call whose render intent is
* a terminal card gets its expanded body from the views instead, through
* `terminalCardModel` in terminal-card-model.ts.
*/
// The block union's defining home is runtime (fold-product types); this
// contract only forwards it (type-definition authority stays with the layer
// that produces the values).
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -70,11 +71,34 @@ export interface ToolRowModel {
* relative values against the session cwd before opening.
*/
filePath: string | undefined
/** Expanded-body text (pretty args); null = row not expandable. */
/** Expanded-body input text (pretty args); null = no input section. */
body: string | null
/** Flattened result text ({@link resultText}); null while running or when the result carries no text. */
output: string | null
/** First line of the result text on an error row; null for every other state. */
errorSummary: string | null
state: ToolRowState
}
/**
* Flatten a settled result's content blocks to display text: text blocks
* verbatim, other block shapes as pretty JSON. Empty content on a failed call
* falls back to the structured error's `name: code` line.
* @param node - the settled result node.
* @returns the flattened result text (may be empty).
*/
export function resultText(node: ToolResultNode): string {
const parts: string[] = []
for (const block of node.content) {
if (block.type === 'text') parts.push(block.text)
else parts.push(JSON.stringify(block, null, 2))
}
if (parts.length === 0 && node.error !== undefined) {
parts.push(`${node.error.name}: ${node.error.code}`)
}
return parts.join('\n')
}
function parseArgs(argsRaw: string): unknown {
try {
return JSON.parse(argsRaw)
@@ -192,12 +216,19 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
? `${toolName} · ${base}`
: base
// The empty string is "no text" for both derived result fields: a settled
// call with blank content has nothing to expand, and a blank first line
// would erase the collapsed error row's summary slot.
const output = done ? (resultText(block) || null) : null
const errorSummary = state === 'error' && output !== null ? firstLine(output) : null
return {
variant,
title: toolTitle ?? VARIANT_TITLES[variant],
summary,
filePath: deriveFilePath(variant, argsRaw),
body: deriveBody(variant, argsRaw),
output,
errorSummary,
state,
}
}

View File

@@ -23,4 +23,10 @@ export interface ChatStoreState {
draft: string
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
view: string | null
/**
* One-shot inspect handoff: chat writes the call to reveal, the trajectory
* view consumes it and acknowledges by clearing. Read with `?? null` —
* persisted snapshots from before this field rehydrate without it.
*/
inspect: { callId: CallId } | null
}

View File

@@ -11,6 +11,7 @@ export type {
CallId, ChatStoreState, SelectionTarget, ViewTab,
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type { ConversationKey } from './locales.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,

View File

@@ -0,0 +1,170 @@
/** `conversation` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'conversation'
// The claimed /plan hint and the plan-mode textarea placeholder share one
// string: both describe the same next action.
const PLAN_NEXT_ACTION_ZH = '描述你的任务以生成计划'
const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'view.chat': '对话',
'hint.plan': PLAN_NEXT_ACTION_ZH,
'hint.goal': '输入目标,智能体将持续执行',
'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
'placeholder.default': '给智能体发消息',
'placeholder.unavailable': '会话不可用',
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'input.addAttachment': '添加附件',
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'hero.headline': '开始构建吧',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'details.title': '详情',
'details.close': '关闭详情',
'details.empty': '点击消息流中的工具行查看详情',
'details.notInWindow': '该调用不在当前窗口内',
'details.input': '输入',
'details.output': '输出',
'details.running': '运行中…',
'todo.title': '任务清单',
'todo.progress': '{done}/{total} 项任务 · {active} 项进行中',
'todo.rowTitle': '更新任务清单',
'todo.completed': '{done}/{total} 已完成',
'chat.loadingHistory': '载入历史…',
'chat.loadError': '历史加载失败:{message}{code}',
'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.steering': '插话',
'message.contextInjection': '上下文注入',
'message.unknownSurface': '未知 surface 事件:{type}',
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
'message.branch': '在新对话中分支',
'command.running': '执行中…',
'command.failed': '命令失败',
'command.done': '已完成',
'command.title': '命令',
'approval.waiting': '等待审批',
'approval.detail.aria': '审批详情',
'approval.escalation': '工具 {toolName} 请求越权执行',
'approval.reject': '拒绝',
'approval.allowOnce': '允许一次',
'ask.rowTitle': '提问',
'ask.waiting': '等待回答',
'ask.cancelled': '已取消',
'ask.interrupted': '已中断',
'ask.answered': '{answered}/{total} 已回答',
'bash.running': '运行中',
'bash.failed': '失败',
'bash.stopped': '已停止',
'queue.count': '{n} 条排队消息',
'queue.edit': '编辑排队消息',
'queue.edit.unsupported': '包含非文本内容,暂不支持编辑',
'queue.save': '保存排队消息',
'queue.cancelEdit': '取消编辑',
'queue.remove': '删除排队消息',
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
'terminal.signal': '信号 {signal}',
'terminal.exitCode': '退出码 {code}',
'terminal.running': '运行中',
'terminal.failed': '失败',
'terminal.done': '已完成',
'terminal.noOutput': '无输出',
'terminal.collapseAria': '收起输出',
'terminal.expandAria': '展开其余 {n} 行输出',
'terminal.expandRest': '… 其余 {n} 行',
'json.truncated': '… 已截断,共 {total} 字符',
'clock.md': '{m}月{d}日',
'clock.ymd': '{y}年{m}月{d}日',
} satisfies Record<string, string>
/** The conversation namespace key union. */
export type ConversationKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'view.chat': 'Chat',
'hint.plan': PLAN_NEXT_ACTION_EN,
'hint.goal': 'describe the objective for a long-running task',
'hint.goal.active': 'goal active — edit / pause / resume / clear',
'placeholder.plan': PLAN_NEXT_ACTION_EN,
'placeholder.default': 'Message the agent',
'placeholder.unavailable': 'Session unavailable',
'placeholder.hero': 'Describe what you want to build',
'placeholder.workspace': 'Choose a workspace to start',
'input.addAttachment': 'Add attachment',
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'hero.headline': 'Let\'s start building',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'details.title': 'Details',
'details.close': 'Close details',
'details.empty': 'Click a tool row in the message flow to view its details',
'details.notInWindow': 'This call is outside the current window',
'details.input': 'Input',
'details.output': 'Output',
'details.running': 'Running…',
'todo.title': 'To-dos',
'todo.progress': '{done}/{total} tasks · {active} in progress',
'todo.rowTitle': 'Update to-do list',
'todo.completed': '{done}/{total} completed',
'chat.loadingHistory': 'Loading history…',
'chat.loadError': 'Failed to load history: {message} ({code})',
'chat.loadOlder': 'Load earlier',
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.steering': 'Interjection',
'message.contextInjection': 'Context injection',
'message.unknownSurface': 'Unknown surface event: {type}',
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',
'message.branch': 'Branch into a new conversation',
'command.running': 'Running…',
'command.failed': 'Command failed',
'command.done': 'Completed',
'command.title': 'Command',
'approval.waiting': 'Waiting for approval',
'approval.detail.aria': 'Approval details',
'approval.escalation': 'Tool {toolName} requests privileged execution',
'approval.reject': 'Reject',
'approval.allowOnce': 'Allow once',
'ask.rowTitle': 'Ask question',
'ask.waiting': 'waiting',
'ask.cancelled': 'cancelled',
'ask.interrupted': 'interrupted',
'ask.answered': '{answered}/{total} answered',
'bash.running': 'Running',
'bash.failed': 'Failed',
'bash.stopped': 'Stopped',
'queue.count': '{n} queued messages',
'queue.edit': 'Edit queued message',
'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet',
'queue.save': 'Save queued message',
'queue.cancelEdit': 'Cancel editing',
'queue.remove': 'Remove queued message',
'queue.editFailed': 'Edit failed: this message may have already started sending.',
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
'terminal.signal': 'signal {signal}',
'terminal.exitCode': 'exit code {code}',
'terminal.running': 'Running',
'terminal.failed': 'Failed',
'terminal.done': 'Done',
'terminal.noOutput': 'No output',
'terminal.collapseAria': 'Collapse output',
'terminal.expandAria': 'Expand the remaining {n} output lines',
'terminal.expandRest': '… {n} more lines',
'json.truncated': '… truncated, {total} characters total',
'clock.md': '{m}/{d}',
'clock.ymd': '{y}-{m}-{d}',
} satisfies Record<ConversationKey, string>

View File

@@ -16,9 +16,11 @@
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
/* Eat InputBar's 6px top padding and tuck the panel 2px under the card;
the later composer sibling paints its surface and shadow over this edge. */
margin: 0 auto -10px;
/* Flex gap still applies after this item; subtract it together with the
design's overlap so the later composer paints over the queue edge. */
margin: 0 auto calc(
0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap)
);
padding: 2px 12px;
}
@@ -29,6 +31,8 @@
padding-top: 2px;
border-radius: 14px 14px 0 0;
background: var(--dsw-specific-tip);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.panel::after {
@@ -41,7 +45,52 @@
pointer-events: none;
}
.header {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 36px;
padding: 4px 16px 4px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
}
.header:focus-visible {
outline: 2px solid var(--dsw-alias-label-tertiary);
outline-offset: -2px;
}
.header:disabled {
cursor: default;
}
.count {
flex: 1 1 auto;
min-width: 0;
font-family: Inter, var(--dsw-font-family);
font-size: 14px;
font-weight: 500;
line-height: 24px;
}
.chevron {
display: grid;
flex: none;
place-items: center;
width: 14px;
height: 14px;
color: var(--dsw-alias-label-tertiary);
}
.list {
max-height: 180px;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;

View File

@@ -4,13 +4,15 @@
// The 'conversation.input.dock' SlotMap declaration lives in
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import { useEffect, useState } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { useEffect, useId, useState } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import { NS } from '../locales.ts'
import css from './QueueDock.module.css'
/** Queue operations injected by the session-scoped registration. */
@@ -19,21 +21,31 @@ export interface QueueDockInjected {
notify: (level: 'info' | 'error', text: string) => void
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'>
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
/**
* Queue strip: one item renders directly; multiple items default to a
* collapsible count header; an empty queue renders nothing.
*/
export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
const queue = useSession(s => s.queue)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
const [busy, setBusy] = useState<QueueItemId | null>(null)
const [collapsed, setCollapsed] = useState(true)
const listId = useId()
useEffect(() => {
if (queue.length === 0 && !collapsed) setCollapsed(true)
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
}, [editing, queue])
}, [collapsed, editing, queue])
if (queue.length === 0) return null
const interactionActive = editing !== null || busy !== null
const expanded = !collapsed || interactionActive
const listVisible = queue.length === 1 || expanded
const applyAction = async (
itemId: QueueItemId,
action: QueueAction,
@@ -56,22 +68,37 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
if (await applyAction(
editing.id,
{ kind: 'edit', content: [{ type: 'text', text: editing.text }] },
'编辑失败:这条消息可能已经开始发送。',
t('queue.editFailed'),
)) setEditing(null)
}
return (
<div className={css.dock} data-queue-dock="">
<div className={css.panel}>
<ul className={css.list}>
{queue.map(row => (
{queue.length > 1 && (
<button
type="button"
className={css.header}
aria-controls={listId}
aria-expanded={expanded}
disabled={interactionActive}
onClick={() => { setCollapsed(value => !value) }}
>
<span className={css.count}>{t('queue.count', { n: queue.length })}</span>
<span className={css.chevron} aria-hidden>
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
</span>
</button>
)}
<ul id={listId} className={css.list} hidden={!listVisible}>
{listVisible && queue.map(row => (
<li key={row.id} className={css.row}>
{editing?.id === row.id
? (
<input
autoFocus
className={css.editor}
aria-label="编辑排队消息"
aria-label={t('queue.edit')}
value={editing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
@@ -94,8 +121,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="保存排队消息"
title="保存排队消息"
aria-label={t('queue.save')}
title={t('queue.save')}
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
@@ -104,8 +131,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="取消编辑"
title="取消编辑"
aria-label={t('queue.cancelEdit')}
title={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
@@ -118,8 +145,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="编辑排队消息"
title={row.text === null ? '包含非文本内容,暂不支持编辑' : '编辑排队消息'}
aria-label={t('queue.edit')}
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
@@ -130,14 +157,14 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
<button
type="button"
className={css.action}
aria-label="删除排队消息"
title="删除排队消息"
aria-label={t('queue.remove')}
title={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
'删除失败:这条消息可能已经开始发送。',
t('queue.removeFailed'),
)
}}
>
@@ -162,14 +189,15 @@ export const queueDockEntry = {
name: 'conversation-queue-dock',
inject: ['slots', 'conversation', 'sessions'],
/**
* Register the queue strip into the input dock (list entry, order 0).
* Register the queue strip as the terminal input-dock entry (order 20).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({
name: 'conversation.input.dock',
id: 'queue',
order: 0,
order: 20,
locale: NS,
inject: (sessionId: SessionId): QueueDockInjected => {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)

View File

@@ -19,6 +19,13 @@
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
/* Elevated surface in dark, same as the menus: `.body` inside scrolls once
the justification or command passes the cap, so the thumb takes the l2
pair. Declared on the card because the elevation belongs to the surface,
and the custom properties inherit down to the region that actually
scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Tinted full-width header band. */
@@ -40,11 +47,22 @@
background: var(--dsw-alias-state-warn-primary);
}
/* Scroll region: an agent's justification and its command are unbounded model
text (a one-line `cd` or a 40-line heredoc), and the seat sits in a
fixed-height column — uncapped, a long command pushed the action row past
the viewport and the approval could not be answered at all. The strip and
the action row stay outside, so the buttons are always on screen. */
.body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px 14px;
/* border-box so the cap is the region's OUTER height: the composer's draft
area counts its padding inside the same number, and the two seats are
only interchangeable if they occupy the same box. */
box-sizing: border-box;
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
padding: 12px 16px 0;
}
/* The model's justification is the panel's message, not a footnote. */
@@ -63,11 +81,15 @@
word-break: break-all;
}
/* Card-level row, not body content. Its padding reproduces the metrics the row
had inside the body: 14px above (the flex gap of 6 plus the row's 8px top
margin, neither of which reaches it out here) and the body's former 14px
bottom pad below, so the resting card is unchanged. */
.actionRow {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
padding: 14px 16px 14px;
}
.allow,

View File

@@ -4,7 +4,11 @@
// pending, this panel occupies the composer slot in place of the InputBar:
// an amber "Waiting for approval" strip on the card top, the model's
// justification as the headline, the paired command in muted code text, and
// a right-aligned refuse/allow action row. One-shot: the buttons disable
// a right-aligned refuse/allow action row. Justification and command are
// unbounded model text, so they scroll inside the card at the shared composer
// cap (`data-approval-scroll`) and the action row stays outside it — the
// buttons must be reachable no matter how long the command is.
// One-shot: the buttons disable
// after a click and the panel leaves (the InputBar returns) on the broadcast
// resolved frame. The draft's "Always allow this type" is deferred with
// grant storage.
@@ -37,10 +41,14 @@ export function ApprovalPanel(props: ApprovalComposerProps) {
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
const command = props.useSession(s => commandOf(
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
return <ApprovalFlow key={approval.key} pending={approval} {...command === undefined ? {} : { command }} />
return <ApprovalFlow key={approval.key} pending={approval} t={props.t} {...command === undefined ? {} : { command }} />
}
function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) {
function ApprovalFlow({ pending, command, t }: {
pending: PendingApproval
command?: string
t: ApprovalComposerProps['t']
}) {
// Local one-shot latch: the panel leaves only when the resolved frame
// lands; until then the buttons must not re-fire. An answer failure
// (rejected receipt / transport) re-arms them for retry.
@@ -52,18 +60,21 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
return (
<div className={css.root} data-approval-key={pending.key}>
<div className={css.card}>
<div className={css.strip}><span className={css.dot} /></div>
<div className={css.body}>
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
<div className={css.strip}><span className={css.dot} />{t('approval.waiting')}</div>
{/* Tab stop: the region scrolls once the command passes the cap and
holds nothing focusable of its own, so without one a keyboard-only
user cannot reach the command's tail before answering. */}
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label={t('approval.detail.aria')}>
<div className={css.headline}>{pending.reason ?? t('approval.escalation', { toolName: pending.toolName })}</div>
{command !== undefined && <div className={css.command}>{command}</div>}
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</button>
</div>
</div>
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
{t('approval.reject')}
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
{t('approval.allowOnce')}
</button>
</div>
</div>
</div>

View File

@@ -127,15 +127,21 @@
min-height: 0;
}
/* Composer stack: dock strips above the input card (design §6 MIX order). */
/* Composer context stack (Figma 9:937): standalone dock cards share one
rhythm; the terminal queue strip additionally tucks under the input card. */
.composerStack {
display: flex;
flex-direction: column;
--dsh-composer-stack-gap: 6px;
--dsh-queue-composer-overlap: 5px;
/* InputBar and dock registrants derive their horizontal geometry from the
same card width, outer clearance, and dock inset. */
--dsh-composer-card-max-width: 800px;
--dsh-composer-side-clearance: 32px;
--dsh-composer-dock-inset: 12px;
display: flex;
flex-direction: column;
gap: var(--dsh-composer-stack-gap);
}
/* Common seat for the composer chain (fallback + elected overlay siblings). */
@@ -143,6 +149,14 @@
display: flex;
flex: none;
flex-direction: column;
/* One cap for every scrolling text region a composer seat can hold: the
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
takeover panels' bodies top out at the same height, so electing a
takeover never grows the footer past the card it replaces. Declared on
the seat because it is the chain's only shared ancestor — fallback and
elected overlay are siblings — and custom properties inherit down to
whichever entry is mounted. */
--dsh-composer-text-max-height: 336px;
}
/* Active phase: header is ordinary column chrome above the scrollport (not
@@ -175,7 +189,17 @@
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
paints under a sticking code header while scrolling. */
z-index: 7;
background: var(--dsw-alias-bg-base);
/* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
band at the seat's top (the figma 24% of the resting ~150px composer),
solid below — px stops, not %, so a growing draft only widens the solid
region and the fade band never stretches. The 0px stop is bg-base at
zero alpha (not white, which the figma export hardcodes) so both themes
fade from their own base. */
background: linear-gradient(
180deg,
color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px,
var(--dsw-alias-bg-base) 36px
);
}
/* Hero phase: the composer stack (hero chrome + workspace row + card) is

View File

@@ -1,13 +1,12 @@
// Resident conversation skeleton. Hero chrome, composer positioning, and the
// chain stay mounted across no-session/session transitions. Only the inert
// input body swaps for the strict session InputBar.
// Resident conversation skeleton. Hero chrome, composer positioning, the
// chain, AND the composer bar (session-maybe slot) stay mounted across
// no-session/session transitions — the bar renders inert via owner props.
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import { DisabledInputBar } from './DisabledInputBar.tsx'
import css from './ConversationRoot.module.css'
/** Full props composed from the slot contract. */
@@ -15,7 +14,7 @@ export type ConversationRootProps = ConversationSlotProps
export function ConversationRoot({
sessionId, useSession, useSessions, useWorkspaces, useInput,
renderSlot, renderSlotChain, selectWorkspace,
renderSlot, renderSlotChain, selectWorkspace, t,
}: ConversationRootProps) {
const openState = useSession(s => s.openState)
const composerPhase = useSession(s => s.composerPhase)
@@ -29,6 +28,23 @@ export function ConversationRoot({
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
const pickerAnchor = useRef<HTMLButtonElement>(null)
// 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.
const seatObserver = useRef<ResizeObserver | null>(null)
const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
seatObserver.current?.disconnect()
seatObserver.current = null
const scroller = seat?.parentElement ?? null
if (seat === null || scroller === null) return
seatObserver.current = new ResizeObserver(() => {
scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`)
})
seatObserver.current.observe(seat)
}, [])
const sessionWorkspace = sessionId === undefined
? undefined
: workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
@@ -78,6 +94,7 @@ export function ConversationRoot({
label={chipTitle}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
t={t}
/>
{renderSlot('conversation.hero.workspace', {
open: pickerOpen,
@@ -96,26 +113,29 @@ export function ConversationRoot({
)
// The placeholder chip ("Choose workspace") and the inert input travel
// together: a blank session whose workspace vanished (deleted from the
// sidebar) reverts to the same disabled bar as the initial no-session state.
const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
? <DisabledInputBar />
: renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
})
// together: no workspace picked yet (cold start, no session at all), or a
// blank session whose workspace vanished (deleted from the sidebar). The
// bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
// not a different tree, so the textarea DOM survives the transition.
const inert = sessionId === undefined || (hero && chipTitle === undefined)
const inputBar = renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(inert
? { disabled: true, placeholder: t('placeholder.workspace') }
: hero ? { placeholder: t('placeholder.hero') } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
// Stats band under the card, inside the bar's width column so both
// share one constraint (composer.dock = stats-line family).
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
})
const composerBar = (
<div className={clsx(css.composerStack, hero && css.composerHero)}>
{hero && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell />}
{hero && <HeroShell t={t} />}
{hero && heroWorkspaceRow}
{/* Stats band above the input-dock strips so the prior ChatView footer
order (stats → todo/queue → card) is preserved under the sticky stack. */}
{!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
{inputBar}
</div>
@@ -133,7 +153,7 @@ export function ConversationRoot({
// on the fallback alone would leave Question/Approval panels at the content
// end off-screen when the user is not pinned to the floor.
const composerSeat = (
<div className={css.composerSeat} data-composer-seat="">
<div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
{composer}
</div>
)
@@ -159,7 +179,7 @@ export function ConversationRoot({
'conversation.session',
{ wrapActiveBody },
)}
{sessionId === undefined ? composerSeat : null}
{sessionId === undefined ? wrapActiveBody(null) : null}
</div>
)
}

View File

@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, wrapActiveBody,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
@@ -35,6 +35,8 @@ export function ConversationSession({
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.
const inspect = useStore(s => s.inspect ?? null)
useEffect(() => {
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
@@ -52,7 +54,10 @@ export function ConversationSession({
const view: ReactNode = hideChrome ? null : (
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
{active !== undefined && renderSlot('conversation.view', {
inspect,
onInspectDone: () => { actions.setInspect(null) },
}, { only: active.id })}
</div>
)
@@ -65,7 +70,7 @@ export function ConversationSession({
{!hideChrome && (
<>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (

View File

@@ -11,8 +11,8 @@ import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
import { terminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
import css from './DetailsPanel.module.css'
/** Full props composed by reference from the contract (automatic shares & injected share). */
@@ -68,7 +68,7 @@ function pretty(raw: string): string {
}
}
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails, t }: DetailsPanelProps) {
const selection = useStore(s => s.selection)
// Session workspace root: an omitted or relative terminal cwd resolves
// against it, which the pure presenter cannot see.
@@ -84,10 +84,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
<div className={css.root}>
<div className={css.header}>
<div className={css.title}>
{selection === null ? '详情' : material?.name ?? selection.toolName ?? '详情'}
{selection === null ? t('details.title') : material?.name ?? selection.toolName ?? t('details.title')}
</div>
<button
type="button" className={css.close} aria-label="关闭详情"
type="button" className={css.close} aria-label={t('details.close')}
onClick={() => { closeDetails() }}
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
@@ -97,24 +97,24 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
</div>
<div className={css.body}>
{selection === null || callId === undefined
? <div className={css.empty}></div>
? <div className={css.empty}>{t('details.empty')}</div>
: material === null
? <div className={css.empty}></div>
? <div className={css.empty}>{t('details.notInWindow')}</div>
: (
<>
{material.argsRaw !== null && (
<section className={css.section}>
<div className={css.sectionLabel}>Input</div>
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
<div className={css.sectionLabel}>{t('details.input')}</div>
<CodeBlock code={pretty(material.argsRaw)} lang="json" copyLabel={t('copy')} copiedLabel={t('copied')} />
</section>
)}
<section className={css.section}>
<div className={css.sectionLabel}>Output</div>
<div className={css.sectionLabel}>{t('details.output')}</div>
{/* Keyed by the selected call: the body owns per-call view
state (the terminal card's expand and copy), which React
would otherwise carry into the next selection because the
panel does not unmount between calls. */}
<OutputBody key={callId} material={material} cwd={sessionCwd} />
<OutputBody key={callId} material={material} cwd={sessionCwd} t={t} />
</section>
</>
)}
@@ -131,9 +131,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* a running call with no terminal card yet, keeps the flattened text form.
* @param props.material - the selected call's material from {@link materialFor}.
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
* @param props.t - the panel's locale seat, passed down as a plain prop.
* @returns the Output section's body element.
*/
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string | undefined; t: DetailsPanelProps['t'] }) {
const terminal = terminalCardModel(material.block, cwd)
if (terminal !== null) {
// The contract renders the presenter's description above the card, and the
@@ -143,30 +144,17 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
{terminal.description !== undefined && (
<div className={css.terminalDescription}>{terminal.description}</div>
)}
<TerminalBlock {...terminal.card} className={css.terminal} />
<TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.terminal} />
</>
)
}
// A settled call always carries the result node the flattened form needs;
// the running shape has no result to flatten.
if (!('kind' in material.block)) return <div className={css.empty}></div>
if (!('kind' in material.block)) return <div className={css.empty}>{t('details.running')}</div>
const result = material.block
return (
<pre className={css.code} data-error={result.isError || undefined}>
{renderResult(result)}
{resultText(result)}
</pre>
)
}
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
function renderResult(node: ToolResultNode): string {
const parts: string[] = []
for (const block of node.content) {
if (block.type === 'text') parts.push(block.text)
else parts.push(JSON.stringify(block, null, 2))
}
if (parts.length === 0 && node.error !== undefined) {
parts.push(`${node.error.name}: ${node.error.code}`)
}
return parts.join('\n')
}

View File

@@ -1,40 +0,0 @@
/** Inert no-session input body; the resident Hero shell renders around it. */
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './InputBar.module.css'
/** Disabled visual twin of the session-bound InputBar. */
export function DisabledInputBar() {
return (
<div className={clsx(css.root, css.hero)}>
<div className={css.card}>
<div className={css.grow}>
<textarea
className={css.input}
value=""
disabled
placeholder="Choose a workspace to start"
rows={2}
readOnly
/>
<div aria-hidden className={css.mirror}>{'\n'}</div>
</div>
<div className={css.row}>
<div className={css.tools}>
<button type="button" className={css.add} aria-label="Add attachment" disabled>
<IconPlusOutline16 size={14} />
</button>
</div>
<div className={css.trailing}>
<button type="button" className={css.primary} aria-label="Send message" disabled>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -10,8 +10,12 @@ import {
FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps } from '../contract/slots.ts'
import css from './HeroShell.module.css'
/** The owner's locale seat type, passed to hero chrome as a plain prop. */
type HeroTranslate = ConversationSlotProps['t']
/**
* Basename label for the workspace chip (the shared derivation);
* separator-only paths echo the raw cwd.
@@ -34,18 +38,19 @@ export function workspaceLabel(cwd: string): string {
* @param props.onClick - menu toggle.
* @returns the chip button element.
*/
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick, t }: {
buttonRef?: RefObject<HTMLButtonElement>
label?: string | undefined
menuOpen?: boolean
onClick?: () => void
t: HeroTranslate
}) {
return (
<button
ref={buttonRef}
type="button"
className={css.workspace}
aria-label="Choose workspace"
aria-label={t('hero.chooseWorkspace')}
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={onClick}
@@ -53,7 +58,7 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
{label === undefined
? <IconFolderClose16 className={css.folder} size={16} />
: <IconFolderOpen16 className={css.folder} size={16} />}
<span className={css.workspaceLabel}>{label ?? 'Choose workspace'}</span>
<span className={css.workspaceLabel}>{label ?? t('hero.chooseWorkspace')}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} />
</button>
)
@@ -95,6 +100,8 @@ export function HeroGlow({ className }: { className?: string | undefined }) {
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
export interface HeroShellProps {
/** The owner's locale seat, passed down as a plain prop. */
t: HeroTranslate
/** Overlay content after the stack (modals). */
children?: ReactNode
}
@@ -105,14 +112,14 @@ export interface HeroShellProps {
* @param props - see {@link HeroShellProps}.
* @returns the centered hero element tree.
*/
export function HeroShell({ children }: HeroShellProps) {
export function HeroShell({ t, children }: HeroShellProps) {
return (
<div className={css.root}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
{t('hero.headline')}
</div>
<div className={css.body}>
{/* The resident composer (ConversationRoot wrapActiveBody seat; the

View File

@@ -20,10 +20,10 @@
display: flex;
flex-direction: column;
align-items: center;
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
margin + 6px here); error/status strips still carry their own margin. */
padding: 6px var(--dsh-composer-side-clearance) 12px;
/* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by
the chat scroller. No top pad: the composer stack's gap owns the space
above; error/status strips still carry their own margin. */
padding: 0 var(--dsh-composer-side-clearance) 8px;
}
.hero {
@@ -210,12 +210,18 @@
.mirror {
visibility: hidden;
pointer-events: none;
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
min-height: 52px;
max-height: 336px;
/* 14-line cap, shared with the composer takeovers (declared on
ConversationRoot .composerSeat). */
max-height: var(--dsh-composer-text-max-height);
overflow: hidden;
}
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
line + 4pt); the docked composer collapses to the content height. */
.hero .mirror {
min-height: 52px;
}
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
(figma Input_Bottom chrome). */
.row {

View File

@@ -15,11 +15,16 @@ import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-plan-mode/client'
// Type-only: the `goal` projection key merge (hint disambiguation).
import type {} from '@deepseek-ai/dsh-goal/client'
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 { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
/** Decoration product of the no-session state (no machine, empty draft). */
const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
/** Prompt failure surface (derived from promptError). */
export interface InputBarError {
op: 'send' | 'stop'
@@ -29,15 +34,16 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
useSession, useInput, inputActions, keyboard, stop, command, t, renderSlot, useNotices, useLexicon,
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
onAdd, addLabel,
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
const promptError = useSession(s => s.promptError) ?? null
const running = useSession(s => s.running) ?? false
const removed = useSession(s => s.removed) ?? false
// Plan mode swaps the textarea placeholder (the projection is the folded
// host value; owner-prop placeholders — hero, session-unavailable — win).
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
@@ -49,7 +55,10 @@ export function InputBar({
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
const draft = input.draft
// Session-maybe: the machine faces are absent together while no session is
// current; the bar renders the same DOM inert instead of a parallel tree.
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
const draft = input?.draft ?? ''
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
@@ -68,16 +77,18 @@ export function InputBar({
// (undefined = capability absent → the chip renders nothing).
const permissions = useProjection('permissions')
// Queue cut 1: running input stays free; locked = session disabled only.
// The transient machine locks (adjudicating pending / submitting) render
// Queue cut 1: running input stays free; locked = session removed, the
// inert no-workspace state, or the machine faces absent (no session). The
// transient machine locks (adjudicating pending / submitting) render
// read-only — the draft stays visible and focused, keystrokes drop.
const disabled = removed || inert || !live
const locked = disabled
const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting'
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
// Unlock (mount / session switch) returns focus to the box.
useEffect(() => {
if (!locked) inputRef.current?.focus()
}, [locked])
}, [locked, sessionId])
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
@@ -101,6 +112,9 @@ export function InputBar({
}, [])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
// Absent machine (no session): the textarea is disabled so events cannot
// fire; the guard narrows the faces for the paths below.
if (keyboard === undefined || inputActions === undefined) return
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return
@@ -162,6 +176,7 @@ export function InputBar({
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
if (keyboard === undefined) return // absent machine: disabled textarea, no events
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
keyboard.setDraft(next)
@@ -187,6 +202,7 @@ export function InputBar({
/* oxlint-enable typescript/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
if (input === undefined || keyboard === undefined) return // absent machine: disabled textarea, no events
const el = e.currentTarget
const { start, end } = selectionOf(el)
if (start === end) return
@@ -211,6 +227,7 @@ export function InputBar({
}
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
if (keyboard === undefined) return // absent machine: disabled textarea, no events
if (machineBusy || locked) return
const text = e.clipboardData.getData('text/plain')
if (text === '') return
@@ -230,7 +247,7 @@ export function InputBar({
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
// Any caret/selection gesture ends a live paste attempt (the machine
// cannot observe DOM selection). Cheap no-op when none is live.
if (keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
if (keyboard !== undefined && keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
void e
}
@@ -240,8 +257,10 @@ export function InputBar({
inputRef.current?.focus()
}
const primaryLabel = running ? 'Stop generating' : 'Send message'
const addText = addLabel ?? t('input.addAttachment')
const primaryLabel = running ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
if (running) {
stop()
return
@@ -251,16 +270,17 @@ export function InputBar({
}
// The Access seat: the projection-fed permission chip (renders nothing
// while the permissions key is absent — permission-less host or Draft).
const accessSelect: ReactNode = (
<PermissionSelect value={permissions} locked={locked} command={command} />
)
// while the permissions key is absent — permission-less host or Draft
// or while the command face is absent with the session).
const accessSelect: ReactNode = command === undefined
? null
: <PermissionSelect value={permissions} locked={locked} command={command} t={t} />
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text.
const deco = deriveDecorations(input, lexicon)
const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon)
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every
@@ -322,9 +342,11 @@ export function InputBar({
pushPlain(draft.length)
if (deco.hint !== null) {
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
const commandName = input.claim?.token.slice(1).trim() ?? ''
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
const translated = translateHint(hintKey)
const commandName = input?.claim?.token.slice(1).trim() ?? ''
const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
// Dynamic lookup by claimed command name: unknown commands miss the
// dictionary and keep the machine's own hint, so the call is wide.
const translated = (t as Translate)(hintKey)
const displayHint = translated !== hintKey ? translated : deco.hint
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
}
@@ -356,10 +378,10 @@ export function InputBar({
value={draft}
disabled={locked}
readOnly={machineBusy}
data-phase={input.phase}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? 'Session unavailable'
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
@@ -377,8 +399,8 @@ export function InputBar({
<button
type="button"
className={css.add}
aria-label={addLabel}
title={addLabel}
aria-label={addText}
title={addText}
disabled={locked}
onMouseDown={keepFocus}
onClick={onAdd}
@@ -417,6 +439,7 @@ export function InputBar({
</div>
</div>
</div>
{footer}
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import css from './PermissionSelect.module.css'
/**
@@ -19,9 +20,11 @@ export interface PermissionSelectProps {
value: PermissionSelectValue | undefined
locked: boolean
command: (line: string) => Promise<boolean>
/** The owning bar's locale seat, passed down as a plain prop. */
t: ComposerBarProps['t']
}
export function PermissionSelect({ value, locked, command }: PermissionSelectProps) {
export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) {
const [pick, setPick] = useState<string | null>(null)
const [open, setOpen] = useState(false)
@@ -56,7 +59,7 @@ export function PermissionSelect({ value, locked, command }: PermissionSelectPro
<button
type="button"
className={css.trigger}
aria-label={`Access mode, current: ${displayName(current?.name ?? currentValue)}`}
aria-label={t('input.accessMode', { name: displayName(current?.name ?? currentValue) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}

View File

@@ -1,8 +1,9 @@
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
tip surface, 14px radius, status icons + secondary item labels. It shares
the composer card geometry and adds the dock inset on both sides. */
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
14px radius, status icons + secondary item labels. It shares the composer
card geometry and adds the dock inset on both sides. */
.root {
box-sizing: border-box;
flex: none;
overflow: hidden;
margin: 0 auto;
@@ -33,8 +34,8 @@
.body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 16px;
gap: 8px;
padding: 9px 15px;
}
.header {

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