Merge commit 'refs/codex/pr1006/master' into worktree/pr1006-merge-20260731

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/headless-agent/cordis.yml
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/llm/llm-deepseek/README.i18n.yaml
#	packages/llm/llm-deepseek/README.md
#	packages/llm/llm-deepseek/README.zh.md
#	packages/llm/llm-deepseek/src/adapter.ts
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-deepseek/tests/adapter.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.md
#	packages/llm/llm/README.zh.md
#	packages/subagent/subagent-dsh-sdk/README.i18n.yaml
#	packages/ui/jsonrpc/README.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-07-31 01:55:19 +08:00
664 changed files with 19076 additions and 2562 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: 11179cf6676d1b4382816e34285529b51152fe8d
README.zh.md: 100d918287613973604b2f85060572b8ee41d132
README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b
README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462

View File

@@ -40,6 +40,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`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

@@ -40,6 +40,7 @@
| [`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

@@ -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: d2fda9f15125915594259e01e5b153609ceb21bb
README.zh.md: 669ae760693b4d98ee873ee5fe323554f58e7ca5

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

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 浏览器信任栅栏

View File

@@ -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 {

View File

@@ -28,7 +28,7 @@ import type {
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
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'
@@ -155,6 +155,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
}
@@ -684,8 +713,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
@@ -1001,7 +1036,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 +1077,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 +1146,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 +1392,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 +1583,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.
@@ -1591,6 +1710,7 @@ export class FixtureApiClient extends AbstractApiClient {
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)
@@ -1614,6 +1734,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

@@ -22,6 +22,8 @@ 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'

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

@@ -46,16 +46,17 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
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: [],
}))
@@ -99,6 +100,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 +156,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

@@ -119,6 +119,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 }))

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/runtime/README.md
README.md: 766d8516225cd46cb1a3a80c832d1cf55e816140
README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692
README.md: a116a5e4ad3070f20e6d90490f2507c1e2369c37
README.zh.md: f375811e6f1480d6636fe4eb77746b76d6414b1e

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
@@ -28,6 +28,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 列表
@@ -28,6 +28,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

@@ -29,6 +29,17 @@ export interface ISessions {
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* 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

@@ -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

@@ -289,6 +289,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

@@ -81,6 +81,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 +137,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
@@ -317,6 +351,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

@@ -62,8 +62,9 @@ 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: [] }))
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 +72,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' }],
}],
@@ -118,6 +119,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 +183,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

@@ -277,6 +277,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 +378,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
@@ -399,6 +399,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

@@ -169,7 +169,7 @@ export class TestSessions implements ISessions {
private readonly channel: SessionProvideChannel
/** Calls observed on the service-level face (open/clear), newest last. */
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
/**
* @param stabilize - the owning runtime's act wrapper.
@@ -392,6 +392,17 @@ export class TestSessions implements ISessions {
this.list.update((draft) => { draft.current = undefined })
}
/**
* 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

@@ -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,13 @@ 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()
})

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: fc466190a744a1c13094ca6ebf62755d5bf49c98
README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79
README.md: bfb56dc52406e1377cd84c87866a644ade10293a
README.zh.md: e09bdcc4bebd8176a3061b221e07ae73a7a8941c

View File

@@ -18,7 +18,9 @@ A tool call declaring the `terminal` render intent renders its command output in
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
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: 10`between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
The todo surfaces are two registrations over that shape, both 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.
@@ -38,7 +40,7 @@ None; this package neither assembles nor sends a provider request.
- **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

@@ -18,7 +18,9 @@
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 10` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`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 提供其余状态和回调。
@@ -36,9 +38,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
## 已知限制与暂缓事项
- **统计行的耗时只覆盖窗口内消息流**LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **统计行的耗时只覆盖窗口内消息流**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

@@ -262,6 +262,13 @@ export function apply(ctx: Context): void {
})
},
loadOlder: () => { void scoped.loadOlder() },
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)

View File

@@ -4,8 +4,9 @@
// 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 type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -21,8 +22,13 @@ export interface AssistantMarkdownProps {
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 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
}
function firstLine(text: string): string {
@@ -60,7 +66,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time,
blocks, streaming, interrupted, time, seq, onFork,
}: AssistantMarkdownProps) {
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
@@ -91,6 +97,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
text={copyText(blocks)}
time={time}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
className={css.actions}
/>
)}

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'
@@ -230,7 +230,7 @@ function StreamingTail({ useSession, onGrow }: {
* 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, forkAt }: 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)
@@ -244,6 +244,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
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)
@@ -376,7 +379,9 @@ 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}
/>
)
}
@@ -385,7 +390,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
}
/* 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} />
}
return (

View File

@@ -1,6 +1,6 @@
// 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.
@@ -20,10 +20,11 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) {
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 ?? ''}`
// 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 ?? '命令'
return (
<ToolRow
variant="others"

View File

@@ -1,5 +1,6 @@
// 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 {
@@ -18,17 +19,19 @@ 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
}
/**
* 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,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
@@ -48,7 +51,7 @@ export function MessageIconActions({
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
<IconBranchOutline16 />
</button>
</Tooltip>

View File

@@ -16,6 +16,8 @@ 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
}
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
@@ -61,7 +63,7 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
switch (node.kind) {
case 'user': {
const { text, rest } = contentText(node.content)
@@ -76,6 +78,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
time={node.time}
clock="start"
edit
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
/>
</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

@@ -419,6 +419,8 @@ export interface ChatViewInjected {
*/
openFile: (path: string) => void
loadOlder: () => void
/** 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. */

View File

@@ -20,6 +20,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 {
@@ -32,7 +34,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,11 +4,12 @@
// 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 { useEffect, useId, useState } from 'react'
import type { 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 css from './QueueDock.module.css'
@@ -22,18 +23,28 @@ export interface QueueDockInjected {
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
/**
* 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 }: 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,
@@ -63,8 +74,23 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
return (
<div className={css.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}>{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
? (

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.
@@ -53,17 +57,20 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
<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}>
{/* 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="审批详情">
<div className={css.headline}>{pending.reason ?? `工具 ${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') }}>
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</button>
</div>
</div>
</div>

View File

@@ -143,6 +143,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

View File

@@ -209,7 +209,9 @@
.mirror {
visibility: hidden;
pointer-events: none;
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;
}

View File

@@ -122,6 +122,13 @@ describe('conversation slot inject surface', () => {
const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
chatView.injected.forkAt(17)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
})
expect(b.runtime.sessions.calls).toContainEqual({
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
})
await b.runtime.dispose()
})

View File

@@ -16,7 +16,7 @@ import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
@@ -61,8 +61,8 @@ const user = (seq: number, text: string): UserMessageNode => ({
content: [{ type: 'text', text }] as never,
source: null,
})
const assistant = (seq: number, text: string): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
@@ -94,6 +94,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => void>()
const loadOlder = vi.fn()
const forkAt = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
@@ -120,9 +121,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
openDetails,
openFile,
loadOlder,
forkAt,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
}
describe('chat-flow derivation', () => {
@@ -154,6 +156,23 @@ describe('chat-flow derivation', () => {
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
it('assistantActionsSeqs keeps only the last content assistant per turn', () => {
const thinkOnly: AssistantMessageNode = {
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'planning' }],
}
const seqs = assistantActionsSeqs([
user(1, 'hi'),
assistant(2, 'looking', 1),
thinkOnly,
toolResult(4, 'a'),
assistant(5, 'done', 1),
user(6, 'again'),
assistant(7, 'second turn', 2),
])
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
})
})
describe('ChatView', () => {
@@ -194,6 +213,33 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('shows assistant IconActions only on the last content message of each turn', () => {
const h = makeHarness({
nodes: [
user(1, 'hi'),
assistant(2, 'mid-turn text'),
toolResult(3, 'a'),
assistant(4, 'final answer'),
user(5, 'next'),
assistant(6, 'second turn', 2),
],
})
const view = render(<h.ChatView {...h.props} />)
// 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
})
it('forks from both user and finalized assistant message actions at their event seq', () => {
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
const view = render(<h.ChatView {...h.props} />)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
const markdown = '# Rendered\n\n- **one**\n- `two`'
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
@@ -448,10 +494,14 @@ describe('ChatView', () => {
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
...over,
})
// Settled success: the command line is the title, the outcome text the summary.
const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] })
// Settled success: the bare command name is the title, the outcome text
// the summary — neither the dispatched `/` nor its arguments reach the row
// (the settlement text already says what the command did).
const settled = makeHarness({ nodes: [user(1, 'hi'), command({ args: ' now' })] })
const view = render(<settled.ChatView {...settled.props} />)
expect(view.getByText('/plan')).toBeTruthy()
expect(view.getByText('plan')).toBeTruthy()
expect(view.queryByText('/plan')).toBeNull()
expect(view.queryByText('/plan now')).toBeNull()
expect(view.getByText('已进入 plan mode')).toBeTruthy()
// Error outcome flips the row state; a text-less error gets the default copy.

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/**
* QueueDock rendering and operations: authoritative rows, inline editing,
* removal, failure notices, and live retirement.
* collapse state, removal, failure notices, and live retirement.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
@@ -78,16 +78,109 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
const single = snapshotWith([row('i-1', 'one')])
const source = liveSession(single)
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull()
expect(view.getByText('one')).toBeTruthy()
act(() => { source.push(snapshotWith([row('i-1', 'one'), row('i-2', 'two')])) })
const header = view.getByRole('button', { name: '2 条排队消息' })
expect(header.getAttribute('aria-expanded')).toBe('false')
expect(document.getElementById(header.getAttribute('aria-controls')!)).toBeTruthy()
expect(view.queryByText('one')).toBeNull()
expect(view.queryByText('two')).toBeNull()
fireEvent.click(header)
expect(header.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText('one')).toBeTruthy()
expect(view.getByText('two')).toBeTruthy()
fireEvent.click(header)
expect(header.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText('one')).toBeNull()
})
it('keeps an active single-row editor visible when another item arrives', () => {
const single = snapshotWith([row('i-edit', 'before')])
const source = liveSession(single)
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
fireEvent.click(view.getByLabelText('编辑排队消息'))
fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } })
act(() => {
source.push(snapshotWith([row('i-edit', 'before'), row('i-2', 'second')]))
})
const header = view.getByRole('button', { name: '2 条排队消息' })
expect(header).toHaveProperty('disabled', true)
expect(header.getAttribute('aria-expanded')).toBe('true')
expect(view.getByRole('textbox', { name: '编辑排队消息' })).toHaveProperty('value', 'draft')
expect(view.getByText('second')).toBeTruthy()
fireEvent.click(view.getByLabelText('取消编辑'))
expect(header).toHaveProperty('disabled', false)
expect(header.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText('second')).toBeNull()
})
it('keeps an in-flight row action visible when another item arrives', async () => {
const single = snapshotWith([row('i-remove', 'remove me')])
const source = liveSession(single)
let finishUpdate: (() => void) | undefined
const updateQueue = vi.fn(() => new Promise<void>((resolve) => { finishUpdate = resolve }))
const view = render(
<QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(view.getByLabelText('删除排队消息'))
act(() => {
source.push(snapshotWith([row('i-remove', 'remove me'), row('i-2', 'second')]))
})
const header = view.getByRole('button', { name: '2 条排队消息' })
expect(header).toHaveProperty('disabled', true)
expect(header.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText('remove me')).toBeTruthy()
expect(view.getByText('second')).toBeTruthy()
act(() => { finishUpdate?.() })
await waitFor(() => {
expect(header).toHaveProperty('disabled', false)
expect(header.getAttribute('aria-expanded')).toBe('false')
})
})
it('defaults a new multi-row queue to collapsed after the prior queue empties', () => {
const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
const source = liveSession(first)
const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} />)
fireEvent.click(view.getByRole('button', { name: '2 条排队消息' }))
expect(view.getByText('one')).toBeTruthy()
act(() => { source.push(snapshotWith([])) })
expect(view.container.innerHTML).toBe('')
act(() => {
source.push(snapshotWith([row('i-3', 'three'), row('i-4', 'four')]))
})
const header = view.getByRole('button', { name: '2 条排队消息' })
expect(header.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText('three')).toBeNull()
})
it('renders active actions and disables editing for mixed-content rows', () => {
const snap = snapshotWith([
row('i-1', '第一条排队消息'),
row('i-2', null, 'image [image]'),
])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
const { container, getByRole } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
expect([...container.querySelectorAll('li')].map(item => item.textContent))
.toEqual(['第一条排队消息', 'image [image]'])
expect(container.querySelectorAll('button')).toHaveLength(4)
expect(container.querySelectorAll('button')).toHaveLength(5)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
@@ -162,10 +255,11 @@ describe('QueueDock', () => {
const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getAllByLabelText } = render(
const { getAllByLabelText, getByRole } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })

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-layout/README.md
README.md: 0e92958c9b088071ab58f7e87e8af68f6c2df68d
README.zh.md: 0fb3b1cd85bbf6e070a2a1dd01b25981e5990df0
README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba
README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话保持关闭显式打开详情栏的操作会使用契约默认宽度返回同一会话时恢复其未改变的宽度选择不同会话时详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。

View File

@@ -49,9 +49,8 @@
}
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
centered on the column border via inline left, above column content. The
visible pill (12x32 r10, riding the border at vertical center) is the figma
Handle component; the hit strip stays wider than the pill. */
centered on the column border via inline left, above column content. Details
adds a visible 12x32 pill at vertical center; sidebar keeps only the hit strip. */
.handle {
position: absolute;
top: 0;
@@ -76,7 +75,7 @@
}
}
.handle::after {
.handle[data-side='details']::after {
content: '';
position: absolute;
top: 50%;
@@ -88,23 +87,22 @@
box-sizing: border-box;
background: var(--dsw-alias-button-floating-fill);
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Hover affordance: the pill hides until the pointer is over the owning
column (data-side pairs handle and column), the strip itself, or a drag. */
/* Hover affordance: the details pill hides until the pointer is over its
column, the strip itself, or a drag. */
opacity: 0;
transition:
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
.detailsCol:hover ~ .handle[data-side='details']::after,
.handle:hover::after,
.handle[data-dragging='true']::after {
.handle[data-side='details']:hover::after,
.handle[data-side='details'][data-dragging='true']::after {
opacity: 1;
}
.handle:hover::after,
.handle[data-dragging='true']::after {
.handle[data-side='details']:hover::after,
.handle[data-side='details'][data-dragging='true']::after {
background: var(--dsw-alias-button-floating-hover);
border-color: var(--dsw-alias-border-l3);
}

View File

@@ -44,6 +44,14 @@ export class ModelService extends Service {
ctx.on('connection/reset', () => {
for (const directory of this.live.directories.values()) directory.resetConnected()
})
// Provider topology changed on the host (a settings-born route appeared
// or dropped): refresh every open directory in the background so pickers
// show the new catalog without a reopen. Failures stay on each store.
ctx.on('models/changed', () => {
for (const directory of this.live.directories.values()) {
directory.load().catch(() => undefined)
}
})
}
/**

View File

@@ -21,7 +21,7 @@ import { apply, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
const GROUPS = [{
id: 'deepseek',
id: 'deepseek-official',
name: 'DeepSeek',
models: [
{
@@ -54,7 +54,7 @@ const GROUPS = [{
/** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */
async function bench() {
const ctx = new Context()
let current: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
let current: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
const calls = { models: 0, select: 0 }
ctx.provide('connection', { api: { sessions: {
models: () => {
@@ -138,17 +138,17 @@ describe('ui-model dual entry', () => {
const seatFace = b.seat().inject!(sid('s1'))
// Switch through the SEAT entry.
expect(await seatFace.select({
provider: 'deepseek',
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
})).toBe(true)
expect(b.hostCurrent()).toEqual({
provider: 'deepseek',
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
})
expect(seatFace.directory.getSnapshot().current).toEqual({
provider: 'deepseek',
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
})
@@ -165,7 +165,7 @@ describe('ui-model dual entry', () => {
const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')!
await b.contribution().ui.onSelect(pro, projection('s1'))
expect(seatFace.directory.getSnapshot().current).toEqual({
provider: 'deepseek',
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
reasoningEffort: 'high',
})
@@ -188,14 +188,14 @@ describe('ui-model dual entry', () => {
const b = await bench()
b.mint('s1')
const face = b.seat().inject!(sid('s1'))
await face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })
b.setHostCurrent({ provider: 'deepseek', model: 'deepseek-v4-flash' })
await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro' })
b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
b.ctx.emit('connection/reset')
expect(face.directory.getSnapshot()).toMatchObject({ current: null, status: 'loading' })
await Promise.resolve()
expect(face.directory.getSnapshot()).toMatchObject({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
status: 'ready',
})
})

View File

@@ -31,9 +31,9 @@ const reasoning = {
function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryState {
return {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
groups: [{
id: 'deepseek',
id: 'deepseek-official',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }],
}],
@@ -72,7 +72,7 @@ describe('ModelSelect reasoning effort', () => {
fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ }))
await waitFor(() => {
expect(select).toHaveBeenCalledWith({
provider: 'deepseek',
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
reasoningEffort: 'max',
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: 13f51d5338affd65d0705cec6a3b4ef78a534f0f
README.zh.md: 90f4eb5959e105178844c7bd5b07596aff81e706
README.md: adfbc084e1b0e227d50032cb6c924401b81c6a79
README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef

View File

@@ -2,11 +2,17 @@
English | [中文](README.zh.md)
Models settings section plugin: registers the `models` nav entry into `settings.section` with an intentionally empty content column — model management lands in a later phase.
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base).
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
## Model Experience
None, as the section renders an empty browser UI column; nothing here reaches a model request.
None, as the section renders a browser configuration UI; nothing here reaches a model request.
#### KV Cache effect
@@ -14,4 +20,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Content column is empty by design** — provider list, editing form, and activation flow are deferred until the model-management service exists.
- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
- **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it.
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.

View File

@@ -2,11 +2,17 @@
[English](README.md) | 中文
模型设置分区插件:注册 `models` 导航项,使其进入 `settings.section`;内容栏有意留空,模型管理将在后续阶段实现
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另加 `reasoningEffort`deepseek`reasoning`pi-ai其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过以免首次使用引导阻塞产品的其他部分Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型体验
无。该分区渲染空白的浏览器 UI 内容栏;这里没有任何内容进入模型请求。
无。该分区渲染浏览器配置 UI这里没有任何内容进入模型请求。
#### KV Cache 影响
@@ -14,4 +20,7 @@
## 已知限制与暂缓事项
- **内容栏按设计留空**:提供方列表、编辑表单和激活流程均暂缓,待模型管理服务就绪后实现。
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile却刻意不清除那条派生凭据重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-models",
"description": "Models feature plugin: registers its Settings section (nav entry, empty content column; model management lands later)",
"description": "Models settings and official-DeepSeek first-run routing over one live provider/settings/credential join",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -36,17 +36,25 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",

View File

@@ -0,0 +1,7 @@
.dialog {
width: min(420px, 100%);
}
.primary {
width: 100%;
}

View File

@@ -0,0 +1,96 @@
/**
* Official-DeepSeek first-run dialog. Readiness comes from the same
* provider/settings/credential join as the Models page; the prompt only
* routes the user to that page's single credential editor.
*/
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
import type { en } from './locales.ts'
import styles from './DeepSeekOnboardingDialog.module.css'
/** Injected dependencies of {@link DeepSeekOnboardingDialog}. */
export interface DeepSeekOnboardingInjected {
/** Shared Models-page join controller. */
controller: ModelsSettingsStore
/** Subscription hook bound to the shared join snapshot. */
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
/** Feature copy. */
t: (key: keyof typeof en) => string
}
/** Slot owner props plus the feature's injected dependencies. */
export type DeepSeekOnboardingDialogProps =
PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected
/* v8 ignore next 3 -- closed-union defaults only defend future source widening */
function assertNever(_value: never): never {
throw new Error('unexpected DeepSeek onboarding state')
}
/**
* Prompt a first-run user to open Models while the official adapter exists
* and its effective credential is not configured.
* @param props - settings-shell owner state and Models feature dependencies.
* @returns the controlled modal or null when onboarding needs no intervention.
*/
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
const { active, openSection, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
const readiness = deepSeekReadiness(state)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
if (active && !dismissed && state.status === 'idle') void controller.load()
}, [active, controller, dismissed, state.status])
const close = (): void => {
setDismissed(true)
}
const openModels = (): void => {
close()
openSection('models')
}
if (!active || dismissed) return null
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
case 'configured':
case 'unavailable':
return null
case 'credential-missing':
break
/* v8 ignore next -- every current readiness variant is handled above */
default:
return assertNever(readiness)
}
return (
<Modal
open
onClose={close}
title={t('onboardingTitle')}
closeLabel={t('onboardingLater')}
description={t('onboardingDescription')}
className={styles['dialog'] as string}
footer={(
<Button
variant="primary"
className={styles['primary']}
autoFocus
onClick={openModels}
>
{t('onboardingGoToSettings')}
</Button>
)}
/>
)
}

View File

@@ -0,0 +1,283 @@
.section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 720px;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.intro {
margin: 0;
font-size: 13px;
color: var(--text-tertiary, #888);
}
.notice {
margin: 0;
font-size: 12px;
color: var(--text-warning, #a15c00);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.rowCard {
border: 1px solid var(--border, #e2e2e2);
border-radius: 12px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--surface, #fff);
}
.rowHead {
display: flex;
align-items: center;
gap: 10px;
}
.rowName {
font-size: 15px;
font-weight: 600;
}
.badges {
display: inline-flex;
gap: 6px;
flex: 1;
}
.badgeOk {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--text-success, #0a7d33);
font-size: 12px;
}
.badgeOk::before {
content: '';
width: 6px;
height: 6px;
border-radius: 999px;
background: currentcolor;
}
.badgeMuted {
color: var(--text-tertiary, #999);
font-size: 12px;
}
.badgeWarn {
color: var(--text-warning, #a15c00);
font-size: 12px;
}
.rowActions {
display: inline-flex;
gap: 8px;
}
.primaryButton {
border: none;
border-radius: 999px;
padding: 8px 18px;
background: var(--accent-strong, #111);
color: var(--text-inverse, #fff);
font: inherit;
cursor: pointer;
}
.secondaryButton {
border: 1px solid var(--border, #d9d9d9);
border-radius: 999px;
padding: 6px 14px;
background: var(--surface, #fff);
color: inherit;
font: inherit;
cursor: pointer;
}
.dangerButton {
border: none;
background: none;
color: var(--text-danger, #c0392b);
font: inherit;
cursor: pointer;
}
.primaryButton:disabled,
.secondaryButton:disabled,
.dangerButton:disabled {
opacity: 0.5;
cursor: default;
}
.editor {
border: 1px solid var(--border, #e6e6e6);
border-radius: 12px;
background: var(--surface-secondary, #f7f7f8);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.editorHeader {
display: flex;
align-items: baseline;
gap: 8px;
}
.editorTitle {
font-size: 14px;
font-weight: 600;
}
.editorRoute {
font-size: 12px;
color: var(--text-tertiary, #999);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.fieldLabel {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #555);
}
.linkButton {
border: none;
background: none;
padding: 0;
color: var(--text-tertiary, #888);
font: inherit;
font-size: 12px;
text-decoration: underline;
cursor: pointer;
}
.linkButton:disabled {
opacity: 0.5;
cursor: default;
}
.advancedHint {
margin: 0;
font-size: 12px;
color: var(--text-tertiary, #999);
}
.editorActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.addBlock {
display: flex;
flex-direction: column;
gap: 12px;
}
.addButton {
align-self: flex-start;
border: 1px solid var(--border, #d9d9d9);
border-radius: 999px;
padding: 8px 16px;
font: inherit;
font-size: 13px;
background: var(--surface, #fff);
color: inherit;
cursor: pointer;
}
.addButton:disabled {
opacity: 0.5;
cursor: default;
}
.addCard,
.setupCard {
border: 1px solid var(--border, #e6e6e6);
border-radius: 12px;
background: var(--surface-secondary, #f7f7f8);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 14px;
list-style: none;
}
.addCard .editor,
.setupCard .editor {
border: none;
background: none;
padding: 0;
}
.customized {
border-top: 1px solid var(--border, #ececec);
padding-top: 10px;
}
.customizedSummary {
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #555);
list-style: revert;
}
.customizedBody {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 12px;
}
.input {
box-sizing: border-box;
padding: 9px 12px;
border: 1px solid var(--border, #d9d9d9);
border-radius: 10px;
font: inherit;
font-size: 13px;
background: var(--surface, #fff);
color: inherit;
}
.input:focus {
outline: none;
border-color: var(--accent-strong, #111);
}
.input::placeholder {
color: var(--text-tertiary, #aaa);
}
.error {
margin: 0;
font-size: 12px;
color: var(--text-danger, #c0392b);
}

View File

@@ -1,13 +1,281 @@
/**
* Models settings section: an intentionally empty content column — the nav
* entry exists so the section slot composition is visible; model management
* lands in a later phase.
* Models settings section: the provider rows joined from the configurable
* directory, settings namespaces, and credential states, with one editor
* card at a time. A whole-section provider without a configured key (the
* unconfigured DeepSeek posture) renders as its open setup card instead of a
* row; the add flow is a card carrying the dormant-provider select. Every
* mutation writes through the wire; the page re-renders from the pushed
* invalidations or the post-apply reload.
*/
/**
* Render the (empty) Models section content column.
* @returns null — no content this phase.
*/
export function ModelsSection() {
return null
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { messageOf } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** Injected dependencies of {@link ModelsSection} (slot `inject`). */
export interface ModelsSectionInjected {
/** The page store (loaded on mount, refreshed on pushed invalidations). */
controller: ModelsSettingsStore
/** uSES subscription hook bound to the store. */
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
/** Wire faces the editor writes through. */
api: Pick<IApiClient, 'settings' | 'credentials'>
/** Section copy. */
t: (key: keyof typeof en) => string
}
/**
* Props delivered by the slot outlet: the inject face spread flat (the
* renderer erases the share boundary at the render call).
*/
export type ModelsSectionProps = Partial<ModelsSectionInjected>
/** The editor target: an existing row or a dormant directory entry. */
interface EditorTarget {
provider: string
displayName: string
settingsNs: string
settingsPath: readonly string[]
}
/**
* Remove one user-added provider profile by unsetting its path in the stored
* user section, then reload. The removal names the profile rather than
* rebuilding the section: this page only ever holds the redacted descriptor,
* so a rebuilt section would drop every literal secret stored elsewhere in
* the namespace along with the profile being removed.
* @param api - settings wire face.
* @param controller - the page store to refresh.
* @param target - the provider's settings address.
* @returns the failure message, or undefined once the write and reload landed.
*/
export async function removeProviderProfile(
api: Pick<IApiClient, 'settings'>,
controller: ModelsSettingsStore,
target: { settingsNs: string; settingsPath: readonly string[] },
): Promise<string | undefined> {
let response
try {
response = await api.settings.mutate({
ns: target.settingsNs,
ops: [{ op: 'unset', path: [...target.settingsPath] }],
})
} catch (error) {
// The transport rejected rather than answering; the caller must be able
// to say so instead of the row silently staying put.
return messageOf(error)
}
if (!response.result.ok) return response.result.error.message
await controller.load()
return undefined
}
/**
* Whether a whole-section provider still needs its first key: nothing marks
* the credential configured and no literal `apiKey` is stored, so the page
* opens the setup card instead of showing a row.
* @param row - the joined provider row.
* @returns whether to render the setup card.
*/
export function needsSetup(row: ProviderRow): boolean {
if (row.entry.settingsPath.length > 0) return false
if (row.credential?.configured === true) return false
return !row.literalApiKeyConfigured
}
function targetOf(row: ProviderRow): EditorTarget {
return {
provider: row.entry.provider,
displayName: row.entry.displayName,
settingsNs: row.entry.settingsNs,
settingsPath: row.entry.settingsPath,
}
}
/**
* Render the Models section content column.
* @param props - slot-delivered injected dependencies.
* @returns the section, or null while the shell has not injected yet.
*/
export function ModelsSection(props: ModelsSectionProps): ReactNode {
const { controller, useSnapshot, api, t } = props
if (controller === undefined || useSnapshot === undefined || api === undefined || t === undefined) return null
return <Loaded injected={{ controller, useSnapshot, api, t }} />
}
function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const { controller, api, t } = injected
const state = injected.useSnapshot(snapshot => snapshot)
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
const [adding, setAdding] = useState(false)
const closeEditor = (changed: boolean): void => {
setEditing(undefined)
setAdding(false)
if (changed) void controller.load()
}
if (state.status === 'idle') void controller.load()
if (state.status === 'error') {
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
const errorText = state.error ?? ''
return (
<div className={styles['section']}>
<p className={styles['error']}>{`${t('loadFailed')}: ${errorText}`}</p>
<button type="button" className={styles['secondaryButton']} onClick={() => { void controller.load() }}>
{t('retry')}
</button>
</div>
)
}
const configured = state.rows.filter(row => row.configured)
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
const addTarget = adding ? editing : undefined
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
return (
<div className={styles['section']}>
<h2 className={styles['title']}>{t('title')}</h2>
<p className={styles['intro']}>{t('intro')}</p>
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
<ul className={styles['rows']}>
{configured.map((row) => {
const target = targetOf(row)
const namespace = state.namespaces.get(target.settingsNs)
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
if (namespace === undefined) return null
if (needsSetup(row)) {
// First-run posture: the provider exists but has no key — the
// setup card IS its presence on the page.
return (
<li key={row.entry.provider} className={styles['setupCard']}>
<ProviderEditor
provider={target.provider}
displayName={target.displayName}
namespace={namespace}
settingsPath={target.settingsPath}
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
/>
</li>
)
}
const open = !adding && editing?.provider === row.entry.provider
return (
<li key={row.entry.provider} className={styles['rowCard']}>
<div className={styles['rowHead']}>
<span className={styles['rowName']}>{row.entry.displayName}</span>
<span className={styles['badges']}>
{row.entry.active
? <span className={styles['badgeOk']}>{t('active')}</span>
: <span className={styles['badgeMuted']}>{t('dormant')}</span>}
</span>
<span className={styles['rowActions']}>
<button
type="button"
className={styles['secondaryButton']}
onClick={() => { setAdding(false); setEditing(open ? undefined : target) }}
>
{t('edit')}
</button>
{row.removable
? (
<button
type="button"
className={styles['dangerButton']}
disabled={!state.writable}
onClick={() => {
void removeProviderProfile(api, controller, target).then((failure) => {
if (failure !== undefined) controller.fail(failure)
})
}}
>
{t('remove')}
</button>
)
: null}
</span>
</div>
{open
? (
<ProviderEditor
provider={target.provider}
displayName={target.displayName}
namespace={namespace}
settingsPath={target.settingsPath}
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
/>
)
: null}
</li>
)
})}
</ul>
<div className={styles['addBlock']}>
{addTarget !== undefined && addNamespace !== undefined
? (
<div className={styles['addCard']}>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('provider')}</span>
<select
className={styles['input']}
value={addTarget.provider}
aria-label={t('provider')}
onChange={(event) => {
const row = addable.find(candidate => candidate.entry.provider === event.target.value)
/* v8 ignore next -- the select only lists addable rows */
if (row === undefined) return
setEditing(targetOf(row))
}}
>
{addable.map(row => (
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
))}
</select>
</div>
<ProviderEditor
key={addTarget.provider}
provider={addTarget.provider}
displayName={addTarget.displayName}
hideTitle
namespace={addNamespace}
settingsPath={addTarget.settingsPath}
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
/>
</div>
)
: (
<button
type="button"
className={styles['addButton']}
disabled={addable.length === 0 || !state.writable}
onClick={() => {
const first = addable[0]
/* v8 ignore next -- the button is disabled while nothing is addable */
if (first === undefined) return
setAdding(true)
setEditing(targetOf(first))
}}
>
{`+ ${t('add')}`}
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,334 @@
/**
* One provider's editor card, hand-written per adapter family: the primary
* field is a single write-only **API key** input (the page never asks for an
* environment-variable name — a typed key stores through `credentials.set`
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
* both families, plus `reasoningEffort` for deepseek / `reasoning` for
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as
* minimal `settings.mutate` path ops against the stored section — the card
* reads the redacted descriptor, so it names only the fields it can see and a
* stored literal secret is never collaterally removed.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client'
import {
deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '@deepseek-ai/dsh-client-schema-form'
import { deriveKeyRef, messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
/** Reasoning vocabularies per layout; the empty option means "inherit". */
const EFFORT_CHOICES: Record<'deepseek' | 'pi-ai', readonly string[]> = {
deepseek: ['off', 'high', 'max'],
'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
}
/** The draft key the effort select edits, per layout. */
const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = {
deepseek: 'reasoningEffort',
'pi-ai': 'reasoning',
}
/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
/** Props of {@link ProviderEditor}. */
export interface ProviderEditorProps {
/** Provider route id. */
provider: string
/** Display name for the card title. */
displayName: string
/** Hide the title row (the add card renders its own provider select). */
hideTitle?: boolean
/** The owning namespace view (schema, layers, secrets). */
namespace: SettingsNamespaceView
/** Path from the section root to this provider's profile. */
settingsPath: readonly string[]
/** Wire faces for writes. */
api: Pick<IApiClient, 'settings' | 'credentials'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable writes (read-only settings provider). */
readOnly: boolean
/** Close the editor; `changed` reports whether an Apply committed. */
onClose: (changed: boolean) => void
}
/** A user-section subtree as a plain draft object (absent → empty). */
function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Record<string, unknown> {
const subtree = getPath(namespace.user, path)
if (typeof subtree !== 'object' || subtree === null || Array.isArray(subtree)) return {}
return structuredClone(subtree) as Record<string, unknown>
}
/**
* The minimal path ops carrying `after` over `before`, both as the card sees
* them (that is, redacted). Only keys the card observed are named: a stored
* `role('secret')` field appears in neither side, so it produces no op and
* survives the write — the whole reason edits are path-addressed rather than
* a rebuilt section.
* @param base - path of the edited subtree inside the user section.
* @param before - the subtree as loaded, or undefined when it is new.
* @param after - the subtree as edited.
* @returns ordered set/unset ops; empty when nothing changed.
*/
export function pathOps(
base: readonly string[],
before: unknown,
after: Record<string, unknown>,
): SettingsPathOpView[] {
const previous = typeof before === 'object' && before !== null && !Array.isArray(before)
? before as Record<string, unknown>
: {}
const ops: SettingsPathOpView[] = []
for (const [key, value] of Object.entries(after)) {
if (JSON.stringify(previous[key]) === JSON.stringify(value)) continue
ops.push({ op: 'set', path: [...base, key], value })
}
for (const key of Object.keys(previous)) {
if (!(key in after)) ops.push({ op: 'unset', path: [...base, key] })
}
return ops
}
/** The editor layout the owning namespace selects. */
function layoutOf(ns: string): EditorLayout {
if (ns === 'llm-deepseek') return 'deepseek'
if (ns === 'llm-pi-ai') return 'pi-ai'
return 'unknown'
}
/** The credential reference this profile resolves keys through. */
function refFor(namespace: SettingsNamespaceView, path: readonly string[], provider: string): string {
const profile = getPath(namespace.value, path)
const named = typeof profile === 'object' && profile !== null
? (profile as { apiKeyEnv?: unknown }).apiKeyEnv
: undefined
return typeof named === 'string' && named.length > 0 ? named : deriveKeyRef(provider)
}
/**
* Render one provider's editing card.
* @param props - the addressed profile plus wire faces and copy.
* @returns the editor card.
*/
export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const { namespace, settingsPath, api, t } = props
const [draft, setDraft] = useState<Record<string, unknown>>(() => draftAt(namespace, settingsPath))
const [keyDraft, setKeyDraft] = useState('')
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
// The revision this card opened at. A write carrying it is refused if
// anything else — another tab, an external edit of settings.yaml — moved the
// namespace meanwhile, instead of silently overwriting that change.
const [openedAt] = useState(() => namespace.revision)
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
const fallback = getPath(namespace.value, settingsPath)
const disabled = props.readOnly || busy
const layout = layoutOf(namespace.ns)
const keyRef = refFor(namespace, settingsPath, props.provider)
useEffect(() => {
let stale = false
setKeyState(undefined)
// The key state is a placeholder hint, not a precondition for editing:
// neither a business rejection nor a transport failure may reach the
// browser as an unhandled rejection, so the card simply renders without
// the "already configured" hint.
void api.credentials.describe({ refs: [keyRef] }).then(
(response) => {
if (stale || !response.result.ok) return
setKeyState(response.result.value.credentials[keyRef])
},
() => undefined,
)
return () => { stale = true }
}, [api.credentials, keyRef])
const stringAt = (source: unknown, key: string): string | undefined => {
const value = getPath(source, [key])
return typeof value === 'string' && value.length > 0 ? value : undefined
}
const setField = (key: string, next: string | undefined): void => {
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
}
/**
* The write for this card, or a failure message. Every edit travels as
* path ops against the STORED section: the draft comes from the redacted
* descriptor, so a wholesale replace rebuilt from it would delete the
* literal secrets the wire never returned. Ops name only the fields this
* card can see, so a stored secret is untouched by construction.
*/
const applyOnce = async (): Promise<string | undefined> => {
const ns = namespace.ns
const original = getPath(namespace.user, settingsPath)
// The pi-ai profile must name the reference the key stores under, so a
// dormant add (or a legacy profile without one) records the derivation.
const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
&& stringAt(fallback, 'apiKeyEnv') === undefined
? setPath(draft, ['apiKeyEnv'], keyRef)
: draft
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
if (node !== undefined && settingsPath.length === 0) {
const sectionError = validateDraft(node, next)
if (sectionError !== undefined) return sectionError
}
const ops = pathOps(settingsPath, original, next)
if (ops.length > 0) {
const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt })
if (!response.result.ok) {
return response.result.error.code === 'settings-conflict'
? t('conflict')
: response.result.error.message
}
}
if (keyDraft.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
if (!stored.result.ok) return stored.result.error.message
}
setKeyDraft('')
return undefined
}
const apply = async (): Promise<void> => {
setBusy(true)
setFailure(undefined)
try {
const failure = await applyOnce()
if (failure !== undefined) {
setFailure(failure)
return
}
props.onClose(true)
} catch (error) {
// A transport failure (disconnect, a request the host refuses) rejects
// rather than answering; without this the card would stay busy forever
// with no error shown.
setFailure(messageOf(error))
} finally {
setBusy(false)
}
}
if (node === undefined) {
// A directory entry addressing a position its schema cannot resolve is a
// host-side inconsistency; showing it beats a blank card.
return <p className={styles['error']}>{`${props.provider}: unresolvable settings path`}</p>
}
const keyLocked = keyState?.writable === false
/**
* The curated fields of one known adapter family. Taking the narrowed
* family as a parameter is what makes `EFFORT_FIELD` total here: an
* unknown namespace never reaches this body.
*/
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
const effortField = EFFORT_FIELD[family]
return (
<>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('keyInput')}</span>
<input
className={styles['input']}
type="password"
autoComplete="off"
value={keyDraft}
placeholder={keyLocked
? t('keyEnvLocked')
: keyState?.configured === true ? t('keyStored') : t('keyPlaceholder')}
aria-label={t('keyInput')}
disabled={disabled || keyLocked}
onChange={(event) => { setKeyDraft(event.target.value) }}
/>
</div>
<details className={styles['customized']}>
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
<div className={styles['customizedBody']}>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
<input
className={styles['input']}
type="text"
value={stringAt(draft, 'baseURL') ?? ''}
placeholder={family === 'deepseek'
? DEEPSEEK_PUBLIC_BASE_URL
: stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')}
aria-label={t('baseUrl')}
disabled={disabled}
onChange={(event) => {
setField('baseURL', event.target.value === '' ? undefined : event.target.value)
}}
/>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('effort')}</span>
<select
className={styles['input']}
value={stringAt(draft, effortField) ?? ''}
aria-label={t('effort')}
disabled={disabled}
onChange={(event) => {
setField(effortField, event.target.value === '' ? undefined : event.target.value)
}}
>
<option value="">{t('effortInherit')}</option>
{EFFORT_CHOICES[family].map(choice => (
<option key={choice} value={choice}>{choice}</option>
))}
</select>
</div>
</div>
</details>
</>
)
}
return (
<div className={styles['editor']}>
{props.hideTitle === true
? null
: (
<div className={styles['editorHeader']}>
<span className={styles['editorTitle']}>{props.displayName}</span>
{props.provider !== props.displayName
? <span className={styles['editorRoute']}>{props.provider}</span>
: null}
</div>
)}
{layout === 'unknown'
? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
: curatedFields(layout)}
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
<div className={styles['editorActions']}>
<button
type="button"
className={styles['secondaryButton']}
disabled={busy}
onClick={() => { props.onClose(false) }}
>
{t('cancel')}
</button>
<button
type="button"
className={styles['primaryButton']}
disabled={disabled || layout === 'unknown'}
onClick={() => { void apply() }}
>
{busy ? t('applying') : t('apply')}
</button>
</div>
</div>
)
}

View File

@@ -1,51 +1,119 @@
/**
* Models settings section plugin, browser half. Registers the `models` nav
* entry into the shell-declared `settings.section` list slot; the content
* column is intentionally empty until model management lands. Export
* discipline: packages/client/AGENTS.md.
* Models settings plugin, browser half. Registers the `models` nav entry and
* official-DeepSeek first-run overlay into shell-declared slots. Both consume
* one provider/settings/credential join; the overlay routes missing-key users
* to the full page's single credential editor. Export discipline:
* packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ModelsSection } from './ModelsSection.tsx'
import type { ModelsSectionInjected } from './ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
import { ModelsSettingsStore } from './store.ts'
import { en, zh } from './locales.ts'
export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
export type { ModelsSettingsState, ProviderRow } from './store.ts'
/**
* Refetch the page snapshot only after its first load: an unopened Models
* page must not fetch on background invalidations.
* @param controller - the page store.
*/
export function refreshIfLoaded(controller: ModelsSettingsStore): void {
if (controller.store.getSnapshot().status === 'idle') return
void controller.load()
}
/**
* Required services (cordis fiber inject). The target slot is declared by
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration goes through declaration-aware deferral.
*/
export const inject = ['slots', 'locale']
export const inject = ['slots', 'locale', 'connection']
/**
* Register the Models section once the `settings.section` declaration is on
* the ledger.
* the ledger, wire its store to the connection, and keep it fresh on every
* pushed invalidation (settings, credentials, or provider topology).
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const disposers = [
ctx.locale.register('settings.models', 'zh', { nav: '模型' }),
ctx.locale.register('settings.models', 'en', { nav: 'Models' }),
ctx.locale.register('settings.models', 'zh', zh),
ctx.locale.register('settings.models', 'en', en),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-models: nav copy dictionaries')
}, 'ui-models: copy dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
const controller = new ModelsSettingsStore(connection.api)
const useSnapshot = bindSnapshotSelector(controller.store)
const t = ctx.locale.bind('settings.models') as ModelsSectionInjected['t']
const injected = (): ModelsSectionInjected => ({
controller,
useSnapshot,
api: connection.api,
t,
})
const onboardingInjected = (): DeepSeekOnboardingInjected => ({
controller,
useSnapshot,
t,
})
// Pushed invalidations converge every open surface without polling: any
// settings/credentials/topology change refetches once the page loaded.
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
const refresh = (): void => { refreshIfLoaded(controller) }
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('credentials/changed', refresh),
ctx.on('models/changed', refresh),
ctx.on('connection/reset', refresh),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-models: pushed invalidations')
ctx.effect(() => {
const section = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
ctx.slots.register({
name: 'settings.section',
id: 'models',
order: 10,
label: ctx.locale.bind('settings.models')('nav'),
label: t('nav'),
inject: injected,
}, ModelsSection))
const onboarding = deferRegistration(
ctx.slots,
'settings.onboarding',
DeepSeekOnboardingDialog,
() => ctx.slots.register({
name: 'settings.onboarding',
id: 'deepseek-official',
order: 0,
inject: onboardingInjected,
}, DeepSeekOnboardingDialog),
)
// Nav labels are registrant-localized: refresh on locale change so the
// ledger carries fresh text (the version bump re-renders the shell).
const offLocale = ctx.on('locale/change', () => { deferred.refresh() })
const offLocale = ctx.on('locale/change', () => {
section.refresh()
onboarding.refresh()
})
return () => {
offLocale()
deferred.dispose()
section.dispose()
onboarding.dispose()
}
}, 'ui-models: settings section registration')
}, 'ui-models: settings registrations')
}

View File

@@ -0,0 +1,69 @@
/** Copy dictionaries for the Models settings section. */
/** English strings. */
export const en = {
nav: 'Models',
title: 'Models',
intro: 'Enter your API keys to use models from the following providers.',
active: 'Active',
dormant: 'Inactive',
edit: 'Edit',
remove: 'Delete',
add: 'Add provider',
provider: 'Provider',
cancel: 'Cancel',
apply: 'Apply',
applying: 'Applying…',
readOnly: 'The settings document is read-only in this deployment.',
loadFailed: 'Loading the provider directory failed',
conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.',
retry: 'Retry',
keyInput: 'API key',
keyPlaceholder: 'Enter your API key',
keyStored: 'Configured — enter a new value to replace',
keyEnvLocked: 'Provided by the launch environment (read-only)',
customized: 'Customized settings',
baseUrl: 'Base URL',
baseUrlDefault: 'Provider default',
effort: 'Reasoning effort',
effortInherit: 'Default',
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
onboardingTitle: 'Add an API key to get started',
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
onboardingGoToSettings: 'Go to settings',
onboardingLater: 'Configure later',
}
/** Chinese strings (same keys as {@link en}). */
export const zh: typeof en = {
nav: '模型',
title: '模型',
intro: '填入各提供方的 API 密钥即可使用其模型。',
active: '已启用',
dormant: '未启用',
edit: '编辑',
remove: '删除',
add: '添加提供方',
provider: '提供方',
cancel: '取消',
apply: '保存',
applying: '保存中…',
readOnly: '当前部署的设置文档为只读。',
loadFailed: '加载提供方目录失败',
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
retry: '重试',
keyInput: 'API 密钥',
keyPlaceholder: '输入 API 密钥',
keyStored: '已配置——输入新值可替换',
keyEnvLocked: '由启动环境提供(只读)',
customized: '自定义设置',
baseUrl: 'API 地址',
baseUrlDefault: '提供方默认',
effort: '推理强度',
effortInherit: '默认',
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
onboardingGoToSettings: '前往配置',
onboardingLater: '稍后配置',
}

View File

@@ -0,0 +1,285 @@
/**
* Models settings page store: one snapshot joining the configurable-provider
* directory (`llm.providers`), the settings namespaces (`settings.describe`),
* and the referenced credentials (`credentials.describe`). The host stays the
* single fact source — every mutation writes through the wire and the page
* re-renders from the next describe, pushed or refetched.
*/
import type {
ConfigurableProviderView, CredentialView, IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { getPath, hasPath } from '@deepseek-ai/dsh-client-schema-form'
/** One provider row the page renders. */
export interface ProviderRow {
/** The directory entry (route id, display name, settings address, live state). */
entry: ConfigurableProviderView
/** Whether any layer configures this provider (its profile resolves). */
configured: boolean
/** Whether the user layer alone carries the profile (removal restores the base). */
removable: boolean
/** The credential reference the resolved profile names, when one does. */
apiKeyEnv: string | undefined
/** Credential state for {@link apiKeyEnv}, once described. */
credential: CredentialView | undefined
/** Whether the redacted secret sidecar reports an effective literal `apiKey`. */
literalApiKeyConfigured: boolean
}
/** Page snapshot. */
export interface ModelsSettingsState {
status: 'idle' | 'loading' | 'ready' | 'error'
/** Whole-load failure text; row-level write failures stay in the editor. */
error: string | null
/** Credential enrichment failure; provider/settings rows remain usable. */
credentialError: string | null
/** Whether the settings provider accepts writes. */
writable: boolean
/** Every configurable provider joined with its configured/credential state. */
rows: readonly ProviderRow[]
/** Namespace views by ns, for the editor's schema/layers/secrets. */
namespaces: ReadonlyMap<string, SettingsNamespaceView>
}
/**
* Human text for a rejected wire call. A transport failure rejects with an
* Error; a host or a runtime can reject with anything, and the page still has
* to say something.
* @param error - the rejection value.
* @returns the message to show.
*/
export function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Derive the conventional credential reference for a provider route: the v1
* page never asks for an environment-variable name, so a typed key stores
* under this derived reference and the profile records it as `apiKeyEnv`.
* @param provider - provider route id (e.g. `anthropic`, `minimax-cn`).
* @returns the derived reference name (e.g. `MINIMAX_CN_API_KEY`).
*/
export function deriveKeyRef(provider: string): string {
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
}
/** The credential reference a resolved profile names (its `apiKeyEnv` field). */
function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined {
if (namespace === undefined) return undefined
const profile = getPath(namespace.value, path)
if (typeof profile !== 'object' || profile === null) return undefined
const ref = (profile as { apiKeyEnv?: unknown }).apiKeyEnv
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
}
/** Whether one namespace's redacted sidecar reports a set literal API key. */
function literalApiKeyConfigured(
namespace: SettingsNamespaceView | undefined,
path: readonly string[],
): boolean {
if (namespace === undefined) return false
const secretPath = [...path, 'apiKey']
return namespace.secrets.some(secret =>
secret.set
&& secret.path.length === secretPath.length
&& secret.path.every((key, index) => key === secretPath[index]))
}
/** The models settings page controller (one per settings surface). */
export class ModelsSettingsStore {
/** The snapshot the section renders from (uSES-safe store). */
readonly store: SnapshotStore<ModelsSettingsState> = createSnapshotStore<ModelsSettingsState>({
status: 'idle', error: null, credentialError: null, writable: false, rows: [], namespaces: new Map(),
})
/** Latest load wins; an older response never overwrites a newer one. */
private generation = 0
/**
* @param api - the wire face (settings/credentials/llm domains).
*/
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
/**
* Surface a failure from an operation the page ran outside {@link load} —
* a row removal — on the same banner a load failure uses.
* @param message - the failure text to show.
*/
fail(message: string): void {
this.store.update((s) => {
s.status = 'error'
s.error = message
})
}
/**
* Refresh the whole page snapshot: directory and namespaces in parallel,
* then one batched credential describe over every referenced ref. A
* failure keeps the last good rows and surfaces the error.
* @returns nothing; the snapshot carries the outcome.
*/
async load(): Promise<void> {
const generation = ++this.generation
this.store.update((s) => { s.status = 'loading'; s.error = null })
let providers: ConfigurableProviderView[]
let writable: boolean
let views: SettingsNamespaceView[]
try {
const [providersResponse, settingsResponse] = await Promise.all([
this.api.llm.providers({}),
this.api.settings.describe({}),
])
if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message)
if (!settingsResponse.result.ok) throw new Error(settingsResponse.result.error.message)
providers = providersResponse.result.value.providers
writable = settingsResponse.result.value.writable
views = settingsResponse.result.value.namespaces
} catch (error) {
if (generation !== this.generation) return
this.store.update((s) => {
s.status = 'error'
s.error = error instanceof Error ? error.message : String(error)
})
return
}
const namespaces = new Map(views.map(view => [view.ns, view]))
const rows: ProviderRow[] = providers.map((entry) => {
const namespace = namespaces.get(entry.settingsNs)
const configured = namespace !== undefined
&& (entry.settingsPath.length === 0 || getPath(namespace.value, entry.settingsPath) !== undefined)
const removable = namespace !== undefined
&& entry.settingsPath.length > 0
&& hasPath(namespace.user, entry.settingsPath)
&& !hasPath(namespace.base, entry.settingsPath)
return {
entry,
configured,
removable,
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
credential: undefined,
literalApiKeyConfigured: literalApiKeyConfigured(namespace, entry.settingsPath),
}
})
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
let credentials: Record<string, CredentialView> = {}
let credentialError: string | null = null
if (refs.length > 0) {
try {
const response = await this.api.credentials.describe({ refs })
// Credential state is an enrichment for the Models page: neither a
// business rejection nor a transport failure fails the load. The
// onboarding projection below retains the failure distinction.
if (response.result.ok) credentials = response.result.value.credentials
else credentialError = response.result.error.message
} catch (error) {
credentialError = messageOf(error)
}
}
if (generation !== this.generation) return
this.store.update((s) => {
s.status = 'ready'
s.error = null
s.credentialError = credentialError
s.writable = writable
s.rows = rows.map(row => ({
...row,
...row.apiKeyEnv !== undefined && credentials[row.apiKeyEnv] !== undefined
? { credential: credentials[row.apiKeyEnv] }
: {},
}))
s.namespaces = namespaces
})
}
}
/** DeepSeek onboarding readiness derived only from the shared Models join. */
export type DeepSeekReadiness =
| { kind: 'loading' }
| { kind: 'adapter-absent' }
| { kind: 'configured' }
| { kind: 'credential-missing' }
| {
kind: 'unavailable'
reason:
| 'load-failed'
| 'provider-inactive'
| 'settings-unavailable'
| 'credential-ref-unavailable'
| 'credentials-unavailable'
| 'settings-read-only'
| 'credential-read-only'
}
/**
* Project official-DeepSeek readiness from the provider/settings/credential
* join used by the Models page. A missing official configurable-provider
* declaration means the adapter is not repairable by navigating to Models.
* @param state - current shared Models join snapshot.
* @returns the onboarding state without reading a parallel fact source.
*/
export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness {
if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) {
return { kind: 'loading' }
}
if (state.status === 'error') {
return {
kind: 'unavailable',
reason: 'load-failed',
}
}
const row = state.rows.find(candidate =>
candidate.entry.provider === 'deepseek-official'
&& candidate.entry.settingsNs === 'llm-deepseek'
&& candidate.entry.settingsPath.length === 0)
if (row === undefined) return { kind: 'adapter-absent' }
if (!row.entry.active) {
return {
kind: 'unavailable',
reason: 'provider-inactive',
}
}
if (!row.configured) {
return {
kind: 'unavailable',
reason: 'settings-unavailable',
}
}
if (row.literalApiKeyConfigured) return { kind: 'configured' }
if (row.apiKeyEnv === undefined) {
return {
kind: 'unavailable',
reason: 'credential-ref-unavailable',
}
}
if (state.credentialError !== null) {
return {
kind: 'unavailable',
reason: 'credentials-unavailable',
}
}
if (row.credential === undefined) {
return {
kind: 'unavailable',
reason: 'credentials-unavailable',
}
}
if (row.credential.configured) {
return { kind: 'configured' }
}
if (!state.writable) {
return {
kind: 'unavailable',
reason: 'settings-read-only',
}
}
if (!row.credential.writable) {
return {
kind: 'unavailable',
reason: 'credential-read-only',
}
}
return { kind: 'credential-missing' }
}

View File

@@ -1,29 +1,39 @@
/** Models section registration: declaration-aware deferral, locale re-registration, and HMR recovery. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-models/client'
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
import { ModelsSection } from '../src/client/ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
// The apply path only captures the wire face; no call leaves this fake
// until a section actually loads.
ctx.provide('connection', { api: {} } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
}
function declare(slots: SlotsService): () => void {
return slots.register(
{ name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never,
{
name: 'root',
children: {
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
} as never,
() => null,
)
}
describe('ui-models apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale'])
expect(inject).toEqual(['slots', 'locale', 'connection'])
})
it('registers the models nav entry for declarations before or after apply', async () => {
@@ -32,14 +42,24 @@ describe('ui-models apply', () => {
await before.ctx.plugin({ inject: [...inject], apply }).await()
const entry = before.slots.entries('settings.section')[0]!
expect(entry.component).toBe(ModelsSection)
expect(entry.options).toEqual({ id: 'models', order: 10, label: '模型' })
expect(entry.options).toMatchObject({ id: 'models', order: 10, label: '模型' })
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
expect(injected.t('nav')).toBe('模型')
expect(typeof injected.controller.load).toBe('function')
expect(typeof injected.useSnapshot).toBe('function')
expect(injected.api).toBeDefined()
const onboarding = before.slots.entries('settings.onboarding')[0]!
expect(onboarding.component).toBe(DeepSeekOnboardingDialog)
expect(onboarding.options).toMatchObject({ id: 'deepseek-official', order: 0 })
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
expect(after.slots.entries('settings.section')).toHaveLength(0)
expect(after.slots.entries('settings.onboarding')).toHaveLength(0)
declare(after.slots)
await Promise.resolve()
expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
expect(after.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
// The self-inflicted ledger notifications hit the duplicate guard.
expect(after.slots.entries('settings.section')).toHaveLength(1)
})
@@ -71,9 +91,11 @@ describe('ui-models apply', () => {
// disposer variable goes stale.
redeclare()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
declare(b.slots)
await Promise.resolve()
expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
expect(b.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
// The locale path also recovers through the same ledger re-check.
b.locale.setLocale('en')
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models')
@@ -88,8 +110,52 @@ describe('ui-models apply', () => {
expect(b.locale.bind('settings.models')('nav')).toBe('模型')
await fiber.dispose()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
// The (ns, locale) seats are free again — the dictionary disposers ran.
expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow()
expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow()
})
})
describe('pushed invalidations', () => {
it('ignores invalidations before the page ever loaded', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
// The fake wire face has no methods: a fetch attempt would throw.
b.ctx.emit('settings/changed', 'llm-pi-ai')
b.ctx.emit('credentials/changed', 'OPENAI_API_KEY')
b.ctx.emit('models/changed')
b.ctx.emit('connection/reset')
})
it('refreshes a loaded page and skips an idle one', () => {
const loads: number[] = []
const controller = {
store: { getSnapshot: () => ({ status: 'ready' }) },
load: () => { loads.push(1); return Promise.resolve() },
}
refreshIfLoaded(controller as unknown as import('../src/client/store.ts').ModelsSettingsStore)
expect(loads).toHaveLength(1)
const idle = {
store: { getSnapshot: () => ({ status: 'idle' }) },
load: () => { loads.push(2); return Promise.resolve() },
}
refreshIfLoaded(idle as unknown as import('../src/client/store.ts').ModelsSettingsStore)
expect(loads).toHaveLength(1)
})
it('routes pushed credential invalidation into the shared onboarding join', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = (
b.slots.entries('settings.onboarding')[0]!.inject as unknown as
() => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
)()
injected.controller.store.update((state) => { state.status = 'ready' })
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
b.ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY')
expect(load).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,606 @@
// @vitest-environment jsdom
/** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Schema from 'schemastery'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
import { pathOps } from '../src/client/ProviderEditor.tsx'
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
import type { ProviderRow } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const t: ModelsSectionInjected['t'] = key => en[key]
const PiAiConfig = Schema.object({
token: Schema.string().role('secret'),
providers: Schema.dict(Schema.object({
apiKey: Schema.string().role('secret'),
apiKeyEnv: Schema.string().role('credential-ref'),
baseURL: Schema.string(),
reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
headers: Schema.dict(Schema.string()),
})),
})
const DeepSeekConfig = Schema.object({
apiKey: Schema.string().role('secret'),
apiKeyEnv: Schema.string().role('credential-ref'),
baseURL: Schema.string().pattern(/^https:\/\//),
reasoningEffort: Schema.union(['off', 'high', 'max']),
})
function wireNamespaces(): SettingsNamespaceView[] {
return [
{
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', reasoningEffort: 'high' },
base: {},
user: { reasoningEffort: 'high' },
applies: 'live',
secrets: [{ path: ['apiKey'], set: false }],
revision: 0,
},
{
ns: 'llm-plain',
schema: JSON.parse(JSON.stringify(Schema.object({
profiles: Schema.dict(Schema.object({ note: Schema.string() })),
}).toJSON())) as unknown,
value: {},
applies: 'live',
secrets: [],
revision: 0,
},
{
ns: 'llm-pi-ai',
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
applies: 'live',
secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
revision: 0,
},
]
}
let nextRpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
}
function fail<T>(message: string, code = 'settings-rejected'): RpcResponse<T> {
return {
rpcId: `r-${nextRpc++}` as never,
result: { ok: false, error: { code, message, details: { ns: 'x' } } as never },
}
}
function scriptedFace(overrides: {
update?: ReturnType<typeof vi.fn>
replace?: ReturnType<typeof vi.fn>
mutate?: ReturnType<typeof vi.fn>
set?: ReturnType<typeof vi.fn>
} = {}) {
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
const face = {
llm: {
providers: vi.fn(() => Promise.resolve(ok({
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 },
{ provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
{ provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false },
{ provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false },
],
}))),
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
},
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
update,
replace,
mutate,
},
credentials: {
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
configured: ref === 'OPENAI_API_KEY',
...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {},
writable: true,
}])),
}))),
set,
unset: vi.fn(() => Promise.resolve(ok({}))),
},
}
return { face, update, replace, mutate, set }
}
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
const { face, update, replace, mutate, set } = scriptedFace(overrides)
const controller = new ModelsSettingsStore(face as unknown as WireFace)
await controller.load()
const injected: ModelsSectionInjected = {
controller,
useSnapshot: bindSnapshotSelector(controller.store),
api: face as never,
t,
}
const view = render(<ModelsSection {...injected} />)
return { view, face, update, replace, mutate, set, controller }
}
describe('ModelsSection', () => {
it('renders nothing before the slot injects its dependencies', () => {
const uninjected = {} as ModelsSectionProps
render(<ModelsSection {...uninjected} />)
expect(document.body.textContent).toBe('')
})
it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
await mountSection()
// DeepSeek has no configured credential and no stored apiKey → setup card.
expect(screen.getByText('DeepSeek')).toBeTruthy()
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
// Configured pi-ai profiles render as rows with liveness badges only.
expect(screen.getByText('openai')).toBeTruthy()
expect(screen.getAllByText(en.active)).toHaveLength(1)
expect(screen.getByText(en.dormant)).toBeTruthy()
expect(screen.getByText(`+ ${en.add}`)).toBeTruthy()
})
it('turns the setup card into a row once the credential reports configured', async () => {
const { face } = await mountSection()
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
})))
const controller = new ModelsSettingsStore(face as unknown as WireFace)
await controller.load()
cleanup()
render(<ModelsSection
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face as never}
t={t}
/>)
// Now a row with an Edit button, not an open card.
expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
})
it('decides setup need from the joined credential state and literal-key sidecar', () => {
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
const row = (
credential: ProviderRow['credential'],
literalApiKeyConfigured = false,
): ProviderRow => ({
entry,
configured: true,
removable: false,
apiKeyEnv: 'X',
credential,
literalApiKeyConfigured,
})
expect(needsSetup(row(undefined))).toBe(true)
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
expect(needsSetup(row(undefined, true))).toBe(false)
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
expect(needsSetup(nested)).toBe(false)
})
it('derives conventional credential references from route ids', () => {
expect(deriveKeyRef('anthropic')).toBe('ANTHROPIC_API_KEY')
expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
})
it('names only the fields the card can see, so an unseen secret survives', () => {
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
// side, so no op mentions it and the seam leaves it alone.
expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' }))
.toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }])
expect(pathOps([], { b: 1 }, { b: 2, d: 3 }))
.toEqual([{ op: 'set', path: ['b'], value: 2 }, { op: 'set', path: ['d'], value: 3 }])
expect(pathOps([], undefined, {})).toEqual([])
expect(pathOps([], { a: 1 }, { a: 1 })).toEqual([])
})
it('stores a typed key write-only from the setup card without touching settings', async () => {
const { set, update, face } = await mountSection()
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(key, { target: { value: 'sk-live' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
expect(update).not.toHaveBeenCalled()
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
})
it('applies customized deepseek fields as path ops', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
// The deepseek placeholder is pinned to the public endpoint, not the
// effective value (which may reflect a launch-environment override).
expect(baseURL.placeholder).toBe('https://api.deepseek.com')
fireEvent.change(baseURL, { target: { value: 'https://next2' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
// Only the field that actually changed: reasoningEffort was already
// 'high' in the loaded profile, so it produces no op.
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{ op: 'set', path: ['baseURL'], value: 'https://next2' }],
expectedRevision: 0,
})
})
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
// The data-loss shape: the old path rebuilt the section from the REDACTED
// user layer and replaced it wholesale, deleting any stored literal key.
const { replace, update, mutate } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
expect(effort.value).toBe('high')
fireEvent.change(effort, { target: { value: '' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(replace).not.toHaveBeenCalled()
expect(update).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{ op: 'unset', path: ['reasoningEffort'] }],
expectedRevision: 0,
})
})
it('pins the deepseek placeholder and clears typed input back to inherited', async () => {
const { face } = scriptedFace()
const bare: SettingsNamespaceView = {
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
value: {},
applies: 'live',
secrets: [],
revision: 0,
}
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
render(<ProviderEditor
provider="deepseek-official"
displayName="DeepSeek"
namespace={bare}
settingsPath={[]}
api={face as never}
t={t}
readOnly={false}
onClose={() => {}}
/>)
fireEvent.click(screen.getByText(en.customized))
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
expect(baseURL.placeholder).toBe('https://api.deepseek.com')
fireEvent.change(baseURL, { target: { value: 'https://x' } })
expect(baseURL.value).toBe('https://x')
fireEvent.change(baseURL, { target: { value: '' } })
expect(baseURL.value).toBe('')
})
it('rejects an invalid draft before writing', async () => {
const { update } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(/baseURL/)
expect(update).not.toHaveBeenCalled()
})
it('edits a pi-ai profile with the curated fields only', async () => {
const { mutate } = await mountSection()
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
// The configured credential shows as the stored placeholder.
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
const editorKey = keys[keys.length - 1] as HTMLInputElement
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
// pi-ai carries Base URL too: the stored override shows as the value and
// the effective profile endpoint as its placeholder source.
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
expect(urls).toHaveLength(2)
expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
// Only the edited field travels: apiKeyEnv, baseURL and headers were
// already stored with these values, so no op restates them — and the
// profile's stored literal apiKey, absent from the redacted view the card
// read, is named by nothing at all.
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }],
expectedRevision: 0,
})
})
it('adds a dormant provider with a derived reference and stores its key', async () => {
const { mutate, set } = await mountSection()
fireEvent.click(screen.getByText(`+ ${en.add}`))
const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
expect(pick.value).toBe('anthropic')
// A dormant profile has no endpoint anywhere: the pi-ai placeholder
// falls back to the provider-default wording.
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
const addKey = keys[keys.length - 1] as HTMLInputElement
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }],
expectedRevision: 0,
})
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
})
it('switches the add card target and degrades unknown or broken targets loudly', async () => {
await mountSection()
fireEvent.click(screen.getByText(`+ ${en.add}`))
const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
fireEvent.change(pick, { target: { value: 'broken' } })
await screen.findByText(/unresolvable settings path/)
fireEvent.change(pick, { target: { value: 'plain' } })
await waitFor(() => {
expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
})
// The hint-only card cannot apply anything.
const applies = screen.getAllByText<HTMLButtonElement>(en.apply)
expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true)
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
})
it('surfaces a rejected settings write and never stores the key after it', async () => {
const { set } = await mountSection({
mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
})
fireEvent.click(screen.getByText(`+ ${en.add}`))
await screen.findByLabelText(en.provider)
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await screen.findByText(/unknown pi-ai provider/)
expect(set).not.toHaveBeenCalled()
})
it('renders the card without the stored-key hint when the credential probe rejects', async () => {
// The probe is a placeholder hint, not a precondition: an escaping
// rejection would surface in the browser as an unhandled rejection.
const { face } = scriptedFace()
face.credentials.describe = vi.fn(() => Promise.reject(new Error('connection lost')))
const unhandled = vi.fn()
process.on('unhandledRejection', unhandled)
try {
const controller = new ModelsSettingsStore(face as unknown as WireFace)
await controller.load()
render(<ModelsSection
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face as never}
t={t}
/>)
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
expect(key.placeholder).toBe(en.keyPlaceholder)
await new Promise(resolve => setTimeout(resolve, 10))
expect(unhandled).not.toHaveBeenCalled()
} finally {
process.off('unhandledRejection', unhandled)
}
})
it('tells the user to reopen when another writer moved the namespace first', async () => {
// The stale-draft overwrite: two tabs open the same card, the other saves,
// and this one must be refused rather than replay its opening snapshot.
const { set } = await mountSection({
mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
})
fireEvent.click(screen.getByText(en.customized))
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://mine' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(en.conflict)
expect(set).not.toHaveBeenCalled()
})
it('keeps the card usable when the write rejects instead of answering', async () => {
// A transport failure (disconnect, or the 403 a non-loopback browser now
// gets on the whole configuration plane) rejects rather than returning a
// failed envelope: without a catch the card would stay busy forever.
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
fireEvent.click(screen.getByText(en.customized))
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText('connection lost')
// Not stuck in `applying…`: the finally cleared busy, so Apply is live again.
expect(screen.getByText(en.apply)).toBeTruthy()
})
it('surfaces a shadowed credential write on the card', async () => {
await mountSection({
set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
})
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(key, { target: { value: 'sk-live' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(/shadowed by the read-only environment/)
})
it('locks the key input when the launch environment provides the credential', async () => {
const { face } = await mountSection()
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
}])),
})))
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
const editorKey = keys[keys.length - 1] as HTMLInputElement
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
expect(editorKey.disabled).toBe(true)
})
it('keeps a failed credential describe silent and the input usable', async () => {
const { face, set } = await mountSection()
face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
const editorKey = keys[keys.length - 1] as HTMLInputElement
expect(editorKey.placeholder).toBe(en.keyPlaceholder)
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
})
it('removes a user-added provider by unsetting its path', async () => {
const { replace, mutate } = await mountSection()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(replace).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'unset', path: ['providers', 'openai'] }],
})
})
it('renders the load failure with a retry control', async () => {
const face = scriptedFace()
face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
const controller = new ModelsSettingsStore(face.face as unknown as WireFace)
await controller.load()
render(<ModelsSection
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face.face as never}
t={t}
/>)
expect(screen.getByText(/directory down/)).toBeTruthy()
fireEvent.click(screen.getByText(en.retry))
await waitFor(() => { expect(screen.queryByText(/directory down/)).toBeNull() })
})
it('shows the read-only notice and disables mutations for a read-only provider', async () => {
const { face } = await mountSection()
face.settings.describe.mockImplementation(() => Promise.resolve(ok({
writable: false,
namespaces: wireNamespaces(),
})))
const controller = new ModelsSettingsStore(face as unknown as WireFace)
await controller.load()
cleanup()
render(<ModelsSection
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face as never}
t={t}
/>)
expect(screen.getByText(en.readOnly)).toBeTruthy()
expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
expect(screen.getByText<HTMLButtonElement>(`+ ${en.add}`).disabled).toBe(true)
})
it('toggles the row editor closed on a second edit click and on cancel', async () => {
const { update } = await mountSection()
const edit = screen.getAllByText(en.edit)[0] as HTMLElement
fireEvent.click(edit)
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
fireEvent.click(edit)
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
fireEvent.click(edit)
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
expect(update).not.toHaveBeenCalled()
})
it('cancels the add card back to the add button', async () => {
await mountSection()
fireEvent.click(screen.getByText(`+ ${en.add}`))
await screen.findByLabelText(en.provider)
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
await screen.findByText(`+ ${en.add}`)
expect(screen.queryByLabelText(en.provider)).toBeNull()
})
it('loads on first render of an idle controller', async () => {
const { face } = scriptedFace()
const controller = new ModelsSettingsStore(face as unknown as WireFace)
render(<ModelsSection
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face as never}
t={t}
/>)
await screen.findByText('DeepSeek')
})
it('removes by unsetting the profile path, never by rebuilding the section', async () => {
// The section rebuild is what dropped stored literal secrets: this page
// only ever holds the redacted descriptor, so the removal names the path.
const { face, mutate, replace, controller } = await mountSection()
await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],
controller,
{ settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
)
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-plain',
ops: [{ op: 'unset', path: ['ghost-profile'] }],
})
expect(replace).not.toHaveBeenCalled()
})
it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => {
const { face, controller } = await mountSection({
mutate: vi.fn(() => Promise.resolve(fail('read-only'))),
})
const before = controller.store.getSnapshot().rows
const failure = await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],
controller,
{ settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
)
expect(failure).toBe('read-only')
expect(controller.store.getSnapshot().rows).toBe(before)
})
it('shows a failed removal on the page banner, including a non-Error rejection', async () => {
// The whole click path: the row's Remove button, the transport rejecting
// with a non-Error value, and the store surfacing it where a load failure
// would appear — rather than the row silently staying put.
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
await screen.findByText(`${en.loadFailed}: the host refused`)
})
it('reports a transport rejection instead of failing the removal silently', async () => {
const { face, controller } = await mountSection({
mutate: vi.fn(() => Promise.reject(new Error('connection lost'))),
})
const failure = await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],
controller,
{ settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
)
expect(failure).toBe('connection lost')
})
})

View File

@@ -17,7 +17,7 @@ describe('invariant companion', () => {
expect(true).toBe(true) // reaching here without throw is the contract
})
it('the section content column is intentionally empty this phase', () => {
expect(ModelsSection()).toBeNull()
it('renders null until the shell injects the section dependencies', () => {
expect(ModelsSection({})).toBeNull()
})
})

View File

@@ -0,0 +1,186 @@
// @vitest-environment jsdom
/** First-run DeepSeek prompt behavior over the shared Models join. */
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
import { ModelsSettingsStore } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
let nextRpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `onboarding-${nextRpc++}` as never, result: { ok: true, value } }
}
function fail<T>(message: string): RpcResponse<T> {
return {
rpcId: `onboarding-${nextRpc++}` as never,
result: { ok: false, error: { code: 'internal', message, details: {} } },
}
}
function harness(options: {
provider?: boolean
providerActive?: boolean
providerSettingsNs?: string
settingsNamespace?: boolean
apiKeyEnv?: string | null
literal?: boolean
configured?: () => boolean
credential?: { source?: string; writable: boolean }
describeFailure?: string
settingsWritable?: boolean
providersReject?: boolean
} = {}) {
let fileConfigured = false
const configured = options.configured ?? (() => fileConfigured)
const face = {
llm: {
providers: () => {
if (options.providersReject === true) {
return Promise.reject(new Error('provider transport unavailable'))
}
return Promise.resolve(ok({
providers: options.provider === false
? []
: [{
provider: 'deepseek-official',
displayName: 'DeepSeek',
settingsNs: options.providerSettingsNs ?? 'llm-deepseek',
settingsPath: [],
active: options.providerActive ?? true,
}],
}))
},
},
settings: {
describe: () => Promise.resolve(ok({
writable: options.settingsWritable ?? true,
namespaces: options.settingsNamespace === false
? []
: [{
ns: 'llm-deepseek',
schema: {},
value: options.apiKeyEnv === null
? {}
: { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
applies: 'live' as const,
secrets: [{ path: ['apiKey'], set: options.literal === true }],
revision: 0,
}],
})),
},
credentials: {
describe: () => options.describeFailure === undefined
? Promise.resolve(ok({
credentials: {
DEEPSEEK_API_KEY: {
configured: configured(),
...configured() && options.credential?.source !== undefined
? { source: options.credential.source }
: {},
writable: options.credential?.writable ?? true,
},
},
}))
: Promise.resolve(fail(options.describeFailure)),
},
}
const controller = new ModelsSettingsStore(face as never)
const openSection = vi.fn()
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
const props: DeepSeekOnboardingDialogProps = {
active: true,
openSection,
useSessions: unusedHook,
useWorkspaces: unusedHook,
controller,
useSnapshot: bindSnapshotSelector(controller.store),
t: key => en[key],
}
return { controller, openSection, props, configure: () => { fileConfigured = true } }
}
describe('DeepSeekOnboardingDialog', () => {
it('loads on first entry and presents one accessible route to Models', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
expect(action).toBeTruthy()
expect(document.activeElement).toBe(action)
expect(screen.queryByRole('textbox')).toBeNull()
})
it('opens the Models section and dismisses the prompt', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.openSection).toHaveBeenCalledWith('models')
expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull()
})
it('allows configure-later dismissal without opening settings', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(h.openSection).not.toHaveBeenCalled()
})
it('does not block the product when DeepSeek setup is unavailable', async () => {
for (const h of [
harness({ describeFailure: 'credentials service is absent' }),
harness({ credential: { writable: false } }),
harness({ settingsWritable: false }),
harness({ providersReject: true }),
harness({ providerActive: false }),
harness({ settingsNamespace: false }),
harness({ apiKeyEnv: null }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
expect(h.openSection).not.toHaveBeenCalled()
view.unmount()
}
})
it('skips an absent adapter and already-configured literal or environment credentials', async () => {
for (const h of [
harness({ provider: false }),
harness({ providerSettingsNs: '' }),
harness({ literal: true, describeFailure: 'credential seam absent' }),
harness({ configured: () => true, credential: { source: 'env', writable: false } }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
view.unmount()
}
})
it('closes when an external credential invalidation refreshes the shared join', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
h.configure()
await act(async () => { await h.controller.load() })
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
})
it('stays hidden while the onboarding owner is inactive', async () => {
const h = harness()
const view = render(<DeepSeekOnboardingDialog {...h.props} active={false} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
})
})

View File

@@ -0,0 +1,105 @@
/** Pure official-DeepSeek readiness projection over the shared Models join. */
import { describe, expect, it } from 'vitest'
import type { CredentialView } from '@deepseek-ai/dsh-client-connection/client'
import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts'
import { deepSeekReadiness } from '../src/client/store.ts'
const missingCredential: CredentialView = { configured: false, writable: true }
function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
return {
entry: {
provider: 'deepseek-official',
displayName: 'DeepSeek',
settingsNs: 'llm-deepseek',
settingsPath: [],
active: true,
},
configured: true,
removable: false,
apiKeyEnv: 'DEEPSEEK_API_KEY',
credential: missingCredential,
literalApiKeyConfigured: false,
...overrides,
}
}
function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsState {
return {
status: 'ready',
error: null,
credentialError: null,
writable: true,
rows: [row()],
namespaces: new Map(),
...overrides,
}
}
describe('deepSeekReadiness', () => {
it('waits for the first join and skips onboarding when the adapter directory entry is absent', () => {
expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
expect(deepSeekReadiness(state({
rows: [row({
entry: {
...row().entry,
settingsNs: '',
},
})],
}))).toEqual({ kind: 'adapter-absent' })
})
it('reports a missing writable effective credential', () => {
expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' })
})
it('accepts file and process-environment credentials without prompting', () => {
expect(deepSeekReadiness(state({
rows: [row({ credential: { configured: true, source: 'file', writable: true } })],
}))).toEqual({ kind: 'configured' })
expect(deepSeekReadiness(state({
rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
}))).toEqual({ kind: 'configured' })
})
it('accepts the redacted literal-key sidecar before judging the credential domain', () => {
expect(deepSeekReadiness(state({
credentialError: 'credentials service absent',
rows: [row({ literalApiKeyConfigured: true, credential: undefined })],
}))).toEqual({ kind: 'configured' })
})
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
kind: 'unavailable',
reason: 'load-failed',
})
expect(deepSeekReadiness(state({
rows: [row({ entry: { ...row().entry, active: false } })],
}))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' })
expect(deepSeekReadiness(state({
rows: [row({ configured: false })],
}))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' })
expect(deepSeekReadiness(state({
rows: [row({ apiKeyEnv: undefined })],
}))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
expect(deepSeekReadiness(state({
credentialError: 'credentials service is absent',
}))).toEqual({
kind: 'unavailable',
reason: 'credentials-unavailable',
})
expect(deepSeekReadiness(state({
rows: [row({ credential: undefined })],
}))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' })
expect(deepSeekReadiness(state({
rows: [row({ credential: { configured: false, writable: false } })],
}))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' })
expect(deepSeekReadiness(state({ writable: false }))).toEqual({
kind: 'unavailable',
reason: 'settings-read-only',
})
})
})

View File

@@ -0,0 +1,287 @@
/** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */
import { describe, expect, it } from 'vitest'
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
import { messageOf, ModelsSettingsStore } from '../src/client/store.ts'
let nextRpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
}
function fail<T>(message: string): RpcResponse<T> {
return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'internal', message, details: {} } } }
}
const DIRECTORY = [
{ 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 },
{ provider: 'ghost', displayName: 'Ghost', settingsNs: '', settingsPath: [], active: true },
]
const NAMESPACES = [
{
ns: 'llm-deepseek',
schema: {},
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
base: { baseURL: 'https://base' },
applies: 'live' as const,
secrets: [{ path: ['apiKey'], set: false }],
revision: 0,
},
{
ns: 'llm-pi-ai',
schema: {},
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
applies: 'live' as const,
secrets: [],
revision: 0,
},
]
function api(overrides: {
providers?: () => Promise<RpcResponse<{ providers: typeof DIRECTORY }>>
describeSettings?: () => Promise<RpcResponse<{ writable: boolean; namespaces: typeof NAMESPACES }>>
describeCredentials?: (refs: string[]) => Promise<RpcResponse<{ credentials: Record<string, unknown> }>>
} = {}) {
const seenRefs: string[][] = []
const face = {
llm: {
providers: overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY }))),
models: () => Promise.resolve(ok({ groups: [], failures: [] })),
},
settings: {
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, namespaces: NAMESPACES }))),
update: () => Promise.resolve(fail('unused')),
replace: () => Promise.resolve(fail('unused')),
},
credentials: {
describe: (payload: { refs: string[] }) => {
seenRefs.push(payload.refs)
return (overrides.describeCredentials ?? (refs => Promise.resolve(ok({
credentials: Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', writable: true }])),
}))))(payload.refs)
},
set: () => Promise.resolve(ok({})),
unset: () => Promise.resolve(ok({})),
},
}
return { face: face as never, seenRefs }
}
describe('ModelsSettingsStore', () => {
it('joins rows with configured, removable, and credential state', async () => {
const { face, seenRefs } = api()
const store = new ModelsSettingsStore(face)
await store.load()
const state = store.store.getSnapshot()
expect(state.status).toBe('ready')
expect(state.writable).toBe(true)
expect(state.credentialError).toBeNull()
expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
expect(byProvider.get('deepseek-official')).toMatchObject({
configured: true,
removable: false,
apiKeyEnv: 'DEEPSEEK_API_KEY',
credential: { configured: false, writable: true },
literalApiKeyConfigured: false,
})
expect(byProvider.get('openai')).toMatchObject({
configured: true,
removable: true,
apiKeyEnv: 'OPENAI_API_KEY',
credential: { configured: true },
})
expect(byProvider.get('anthropic')).toMatchObject({ configured: false, removable: false })
expect(byProvider.get('anthropic')?.apiKeyEnv).toBeUndefined()
expect(byProvider.get('ghost')).toMatchObject({ configured: false, removable: false })
expect(state.namespaces.get('llm-pi-ai')?.ns).toBe('llm-pi-ai')
})
it('degrades the credential badge, not the page, when the credential domain fails', async () => {
const { face } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) })
const store = new ModelsSettingsStore(face)
await store.load()
const state = store.store.getSnapshot()
expect(state.status).toBe('ready')
expect(state.credentialError).toBe('no provider')
expect(state.rows.every(row => row.credential === undefined)).toBe(true)
})
it('settles a credential transport rejection without leaving the store loading', async () => {
const { face } = api({
describeCredentials: () => Promise.reject(new Error('credential transport down')),
})
const store = new ModelsSettingsStore(face)
await expect(store.load()).resolves.toBeUndefined()
expect(store.store.getSnapshot()).toMatchObject({
status: 'ready',
credentialError: 'credential transport down',
})
})
it('stringifies a non-Error credential transport rejection', async () => {
const { face } = api({
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
describeCredentials: () => Promise.reject('credential transport refusal'),
})
const store = new ModelsSettingsStore(face)
await expect(store.load()).resolves.toBeUndefined()
expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
})
it('joins a configured literal key from the redacted secret sidecar', async () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
namespaces: [{
...NAMESPACES[0],
secrets: [
{ path: ['apiKey', 'nested'], set: true },
{ path: ['different'], set: true },
{ path: ['apiKey'], set: true },
],
}] as never,
})),
providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })),
})
const store = new ModelsSettingsStore(face)
await store.load()
expect(store.store.getSnapshot().rows[0]).toMatchObject({
literalApiKeyConfigured: true,
apiKeyEnv: 'DEEPSEEK_API_KEY',
})
})
it('surfaces a directory failure and keeps the last good rows', async () => {
const { face } = api()
const store = new ModelsSettingsStore(face)
await store.load()
expect(store.store.getSnapshot().rows).toHaveLength(4)
const broken = api({ providers: () => Promise.resolve(fail('directory down')) })
const failing = new ModelsSettingsStore(broken.face)
await failing.load()
expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' })
// The first store's snapshot is untouched by the second's failure.
expect(store.store.getSnapshot().status).toBe('ready')
})
it('lets the newest load win over a stale slow response', async () => {
let release: (() => void) | undefined
const gate = new Promise<void>((resolve) => { release = resolve })
let call = 0
const { face } = api({
providers: async () => {
call += 1
if (call === 1) {
await gate
return fail('stale slow failure')
}
return ok({ providers: DIRECTORY })
},
})
const store = new ModelsSettingsStore(face)
const first = store.load()
const second = store.load()
release?.()
await Promise.all([first, second])
expect(store.store.getSnapshot().status).toBe('ready')
})
})
describe('edge joins', () => {
it('treats a non-object profile as having no credential reference', async () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
namespaces: [{
ns: 'llm-pi-ai',
schema: {},
value: { providers: { weird: 'oops' } },
applies: 'live' as const,
secrets: [],
revision: 0,
}] as never,
})),
providers: () => Promise.resolve(ok({
providers: [
{ provider: 'weird', displayName: 'weird', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'weird'], active: false },
] as never,
})),
})
const store = new ModelsSettingsStore(face)
await store.load()
const state = store.store.getSnapshot()
expect(state.rows[0]).toMatchObject({ configured: true, removable: false })
expect(state.rows[0]?.apiKeyEnv).toBeUndefined()
})
it('skips the credential describe entirely when no row names a reference', async () => {
const { face, seenRefs } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
})),
providers: () => Promise.resolve(ok({
providers: [
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
] as never,
})),
})
const store = new ModelsSettingsStore(face)
await store.load()
expect(seenRefs).toEqual([])
expect(store.store.getSnapshot().status).toBe('ready')
})
it('surfaces a settings describe failure', async () => {
const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
const store = new ModelsSettingsStore(face)
await store.load()
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
})
it('stringifies a non-Error load failure', async () => {
// The wire can surface non-Error throwables; the store must stringify them.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
const { face } = api({ providers: () => Promise.reject('plain refusal') })
const store = new ModelsSettingsStore(face)
await store.load()
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' })
})
it('drops a stale successful response after a newer load finished', async () => {
let release: (() => void) | undefined
const gate = new Promise<void>((resolve) => { release = resolve })
let call = 0
const { face } = api({
providers: async () => {
call += 1
if (call === 1) {
await gate
return ok({ providers: [] as never })
}
return ok({ providers: DIRECTORY })
},
})
const store = new ModelsSettingsStore(face)
const first = store.load()
const second = store.load()
await second
release?.()
await first
// The stale empty directory never overwrote the newer join.
expect(store.store.getSnapshot().rows).toHaveLength(4)
})
})
describe('messageOf', () => {
it('reads an Error message, and stringifies anything else a rejection may carry', () => {
// The wire layer rejects with an Error, but a host or a runtime can reject
// with any value, and the page still has to render something.
expect(messageOf(new Error('connection lost'))).toBe('connection lost')
expect(messageOf('the host refused')).toBe('the host refused')
expect(messageOf(undefined)).toBe('undefined')
})
})

View File

@@ -17,6 +17,18 @@
{
"path": "../runtime"
},
{
"path": "../connection"
},
{
"path": "../schema-form"
},
{
"path": "../ui-primitives"
},
{
"path": "../web-react"
},
{
"path": "../ui-settings"
},

View File

@@ -1,5 +1,5 @@
// DeepSeek Harness brand wordmark (figma 356:14644, exact extract): whale +
// "deepseek" letterforms + HARNESS badge plate in one svg. Native 182x24.
// "deepseek-official" letterforms + HARNESS badge plate in one svg. Native 182x24.
// Ink rides currentColor; the badge text is knocked out in the inverted
// label color so the plate stays legible in both themes.

View File

@@ -13,6 +13,7 @@ import css from './Modal.module.css'
* @param props.open - whether the dialog is showing.
* @param props.onClose - Escape or mask click.
* @param props.title - dialog heading (aria-label in every mode).
* @param props.closeLabel - accessible close-button label.
* @param props.description - optional supporting sentence under the title.
* @param props.children - body (inputs, etc.).
* @param props.footer - action row (Cancel / Create).
@@ -21,10 +22,13 @@ import css from './Modal.module.css'
* header structure; mask, card, Escape, and aria-label remain.
* @returns null when closed; otherwise the overlay tree.
*/
export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: {
export function Modal({
open, onClose, title, closeLabel = 'Close', description, children, footer, className, headless = false,
}: {
open: boolean
onClose: () => void
title: string
closeLabel?: string
description?: string
children?: ReactNode
footer?: ReactNode
@@ -58,7 +62,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
<div className={css.content}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
<IconCloseOutline16 size={14} />
</button>
</div>

View File

@@ -324,10 +324,11 @@ describe('Modal', () => {
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
expect(screen.queryByRole('dialog')).toBeNull()
rerender(
<Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." footer={<button type="button">Create</button>}>
<input aria-label="name" />
</Modal>)
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Configure later' })).toBeDefined()
expect(screen.getByText('Name it.')).toBeDefined()
fireEvent.keyDown(document, { key: 'a' })
expect(onClose).not.toHaveBeenCalled()

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-question/README.md
README.md: 0700375758774610fcd897b9a3e16484206a871d
README.zh.md: d9e5eb22cef13e16ab1ce2cebba9e563bd9d08d9
README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345
README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3

View File

@@ -6,6 +6,8 @@ Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user`
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.

View File

@@ -6,6 +6,8 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定超长的详情与选项共享内部滚动区。单选选项会立即前进所有问题均已回答或跳过后Enter 会提交IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode``exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权HTTP 交付成功不会在本地移除待处理状态。
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale``question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该配置项,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。

View File

@@ -0,0 +1,113 @@
/* Plan-review takeover: the waiting-approval card language (amber strip on a
floating capsule, right-aligned actions) applied to a reviewed plan. Kept as
its own module rather than shared with ui-conversation's ApprovalPanel: the
two takeovers agree on tokens and geometry, not on content — this one's body
is scrollable markdown, that one's is a headline plus a command line. Warn
semantics ride the alias state tokens; no hardcoded colors. */
/* Mirrors the question card's frame so the takeover is a content swap. */
.frame {
display: flex;
justify-content: center;
padding: 6px 24px 10px;
}
.card {
display: flex;
overflow: hidden;
flex-direction: column;
width: 100%;
max-width: 776px;
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the plan, so the
strip and the decision row stay reachable on a long plan. */
max-height: min(60vh, 520px);
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
/* Elevated surface in dark: the plan body inside scrolls once the card hits
the cap above, so the thumb takes the l2 pair (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);
}
.card,
.card * {
box-sizing: border-box;
}
/* Tinted full-width header band, as on the approval takeover. */
.strip {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
padding: 10px 16px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 13px;
line-height: 18px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dsw-alias-state-warn-primary);
}
/* The plan is the panel's message: it takes the whole body and the scroll. */
.body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px 16px 4px;
font-size: 14px;
line-height: 22px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: 12px;
padding: 8px 16px 12px;
}
.feedback {
min-height: 16px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}
.actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
}
@media (max-width: 720px) {
.frame {
padding: 6px 10px 10px;
}
.card {
border-radius: 16px;
}
.body {
padding: 10px 12px 4px;
}
.footer {
align-items: flex-end;
padding: 8px 12px 10px;
}
}

View File

@@ -0,0 +1,100 @@
// PlanReviewPanel: the composer takeover for a question carrying the
// `plan-review` presentation intent. A plan under review is one decision over
// one body of markdown, so it takes the waiting-approval card shape — tinted
// strip, content, right-aligned action row — instead of the generic question
// flow's pager, numbered options, skip and custom-answer affordances, which
// read as a quiz the user is being graded on.
//
// The three actions are the whole decision surface: approve and decline answer
// the question with the option labels the asker offered (localised copy on the
// buttons, the asker's descriptions as their tooltips), while "discuss"
// dismisses the request so the composer returns and the user can simply say
// what they want. Dismissal is the generic flow's own cancel verb, promoted to
// a labelled button because in a two-outcome decision it is the third real
// answer, not an escape hatch.
import { useState } from 'react'
import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
import css from './PlanReviewPanel.module.css'
/** The panel's own props: the question domain face, the narrowed review, and the locale seat. */
export type PlanReviewPanelProps =
{ pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't'>
/**
* Optional-prop spread for a decision button's tooltip: `title` is optional on
* the DOM props, and exactOptionalPropertyTypes rejects an explicit undefined.
*
* @param description - the asker's option description, when it carries one.
* @returns The `title` prop to spread, or nothing.
*/
function tooltip(description: string | undefined): { title?: string } {
return description === undefined ? {} : { title: description }
}
/**
* Render a plan review as a decision card.
*
* @param props - the question domain face, the narrowed plan review, and `t`.
* @returns The plan-review takeover for this request.
*/
export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
// One-shot latch shaped like the approval takeover's: the panel leaves only
// when the host's resolved frame lands, so until then a second click must
// not re-fire. A failed send (rejected receipt / transport) re-arms it and
// shows why, since nothing else would tell the user the click was lost.
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const settle = (send: () => Promise<void>): void => {
setBusy(true)
setError(null)
void send().catch((cause: unknown) => {
setBusy(false)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
const decide = (label: string): void => {
settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] }))
}
const decline = review.decline
return (
<div className={css.frame} data-plan-review-key={pending.key}>
<section className={css.card} aria-label={review.question}>
<div className={css.strip}>
<span className={css.dot} />
{t('plan.header')}
</div>
<div className={css.body} data-plan-review-scroll>
<MarkdownText text={review.plan} />
</div>
<div className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.actions}>
<Button
size="sm" variant="ghost" icon={<IconEditOutline16 />}
disabled={busy} onClick={() => { settle(() => pending.cancel()) }}
>
{t('plan.discuss')}
</Button>
{decline !== undefined && (
<Button
size="sm" variant="outline" {...tooltip(decline.description)}
disabled={busy} onClick={() => { decide(decline.label) }}
>
{t('plan.decline')}
</Button>
)}
<Button
size="sm" variant="primary" {...tooltip(review.approve.description)}
disabled={busy} onClick={() => { decide(review.approve.label) }}
>
{t('plan.approve')}
</Button>
</div>
</div>
</section>
</div>
)
}

View File

@@ -5,9 +5,10 @@ import {
IconCloseOutline16, IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
PendingQuestion,
PendingQuestion, planReviewOf,
type QuestionAnswer, type QuestionComposerProps,
} from './contract/slots.ts'
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
@@ -46,14 +47,24 @@ function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement
/**
* Composer takeover boundary; the carrier key keys local drafts, so a
* same-request replay (same key, new carrier object) preserves them.
*
* One takeover, two shapes: a request that declares a presentation intent this
* package renders takes that shape (a plan review is one decision over one
* plan, not a question set), and every other request takes the generic flow.
* The routing lives here, at the one entry that owns the composer seat, so
* neither shape can claim a request the other is already rendering.
*
* @param props - the selector-matched pending question carrier plus the framework standard kit.
* @returns The question flow for this request.
* @returns The question flow, or the intent's own surface, for this request.
*/
export function QuestionComposer(props: QuestionComposerProps) {
// Domain-face mint rides the carrier's stable identity (never minted in a
// select/render dispatch — per-dispatch minting would churn memo identity).
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
return <QuestionFlow key={question.key} pending={question} t={props.t} />
const review = useMemo(() => planReviewOf(question.questions), [question])
return review === undefined
? <QuestionFlow key={question.key} pending={question} t={props.t} />
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
}
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {

View File

@@ -19,6 +19,71 @@ export type QuestionWait = PendingWait<'question'>
/** One structured answer batch covering every question of the request. */
export type QuestionAnswer = QuestionResponsePayload['answer']
/** One question of the request, as the carrier payload carries it. */
type QuestionItem = QuestionWait['payload']['questions'][number]
/** One option the asker offered on a question. */
type QuestionOption = NonNullable<QuestionItem['options']>[number]
/**
* A request narrowed to the `plan-review` presentation intent: everything the
* decision card renders and answers with, so the panel never re-reads the
* request shape. `approve` and `decline` are the asker's own options — an
* answer must carry one of those labels verbatim — and `plan` is the markdown
* body under review.
*/
export interface PlanReview {
/** The reviewed question's id, echoed in the answer. */
id: string
/** The question text, kept as the card's accessible name. */
question: string
/** The plan markdown under review. */
plan: string
/** The option that approves the plan. */
approve: QuestionOption
/** The option that declines it; absent when the asker offered no other option. */
decline?: QuestionOption
}
/**
* Narrow a request to a renderable plan review, or return undefined to leave it
* to the generic question flow.
*
* The card is one decision over one plan, and it claims a request only when it
* can send every answer that request allows — an intent changes the layout,
* never which answers are reachable. So the batch must be a single question
* that declares the intent, carries the plan as its detail, offers the approve
* label the intent names, and is a binary single choice: at most one option
* besides approve, and not multi-select. A third option or a multi-select batch
* has answers two buttons cannot express, so the generic flow keeps it — as it
* keeps any request whose intent the asker's own service would have rejected,
* because the client sits downstream of a wire boundary and every request must
* stay answerable.
*
* @param questions - the request's whole question batch.
* @returns The narrowed review, or undefined when the generic flow owns it.
*/
export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | undefined {
if (questions.length !== 1) return undefined
// Length-checked above; the index read is the narrowing tax, not a guess.
const question = questions[0] as QuestionItem
const intent = question.intent
if (intent?.kind !== 'plan-review' || question.detail === undefined) return undefined
if (question.multiSelect === true) return undefined
const options = question.options ?? []
if (options.length > 2) return undefined
const approve = options.find(option => option.label === intent.approve)
if (approve === undefined) return undefined
const decline = options.find(option => option.label !== intent.approve)
return {
id: question.id,
question: question.question,
plan: question.detail,
approve,
...(decline === undefined ? {} : { decline }),
}
}
/**
* Question domain face over the carrier: render identity and questions
* transparently forwarded; answer/cancel own the wire encoding (the ok value

View File

@@ -5,6 +5,12 @@
* question carrier (matched prop), and the whole behavior surface rides the
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
*
* One entry, two shapes: the composer renders a request that declares a
* presentation intent as that intent's own surface (`plan-review` → the plan
* decision card) and every other request as the generic question flow. A
* separate chain entry per shape would race the same carrier, so the shape
* choice lives inside this entry — see QuestionComposer.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -15,7 +21,9 @@ import { QuestionComposer } from './QuestionComposer.tsx'
import { en, zh, type QuestionKey } from './locales.ts'
export { PendingQuestion } from './contract/slots.ts'
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
export type {
PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait,
} from './contract/slots.ts'
export type { QuestionKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {

View File

@@ -11,6 +11,10 @@ export const zh = {
'custom.placeholder': '输入你的答案',
'action.skip': '跳过本题',
'action.next': '下一题',
'plan.header': '计划待审',
'plan.approve': '确认执行',
'plan.decline': '拒绝',
'plan.discuss': '去聊天里说',
} satisfies Record<string, string>
/** The question namespace key union. */
@@ -27,4 +31,8 @@ export const en = {
'custom.placeholder': 'Type your answer',
'action.skip': 'Skip this question',
'action.next': 'Next',
'plan.header': 'Plan review',
'plan.approve': 'Approve',
'plan.decline': 'Refuse',
'plan.discuss': 'Chat about it',
} satisfies Record<QuestionKey, string>

View File

@@ -0,0 +1,228 @@
// @vitest-environment jsdom
// The plan-review takeover, driven through the composer entry that routes to
// it: a request carrying the intent must reach the decision card and answer
// with the asker's own option labels, and a request that does not (or cannot)
// must keep the generic question flow.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { en, zh } from '../src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
(key => dict[key] ?? common[key] ?? key)
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
const kit = {
sessionId: SID,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
useProjection: (() => undefined) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
t: seatOver(zh, commonZh),
}
const PLAN = '# Ship the picker\n\n- read the store\n- render the rows\n'
/** The plan-mode request shape: one question, the plan as detail, approve named. */
const questions = (): QuestionWait['payload']['questions'] => [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: PLAN,
options: [
{ label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
],
intent: { kind: 'plan-review', approve: 'Approve' },
}]
/** Carrier fixture over a scripted respond carrier. */
function wait(
payload: QuestionWait['payload'] = { questions: questions() },
respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true })),
) {
return { carrier: new PendingWait('question', RpcId('q-1'), SID, payload, respond), respond }
}
/** The client-response envelope respond must have received for a decision. */
function decidedEnvelope(label: string) {
return {
type: 'client-response', rpcId: RpcId('q-1'),
result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } },
}
}
describe('planReviewOf', () => {
it('narrows a plan-review request to its decision, options included', () => {
expect(planReviewOf(questions())).toEqual({
id: 'plan-review',
question: 'Approve this plan and leave plan mode?',
plan: PLAN,
approve: { label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
decline: { label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
})
})
it('leaves the decline absent when the asker offered approve alone', () => {
const [question] = questions()
const review = planReviewOf([{ ...question as object, options: [{ label: 'Approve' }] } as never])
expect(review?.approve).toEqual({ label: 'Approve' })
expect(review === undefined ? true : 'decline' in review).toBe(false)
})
it.each([
['a batch of more than one question', () => [...questions(), ...questions()]],
['no intent at all', () => [{ ...questions()[0] as object, intent: undefined }]],
['an intent without the plan as detail', () => [{ ...questions()[0] as object, detail: undefined }]],
['an intent whose approve names no option', () => [{
...questions()[0] as object, intent: { kind: 'plan-review', approve: 'Ship it' },
}]],
['an intent with no options at all', () => [{ ...questions()[0] as object, options: undefined }]],
// Two buttons cannot send a third label or a combination, and the generic
// flow can: an intent never costs the user a reachable answer.
['a third option the card could not offer', () => [{
...questions()[0] as object,
options: [{ label: 'Approve' }, { label: 'Keep planning' }, { label: 'Start over' }],
}]],
['a multi-select decision', () => [{ ...questions()[0] as object, multiSelect: true }]],
])('declines %s, leaving the request to the generic flow', (_case, build) => {
expect(planReviewOf(build() as never)).toBeUndefined()
})
it('declines an empty batch, which the generic flow reports as such', () => {
expect(planReviewOf([])).toBeUndefined()
})
})
describe('PlanReviewPanel', () => {
it('renders the plan under a review strip, with none of the quiz affordances', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy()
expect(screen.getByText(zh['plan.header'])).toBeTruthy()
// The plan renders as markdown, so its heading is a heading.
expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy()
expect(screen.getByText('render the rows')).toBeTruthy()
// The question text stays as the card's accessible name rather than a title
// that reads like a test item.
expect(screen.getByLabelText('Approve this plan and leave plan mode?')).toBeTruthy()
// No pager, no numbered options, no skip, no custom answer.
expect(screen.queryByText('1 / 1')).toBeNull()
expect(screen.queryByRole('radio')).toBeNull()
expect(screen.queryByText(zh['action.skip'])).toBeNull()
expect(screen.queryByRole('textbox')).toBeNull()
})
it('answers with the asker\'s approve label and keeps its description as the tooltip', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
const approve = screen.getByRole('button', { name: zh['plan.approve'] })
expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.')
fireEvent.click(approve)
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Approve'))
// One-shot: every action locks until the host's resolved frame lands.
expect(approve.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true)
fireEvent.click(approve)
expect(respond).toHaveBeenCalledTimes(1)
})
it('answers with the asker\'s decline label', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] }))
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning'))
})
it('dismisses the request so the composer returns for a plain message', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(respond).toHaveBeenCalledWith({
type: 'client-response', rpcId: RpcId('q-1'),
result: {
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
},
})
})
it('omits the tooltip for an option carrying no description', () => {
const { carrier } = wait({ questions: [{
...questions()[0] as object,
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
}] as never })
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false)
})
it('hides the decline action when the asker offered approve alone', () => {
const { carrier } = wait({ questions: [{
...questions()[0] as object, options: [{ label: 'Approve' }],
}] as never })
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull()
expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy()
})
it('re-arms the actions and says why when the decision does not land', async () => {
const { carrier, respond } = wait(
{ questions: questions() },
vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: false, reason: 'not-pending' })),
)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
const failure = await screen.findByText('question response rejected: not-pending')
expect(failure.getAttribute('role')).toBe('status')
// Re-armed for the retry: a lost click must not leave a dead card.
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('disabled')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
expect(respond).toHaveBeenCalledTimes(2)
})
it('reports a non-Error transport failure as its stringified value', async () => {
// A non-Error rejection is the case under test: a carrier can reject with
// anything, and the panel must still show the user something.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone')))
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(await screen.findByText('socket gone')).toBeTruthy()
})
it('carries the same decision surface in English', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
expect(screen.getByText('Plan review')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Refuse' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Chat about it' })).toBeTruthy()
})
})

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