Merge remote-tracking branch 'origin/master' into dshw/pr-2250

This commit is contained in:
_Kerman
2026-08-11 22:33:12 +08:00
422 changed files with 15607 additions and 1120 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/client/README.md
README.md: a349b79783fae8fcfc9b3af7d3e96de779514d01
README.zh.md: 342442001bd268048de901233fc1913027b6d70f
README.md: 75abe408952ed66dcc237ce489e417f61159bcc3
README.zh.md: 5432efcb0a5ebc410093da4c3ec6c2e07c4520ca

View File

@@ -35,6 +35,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
| [`ui-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. |
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. |
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |

View File

@@ -35,6 +35,7 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 |
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 |
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |

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: 85ff46052ba2f032ee6a95b16c396d45e766d3ba
README.zh.md: 89cbb19a984d88e09b7af0890f57ecd15d46d3a5
README.md: a82bb55ab65df2732ad16248d2cc9aa15b60e94d
README.zh.md: d4c19db8e74e3562ec55b5ef60675aef79b82008

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) 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 carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) 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 carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
## /api browser-trust fence

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink配置类型。浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocket进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list``agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe`generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocket进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list``agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
## /api 浏览器信任栅栏

View File

@@ -1,4 +1,4 @@
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
* future `ctx.connection` plugin's Config). All fields optional; defaults below. */
@@ -45,7 +45,7 @@ export interface ConnectionSinks {
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
onConnected?: () => void
onConnected?: (description: HostDescription) => void
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
onStateChange?: (state: ConnectionState) => void
@@ -99,6 +99,11 @@ export class ConnectionController {
return this.running
}
/** Re-read both mutable liveness guards after a potentially reentrant sink. */
private isGenerationActive(controller: AbortController): boolean {
return this.isRunning() && !controller.signal.aborted
}
private async loop(): Promise<void> {
while (this.running) {
const gen = ++this.generation
@@ -143,7 +148,11 @@ export class ConnectionController {
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
this.attempt = 0
this.emitState('connected')
this.callSink(this.sinks.onConnected)
// A state sink may synchronously stop this controller. Do not publish
// a description for a generation that no longer exists afterward.
if (this.isGenerationActive(ac)) {
this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value) })
}
} catch {
// Transport failure: treat as generation failure, fall through to the shared backoff.
if (!ac.signal.aborted) ac.abort()
@@ -183,8 +192,7 @@ export class ConnectionController {
}
/** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
private callSink(fn: (() => void) | undefined): void {
if (fn === undefined) return
private callSink(fn: () => void): void {
try {
fn()
} catch (error) {

View File

@@ -2305,7 +2305,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
describe: request => ok(request, {
version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, canOpenPath: true,
}),
// Deterministic native pick: the keyless lanes drive the full
// pick-then-adopt path without an OS chooser (design-mock content,
// same tree the browse primitives serve).

View File

@@ -4,7 +4,7 @@
* controller with its sinks.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { IApiClient } from './api.ts'
import type { HostDescription, IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
@@ -41,6 +41,13 @@ export {
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
export type { ClientConnectionRpc } from '../rpc.ts'
/** Observable Host description published by each completed connection handshake. */
export interface HostDescriptionSource {
/** Latest connected-generation description; absent before connect and while reconnecting. */
getSnapshot(): HostDescription | undefined
/** Subscribe to description replacement and connection loss. */
subscribe(listener: () => void): () => void
}
/** Required services (none — this is the wire root). */
export const inject: string[] = []
@@ -55,6 +62,8 @@ export interface ConnectionHandle {
readonly api: IApiClient
/** Whether the current page authority is loopback; non-browser contexts default to true. */
readonly isLoopback: boolean
/** Generation-scoped Host facts, including native path-open capability. */
readonly hostDescription: HostDescriptionSource
/** Generic logical RPC channels over the same Connection transport. */
readonly rpc: ClientConnectionRpc
/**
@@ -79,16 +88,56 @@ export function apply(ctx: Context): void {
const api: IApiClient = fixtureClient ?? new WebApiClient()
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
let started = false
let description: HostDescription | undefined
const descriptionListeners = new Set<() => void>()
const publishDescription = (next: HostDescription | undefined): void => {
if (Object.is(description, next)) return
description = next
for (const listener of [...descriptionListeners]) {
try {
listener()
} catch (error) {
console.error('[web-runtime] host-description listener threw:', error)
}
}
}
const handle: ConnectionHandle = {
api,
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
hostDescription: {
getSnapshot: () => description,
subscribe: (listener) => {
descriptionListeners.add(listener)
return () => { descriptionListeners.delete(listener) }
},
},
rpc,
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true
const controller = new ConnectionController(api, sinks, config ?? {})
const controller = new ConnectionController(api, {
...sinks,
onConnected: (next) => {
publishDescription(next)
// A description subscriber may synchronously stop the loop. In that
// case publishDescription(undefined) has already retracted this
// generation, so do not leak its stale connected notification to
// the consumer sink afterward.
if (!Object.is(description, next)) return
sinks.onConnected?.(next)
},
onStateChange: (state) => {
if (state === 'reconnecting') publishDescription(undefined)
sinks.onStateChange?.(state)
},
}, config ?? {})
controller.start()
return { stop: () => { controller.stop() } }
return {
stop: () => {
controller.stop()
publishDescription(undefined)
},
}
},
}
ctx.provide('connection', handle)

View File

@@ -87,10 +87,87 @@ describe('connection client apply', () => {
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
// config omitted: the `config ?? {}` default arm is part of the API.
const loop = handle.start({})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const descriptions: Array<boolean | undefined> = []
const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') })
const stopDescription = handle.hostDescription.subscribe(() => {
descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath)
})
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
// config omitted: the `config ?? {}` default arm is part of the surface.
let connected = 0
const loop = handle.start({ onConnected: () => { connected++ } })
expect(() => handle.start({})).toThrow(/already owned by another consumer/)
await vi.waitFor(() => {
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
})
loop.stop() // teardown must not throw; the fixture streams abort quietly
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
expect(descriptions).toEqual([true, undefined])
expect(connected).toBe(1)
expect(errorSpy).toHaveBeenCalledTimes(2)
stopThrowing()
stopDescription()
errorSpy.mockRestore()
})
it('does not announce a generation synchronously stopped by a description subscriber', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
let sawDescription = false
const stopDescription = handle.hostDescription.subscribe(() => {
if (handle.hostDescription.getSnapshot() === undefined) return
sawDescription = true
owner.loop?.stop()
})
const connected = vi.fn()
const loop = handle.start({ onConnected: connected })
owner.loop = loop
try {
await vi.waitFor(() => { expect(sawDescription).toBe(true) })
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
expect(connected).not.toHaveBeenCalled()
} finally {
stopDescription()
loop.stop()
}
})
it('retracts the host description while reconnecting and republishes the next generation', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
const descriptions: Array<boolean | undefined> = []
const reconnectSnapshots: Array<boolean | undefined> = []
const stopDescription = handle.hostDescription.subscribe(() => {
descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath)
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const loop = handle.start({
onStateChange: (state) => {
if (state === 'reconnecting') {
reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath)
}
},
}, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 })
try {
await vi.waitFor(() => {
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
})
const timing = (globalThis as Record<string, unknown>).__fxTiming as
| { breakStreams(): void }
| undefined
if (timing === undefined) throw new Error('fixture timing hooks missing')
timing.breakStreams()
await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) })
await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) })
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
} finally {
stopDescription()
loop.stop()
warnSpy.mockRestore()
}
})
it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {

View File

@@ -23,10 +23,14 @@ describe('connection lifecycle', () => {
it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
const api = new FakeApiClient()
const muxSeen: string[] = []
const descriptions: boolean[] = []
let connected = 0
const controller = new ConnectionController(api, {
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
onConnected: () => { connected++ },
onConnected: (description) => {
connected++
descriptions.push(description.canOpenPath)
},
}, FAST)
controller.start()
try {
@@ -34,6 +38,7 @@ describe('connection lifecycle', () => {
api.pushMux(subscribedFrame())
await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
expect(api.callsOf('host.describe')).toHaveLength(1)
expect(descriptions).toEqual([true])
} finally {
controller.stop()
}
@@ -75,7 +80,7 @@ describe('connection lifecycle', () => {
try {
await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
expect(connected).toBe(0) // never announced during the failed generation
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
await vi.waitFor(() => { expect(connected).toBe(1) })
} finally {
controller.stop()
@@ -97,7 +102,7 @@ describe('connection lifecycle', () => {
},
})
}
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
}
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -175,6 +180,38 @@ describe('connection lifecycle', () => {
}
})
it('rejects a generation whose streams end during readiness and retries', async () => {
const api = new FakeApiClient()
const firstDescribe = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
let describeCalls = 0
api.onDescribe = () => {
describeCalls++
return describeCalls === 1
? firstDescribe.promise
: Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
}
const states: ConnectionState[] = []
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onStateChange: state => states.push(state),
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) })
api.endStreams()
firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
await vi.waitFor(() => { expect(describeCalls).toBe(2) })
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['reconnecting', 'connected'])
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => {
const api = new FakeApiClient()
api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires
@@ -210,6 +247,24 @@ describe('connection lifecycle', () => {
}
})
it('does not announce a generation stopped synchronously by its connected state sink', async () => {
const api = new FakeApiClient()
const states: ConnectionState[] = []
let connected = 0
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onStateChange: (state) => {
states.push(state)
if (state === 'connected') controller.stop()
},
}, FAST)
controller.start()
await vi.waitFor(() => { expect(states).toEqual(['connected']) })
await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
expect(connected).toBe(0)
})
it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
@@ -228,7 +283,7 @@ describe('connection lifecycle', () => {
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
} finally {

View File

@@ -71,8 +71,15 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{
version: string
cwd: string
attachedSessions: number
canOpenPath: boolean
}>> =
() => Promise.resolve(ok({
version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
}))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =

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: cfc612a445d1b673bffb4499c54fc612c15daa92
README.zh.md: 0dc6136a7ebb4db5c3fc514f029bc255b55042aa
README.md: 44fd9b84e45c0a4d7f5846ce9ba040ef41b8b446
README.zh.md: 7c5a70ef5d032fab8d3b75e84de6608b43f2e294

View File

@@ -4,7 +4,10 @@ English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. 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 Session and Workspace owners and hands each generic `host/remote-event` frame to `ctx.remote.$dispatch`; domain packages subscribe to their owner events through `ctx.remote.$on` and decide which caches or session rows they invalidate. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
For each prompt that can reach a local root or continuable child Agent, the runtime samples the browser's current `Intl.DateTimeFormat().resolvedOptions().timeZone` and attaches it to that one Session or subagent prompt RPC. It is neither cached nor included in Session creation or fork state, so travel and concurrent tabs keep message-local provenance. A browser that cannot provide a non-empty zone fails the prompt locally instead of silently substituting deployment state.
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, the composition `base` and raw `user` layers, revision, writability, host/memory mode), serializes `set` and `unset` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. A field is overridden when it is PRESENT in `user` — an override equal to the composition default is still an override, which comparing values could not see — and `unset` is how a form clears one back to `base`. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
## Slot declaration injection
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.

View File

@@ -4,7 +4,10 @@
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把每个通用 `host/remote-event` 帧交给 `ctx.remote.$dispatch`;各领域包通过 `ctx.remote.$on` 订阅自身 owner 事件,并自行决定使哪些缓存或会话行失效。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
`bindSettingsScope` 面向单个由领域持有的 namespace是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照状态、分节值、revision、可写性、host内存模式使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有而非把产品政策放入运行时
对于每条可到达本地根 Agent 或可继续子 Agent 的提示词,运行时都会采样浏览器当前的 `Intl.DateTimeFormat().resolvedOptions().timeZone`,并只把该值附加到这一次 Session 或 subagent 提示词 RPC。该值既不缓存也不包含在 Session 创建或 fork 状态中,因此旅行与并发标签页都能保留消息本地的来源信息。浏览器若无法提供非空时区,会在本地拒绝该提示词,而不会悄然使用部署状态代替
`bindSettingsScope` 面向单个由领域持有的 namespace是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、组装 `base` 层与原始 `user` 层、revision、可写性、host内存模式使用已知最新 namespace revision 串行执行 `set``unset` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API远程页面则停留在内存模式。字段是否被覆盖取决于它是否**出现**在 `user` 中——与组装默认值相同的覆盖仍然是覆盖,比较值是看不出来的——而 `unset` 就是表单把某个字段清回 `base` 的方式。namespace schema、默认值与实时服务归领域包所有而非把产品政策放入运行时。
## Slot 声明注入
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose资源释放回调 effect重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。

View File

@@ -17,6 +17,17 @@ export interface SettingsScopeSnapshot<T> {
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/**
* Composition layer the Host resolved {@link value} over, when the owning
* plugin declared one. What a field reverts to once cleared.
*/
base: unknown
/**
* Raw user layer as stored, when one exists. A field's PRESENCE here is what
* marks it overridden — an override whose value equals the composition
* default is still an override, and comparing values could not see it.
*/
user: unknown
/** Namespace revision fencing the next write; undefined before the first Host view. */
revision: number | undefined
/** Whether the Host document accepts writes; memory mode never does. */
@@ -60,4 +71,11 @@ export interface SettingsScope<T> {
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
/**
* Queue one field clear, so the field re-inherits the composition layer.
* Shares {@link set}'s ordering, revision, and recovery contract.
* @param field - scalar field inside the namespace section.
* @returns settlement after the clear and any latest-write recovery read.
*/
unset(field: string): Promise<void>
}

View File

@@ -23,6 +23,7 @@ import { PendingWait } from './pending.ts'
import { Notifier } from './notifier.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { resolvedClientTimeZone } from '../time-zone.ts'
import { SessionQueueMirror } from './queue-mirror.ts'
/** Messages requested per history page. */
@@ -194,7 +195,12 @@ export class Session implements SessionFace {
let result: RpcResult<{ accepted: true }>
try {
if (this.address === undefined) {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
result = (await this.api.sessions.prompt({
sessionId: this.sessionId,
mode,
content,
clientTimeZone: resolvedClientTimeZone(),
})).result
} else if (this.address.mode === 'one-shot') {
result = {
ok: false,
@@ -220,6 +226,7 @@ export class Session implements SessionFace {
content: content.flatMap(part => part.type === 'text'
? [{ type: 'text' as const, text: part.text }]
: []),
clientTimeZone: resolvedClientTimeZone(),
})).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}

View File

@@ -0,0 +1,14 @@
/** Browser-owned time-zone sampling for prompt RPC provenance. */
/**
* Resolve the current browser IANA zone for one outbound operation.
* @returns The browser-provided canonical zone.
* @throws when the runtime cannot provide a non-empty zone.
*/
export function resolvedClientTimeZone(): string {
const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone
if (typeof timeZone !== 'string' || timeZone.length === 0) {
throw new Error('browser time zone is unavailable')
}
return timeZone
}

View File

@@ -31,6 +31,10 @@ async function mount(): Promise<Bench> {
const handle: ConnectionHandle = {
api,
isLoopback: true,
hostDescription: {
getSnapshot: () => undefined,
subscribe: () => () => {},
},
rpc: {
call: () => Promise.reject(new Error('unexpected generic RPC call')),
},
@@ -86,7 +90,7 @@ describe('runtime client apply', () => {
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onConnected?.()
bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
})
it('selects the recent Workspace once when the first baselines have no current session', async () => {
@@ -99,7 +103,7 @@ describe('runtime client apply', () => {
}))
bench.api.onList = () => Promise.resolve(ok({ items: [] }))
bench.sinks?.onConnected?.()
bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
await flushMicrotasks()
const sessions = bench.ctx.get('sessions') as SessionsService

View File

@@ -90,8 +90,15 @@ export class FakeApiClient implements IApiClient {
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{
version: string
cwd: string
attachedSessions: number
canOpenPath: boolean
}>> =
() => Promise.resolve(ok({
version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
}))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =

View File

@@ -374,6 +374,7 @@ describe('subagent catalogs', () => {
{
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
content: [{ type: 'text', text: 'continue' }],
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
},
])
expect(api.callsOf('session.history')).toEqual([])

View File

@@ -465,6 +465,7 @@ describe('prompt and cancel errors', () => {
{
parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
content: [{ type: 'text', text: '继续' }],
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
},
])
expect(api.callsOf('subagent.interrupt')).toEqual([
@@ -530,7 +531,12 @@ describe('prompt and cancel errors', () => {
expect(result.ok).toBe(true)
// Monotone: settlement alone does not step the phase anywhere.
expect(session.getSnapshot().composerPhase).toBe('engaging')
expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
expect(api.callsOf('session.prompt')).toMatchObject([{
sessionId: SID,
mode: 'queue',
content: [{ type: 'text', text: '要发的' }],
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
}])
// First content lands (running turn): engaging → active.
session.handleRunning(true)
expect(session.getSnapshot().composerPhase).toBe('active')

View File

@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolvedClientTimeZone } from '../src/client/time-zone.ts'
afterEach(() => {
vi.restoreAllMocks()
})
describe('browser time zone', () => {
it('returns the runtime-resolved zone', () => {
expect(resolvedClientTimeZone()).toBe(
new Intl.DateTimeFormat().resolvedOptions().timeZone,
)
})
it.each([undefined, ''])('fails loud when the runtime exposes no zone %#', (timeZone) => {
const options = new Intl.DateTimeFormat().resolvedOptions()
vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({
...options,
timeZone: timeZone as string,
})
expect(() => resolvedClientTimeZone()).toThrow('browser time zone is unavailable')
})
})

View File

@@ -61,6 +61,10 @@ async function mount(): Promise<Bench> {
const handle: ConnectionHandle = {
api,
isLoopback: true,
hostDescription: {
getSnapshot: () => undefined,
subscribe: () => () => {},
},
rpc: {
call: () => Promise.reject(new Error('unexpected generic RPC call')),
},
@@ -122,8 +126,9 @@ describe('wire event bridge', () => {
const bench = await mount()
let resets = 0
bench.ctx.on('connection/reset', () => { resets++ })
bench.sinks?.onConnected?.()
bench.sinks?.onConnected?.() // second generation after a reconnect
const description = { version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }
bench.sinks?.onConnected?.(description)
bench.sinks?.onConnected?.(description) // second generation after a reconnect
expect(resets).toBe(2)
})
})

View File

@@ -8,6 +8,8 @@ export interface StubSettingsScope<T> {
scope: SettingsScope<T>
/** Spy behind `scope.set`; resolves immediately. */
set: ReturnType<typeof vi.fn>
/** Spy behind `scope.unset`; resolves immediately. */
unset: ReturnType<typeof vi.fn>
/** @returns how many listeners are currently subscribed (disposal assertions). */
listenerCount(): number
/**
@@ -25,10 +27,12 @@ export interface StubSettingsScope<T> {
*/
export function stubSettingsScope<T>(): StubSettingsScope<T> {
let snapshot: SettingsScopeSnapshot<T> = {
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
status: 'loading', value: undefined, base: undefined, user: undefined,
revision: undefined, writable: false, mode: 'host',
}
const listeners = new Set<() => void>()
const set = vi.fn(() => Promise.resolve())
const unset = vi.fn(() => Promise.resolve())
return {
scope: {
getSnapshot: () => snapshot,
@@ -37,8 +41,10 @@ export function stubSettingsScope<T>(): StubSettingsScope<T> {
return () => { listeners.delete(listener) }
},
set,
unset,
},
set,
unset,
listenerCount: () => listeners.size,
publish: (next) => {
snapshot = { ...snapshot, ...next }

View File

@@ -7,6 +7,7 @@
* stack — this suite is the fixture the migrated feature specs rely on.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { stubSettingsScope } from '../src/settings-scope.ts'
import { cleanup } from '@testing-library/react'
import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -640,3 +641,32 @@ describe('single-slot mounting edge arms', () => {
await runtime.dispose()
})
})
describe('stubbed settings scope', () => {
it('records both write kinds and publishes a Host acceptance to its listeners', async () => {
const host = stubSettingsScope<{ preference: string }>()
let notified = 0
const stop = host.scope.subscribe(() => { notified += 1 })
expect(host.listenerCount()).toBe(1)
expect(host.scope.getSnapshot()).toMatchObject({
status: 'loading', base: undefined, user: undefined,
})
await host.scope.set('preference', 'dark')
await host.scope.unset('preference')
host.publish({
status: 'ready',
value: { preference: 'system' },
base: { preference: 'system' },
revision: 2,
writable: true,
})
expect(host.set).toHaveBeenCalledWith('preference', 'dark')
expect(host.unset).toHaveBeenCalledWith('preference')
expect(notified).toBe(1)
expect(host.scope.getSnapshot()).toMatchObject({ status: 'ready', revision: 2, writable: true })
stop()
expect(host.listenerCount()).toBe(0)
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
README.md: 7d03e5faedda3ba8c9cc4cab6ca134d98dc7ec13
README.zh.md: dfbbc7a39aa94aab438119a4f23ffb02da2daa3d
README.md: 9cfcde9510c5d6814d5b42fd58a0520ea37b8679
README.zh.md: 94dba9bb38725ddb3ef39d55a5f8a185a5008d18

View File

@@ -6,7 +6,7 @@ Produced-files feature owner: registers the deliverables row a finished turn end
`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row.
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label and one measured file lane. It shows the largest leading prefix that fits (up to six chips; basename text, full path as the `title`) while reserving the exact localized `+ N files` width, so the remainder stays visible without wrapping or horizontal scrolling. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. When files are hidden, a second-line **Show in folder** action opens the session workspace through that same owner path only while the page is loopback and the current Host handshake reports `canOpenPath`; direct remote Web and headless/container Linux Hosts omit the action by default. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md).
@@ -21,3 +21,4 @@ None; this package neither assembles nor sends provider requests.
## Known Limitations and Deferred Work
- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it.
- **Native folder handoff targets the Host desktop.** A browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener. SSH forwarding that makes a remote Host look loopback-local must set the gateway's `nativeOpen: false`; so must a headless macOS/Windows Host, a WSL deployment without working Windows interop, or any Linux desktop whose display/opener probe is a false positive. Identifying the operator-visible desktop remains deployment policy.

View File

@@ -6,7 +6,7 @@
`deliverablesDefinition` 把每个 Turn 中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData``producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`而不是收尾正文无论模型是否记得点名产出文件都会被列出。修改操作按渲染意图而非工具名识别diff 卡片,或 `kind``edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个 Turn 内按首见顺序只出现一次。Conversation Location 索引拥有 Turn 成员关系,因此一个 Turn 即使先修改文件、随后没有正文内容就结束,也不会溢进下一个 Turn 的行里。
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签至多六个标签项文本为文件名,完整路径作为 `title`超出上限则显示一个明确的剩余计数。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签和一条经过测量的单行文件 lane。它展示能够放下的最大前缀至多六个标签项文本为文件名,完整路径作为 `title`并为本地化后的精确 `+ N 个文件` 宽度预留空间,因此剩余计数始终可见,既不换行也不横向滚动。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器chat 视图会把相对路径按会话 cwd 解析。存在隐藏文件时,第二行的**在文件夹中显示**也经由同一属主路径打开会话 workspace它只在页面使用 loopback 且当前 Host 握手报告 `canOpenPath` 时出现,直接远程 Web 与 headless容器 Linux Host 默认均省略该操作。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
收尾正文承载同一份词表。本插件提供供 chat 视图按收尾消息查询的 `chatFileMentions` 服务:`producedFileMentions` 按精确路径解析行内代码 token或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享同一 basename 时,文本保持不可点击而不作猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留代码标签并采用 Markdown 样式表的链接样式:静止时为链接蓝色,悬停时显示下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在链接内部或流式文本中。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。
@@ -21,3 +21,4 @@
## 已知限制与暂缓事项
- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持不可点击;等真实的收尾消息形态产生需求后再放宽匹配规则。
- **原生文件夹交接以 Host 桌面为目标。**经非 loopback 权威访问的浏览器会省略该操作,报告没有原生打开器的部署也一样。若 SSH 转发让远端 Host 看似处于本机 loopback部署必须为网关设置 `nativeOpen: false`;无界面的 macOSWindows Host、Windows interop 不可用的 WSL或 displayopener 探测误报的 Linux 桌面也必须这样配置。识别操作者实际可见的桌面仍属于部署策略。

View File

@@ -32,6 +32,7 @@
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
@@ -48,6 +49,7 @@
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -56,6 +58,7 @@
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",

View File

@@ -1,24 +1,39 @@
/* Turn-tail produced-files row: a quiet label followed by wrapping file chips.
Sits between the assistant body and its IconActions footer, so it reads as
part of the answer rather than as another tool row. */
/* Turn-tail produced-files summary: one measured chip lane plus an optional
native-folder action below it. */
.root {
display: flex;
flex-wrap: wrap;
position: relative;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
gap: 8px;
column-gap: 8px;
row-gap: 6px;
margin-top: 16px;
font-size: 13px;
line-height: 22px;
}
.label {
grid-column: 1;
grid-row: 1;
color: var(--dsw-alias-label-tertiary);
}
.row {
grid-column: 2;
grid-row: 1;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
}
/* One produced file. A link by behavior (it opens the file), a chip by shape:
full paths are long and several may wrap onto one row. */
full paths are long, while the measured lane stays on one row. */
.file {
flex: 0 0 auto;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
@@ -38,7 +53,53 @@
text-decoration: underline;
}
.file:focus-visible,
.showFolder:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3);
}
/* Overflow count: the row never silently drops files it did not show. */
.more {
flex: 0 0 auto;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
.showFolder {
grid-column: 2;
grid-row: 2;
justify-self: start;
margin: 0;
padding: 0 2px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: inherit;
line-height: 20px;
cursor: pointer;
}
.showFolder:hover {
color: var(--dsw-alias-label-secondary);
text-decoration: underline;
}
/* Exact browser-native probes for every candidate shown count. They share the
visible styles but never affect layout, accessibility, or scroll width. */
.measure {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
contain: strict;
}
.probe {
position: absolute;
inset: 0 auto auto 0;
width: max-content;
}

View File

@@ -4,46 +4,153 @@
// through the same openFile the tool rows use — the Host's own opener, on the
// Host machine.
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { useLayoutEffect, useRef, useState } from 'react'
import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { basename } from './turn-deliverables.ts'
import type { NS } from './locales.ts'
import css from './ProducedFiles.module.css'
/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */
const SHOWN = 6
/** At most six chips compete for the one-line summary; every other path stays counted. */
const SHOWN_LIMIT = 6
/** Matched paths plus the opener and locale seats needed to present them. */
/**
* Select the largest prefix whose measured chips and exact remainder fit.
* @param available - usable width of the one-line file lane.
* @param gap - computed flex gap between adjacent visible items.
* @param chipWidths - measured widths for the candidate file chips.
* @param moreWidthsByShown - exact localized remainder width for each shown count.
* @returns Number of leading chips to render.
*/
export function fitProducedFiles(
available: number,
gap: number,
chipWidths: readonly number[],
moreWidthsByShown: readonly (number | undefined)[],
): number {
if (available <= 0) return chipWidths.length
const prefix = [0]
let prefixWidth = 0
for (const width of chipWidths) {
prefixWidth += width
prefix.push(prefixWidth)
}
let largestFit = 0
for (const [shown, width] of prefix.entries()) {
const more = moreWidthsByShown[shown]
const items = shown + (more === undefined ? 0 : 1)
const needed = width + (more ?? 0) + Math.max(0, items - 1) * gap
if (needed <= available) largestFit = shown
}
return largestFit
}
/** Registration-side Host capability facts. */
export interface ProducedFilesInjected {
/** Whether the browser itself is connected over loopback. */
isLoopback: boolean
hooks: {
/** Current generation's Host description, bound by the slot renderer. */
hostDescription: HostDescriptionSource
}
}
/** Matched paths plus the opener, locale, and injected Host capability. */
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
matched: readonly string[]
} & PropsLocale<typeof NS>
} & PropsLocale<typeof NS> & InjectFace<ProducedFilesInjected>
function moreLabel(t: ProducedFilesProps['t'], count: number): string {
return count === 1 ? t('produced.moreOne') : t('produced.more', { count: String(count) })
}
/**
* Render one turn's produced files as openable chips.
* @param props - selector-matched paths, the chat view's file opener, and the locale seat.
* @returns The produced-files row.
*/
export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) {
const shown = paths.slice(0, SHOWN)
export function ProducedFiles({
matched: paths, openFile, isLoopback, useHostDescription, t,
}: ProducedFilesProps) {
const hostCanOpenPath = useHostDescription(description => description?.canOpenPath === true)
const canOpenPath = isLoopback && hostCanOpenPath
const limit = Math.min(paths.length, SHOWN_LIMIT)
const [shownCount, setShownCount] = useState(limit)
const rowRef = useRef<HTMLDivElement>(null)
const chipProbes = useRef<Array<HTMLButtonElement | null>>([])
const moreProbe = useRef<HTMLSpanElement>(null)
useLayoutEffect(() => {
const row = rowRef.current
const remainderProbe = moreProbe.current
/* v8 ignore next -- React attaches both refs before the layout effect runs. */
if (row === null || remainderProbe === null) return
const measure = (): void => {
const styles = getComputedStyle(row)
const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0
// React attaches every still-mounted callback ref before layout effects run.
const activeChipProbes = chipProbes.current.slice(0, limit) as HTMLButtonElement[]
const chips = activeChipProbes.map(probe => probe.getBoundingClientRect().width)
const more = Array.from({ length: limit + 1 }, (_, candidate) => {
if (paths.length === candidate) return undefined
remainderProbe.textContent = moreLabel(t, paths.length - candidate)
return remainderProbe.getBoundingClientRect().width
})
setShownCount(fitProducedFiles(row.clientWidth, gap, chips, more))
}
measure()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(row)
for (const probe of [...chipProbes.current, moreProbe.current]) {
if (probe !== null) observer.observe(probe)
}
return () => { observer.disconnect() }
}, [limit, paths, t])
const visibleCount = Math.min(shownCount, limit)
const shown = paths.slice(0, visibleCount)
const hidden = paths.length - shown.length
return (
<div className={css.root}>
<span className={css.label}>{t('produced.label')}</span>
{shown.map(path => (
<button
key={path}
type="button"
className={css.file}
// The full path is the disambiguator when two turns produce files
// that share a basename; the chip itself stays short.
title={path}
aria-label={t('produced.open', { name: path })}
onClick={() => { openFile(path) }}
>
{basename(path)}
<div ref={rowRef} className={css.row} data-produced-files-row>
{shown.map(path => (
<button
key={path}
type="button"
className={css.file}
// The full path is the disambiguator when two turns produce files
// that share a basename; the chip itself stays short.
title={path}
aria-label={t('produced.open', { name: path })}
onClick={() => { openFile(path) }}
>
{basename(path)}
</button>
))}
{hidden > 0 && <span className={css.more}>{moreLabel(t, hidden)}</span>}
</div>
{hidden > 0 && canOpenPath && (
<button type="button" className={css.showFolder} onClick={() => { openFile('.') }}>
{t('produced.showInFolder')}
</button>
))}
{hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>}
)}
<div className={css.measure} aria-hidden="true">
{paths.slice(0, limit).map((path, index) => (
<button
key={path}
ref={(node) => { chipProbes.current[index] = node }}
type="button"
tabIndex={-1}
className={`${css.file} ${css.probe}`}
>
{basename(path)}
</button>
))}
<span ref={moreProbe} className={`${css.more} ${css.probe}`} />
</div>
</div>
)
}

View File

@@ -7,6 +7,7 @@
* composing this plugin out of cordis.yml removes both surfaces entirely;
* the owning view renders an empty chain and inert prose at zero cost.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -27,13 +28,14 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
export { producedForClosing } from './turn-deliverables.ts'
/** Required services for the tail-slot registration and its dictionaries. */
export const inject = ['slots', 'locale', 'conversationEvents']
export const inject = ['slots', 'locale', 'conversationEvents', 'connection']
/**
* Client plugin body: register the dictionaries and the turn-tail entry.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const connection = ctx.get('connection') as ConnectionHandle
ctx.conversationEvents.register(deliverablesDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
ctx.slots.inject(
@@ -42,6 +44,10 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.chat.turnTail',
select: selectProducedFiles,
locale: NS,
inject: () => ({
isLoopback: connection.isLoopback,
hooks: { hostDescription: connection.hostDescription },
}),
}, ProducedFiles),
)
// The prose side of the same vocabulary: the chat view reaches this face

View File

@@ -6,15 +6,19 @@ export const NS = 'deliverables'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'produced.label': '产物',
'produced.more': '还有 {count} 个',
'produced.moreOne': '+ 1 个文件',
'produced.more': '+ {count} 个文件',
'produced.open': '打开 {name}',
'produced.showInFolder': '在文件夹中显示',
}
/** English dictionary (same key set). */
export const en: Record<DeliverablesKey, string> = {
'produced.label': 'Produced',
'produced.more': '{count} more',
'produced.moreOne': '+ 1 file',
'produced.more': '+ {count} files',
'produced.open': 'Open {name}',
'produced.showInFolder': 'Show in folder',
}
/** Union of this namespace's dictionary keys. */

View File

@@ -6,7 +6,7 @@
* (HMR safety) against the real SlotsService.
*/
import { Context } from '@deepseek-ai/cordis'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
@@ -19,7 +19,9 @@ import type {
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
import {
fitProducedFiles, ProducedFiles, type ProducedFilesProps,
} from '../src/client/ProducedFiles.tsx'
import {
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
type DeliverablesTurnData,
@@ -27,9 +29,20 @@ import {
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
import { apply as applyInvariant } from '../src/invariant.ts'
import { zh } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
if (originalClientWidth === undefined) {
delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth
} else {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
}
})
class TestTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
private readonly values = new Map<string, unknown>()
@@ -268,22 +281,132 @@ describe('produced-file Turn data', () => {
describe('ProducedFiles row', () => {
const t = makeTranslate(zh)
const capability = (
canOpenPath: boolean | undefined,
isLoopback = true,
): Pick<ProducedFilesProps, 'isLoopback' | 'useHostDescription'> => {
const description = canOpenPath === undefined
? undefined
: { version: 'test', cwd: '/workspace', attachedSessions: 1, canOpenPath }
return {
isLoopback,
useHostDescription: selector => selector(description),
}
}
it('renders capped chips with the full path reachable and opens one on click', () => {
// Seven files: six chips plus an explicit remainder — the row bounds what
// it shows and says so rather than dropping the rest silently.
it('selects the largest prefix using the exact remainder width', () => {
expect(fitProducedFiles(230, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(2)
expect(fitProducedFiles(145, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(1)
expect(fitProducedFiles(300, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(3)
// A zero-width lane is a pre-layout test/hidden state, not evidence that
// every chip overflowed; keep the bounded initial prefix until measured.
expect(fitProducedFiles(0, 8, [70, 60], [60, 50, undefined])).toBe(2)
expect(fitProducedFiles(128, 8, [60, 60], [70, 50, undefined])).toBe(2)
// Candidate-specific suffix widths matter at the 10 -> 9 digit boundary.
expect(fitProducedFiles(126, 8, [60], [70, 50])).toBe(1)
expect(fitProducedFiles(20, 8, [60], [70, 50])).toBe(0)
})
it('keeps one measured line, updates on resize, and opens a file or the workspace folder', () => {
const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts']
const openFile = vi.fn<(path: string) => void>()
const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
let available = 226
let resize: ResizeObserverCallback | undefined
const disconnect = vi.fn()
const observeNode = vi.fn<(target: Element) => void>()
vi.stubGlobal('ResizeObserver', class {
constructor(callback: ResizeObserverCallback) { resize = callback }
observe(target: Element): void {
expect(target).toBeInstanceOf(Element)
observeNode(target)
}
disconnect(): void { disconnect() }
})
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get(this: HTMLElement) { return this.hasAttribute('data-produced-files-row') ? available : 0 },
})
const rect = (width: number): DOMRect => ({
x: 0, y: 0, width, height: 22, top: 0, right: width, bottom: 22, left: 0,
toJSON: () => ({}),
})
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getProbeRect(this: HTMLElement) {
if (this.closest('[aria-hidden="true"]') === null) return rect(0)
if (this.tagName !== 'BUTTON') return rect(60)
return rect(this.textContent === 'a.html' || this.textContent === 'b.css' ? 50 : 100)
})
const view = render(
<ProducedFiles matched={paths} openFile={openFile} {...capability(true)} t={t} />,
)
expect(view.getByText('产物')).toBeTruthy()
// Chips carry the basename; the full path stays reachable as the title.
const row = view.container.querySelector('[data-produced-files-row]')
if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
// The third probe is 100px: two chips plus the remainder fit, three do not.
expect(within(row).getAllByRole('button')).toHaveLength(2)
expect(within(row).getByText('+ 5 个文件')).toBeTruthy()
const chip = view.getByRole('button', { name: '打开 deep/a.html' })
expect(chip.textContent).toBe('a.html')
expect(chip.getAttribute('title')).toBe('deep/a.html')
expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull()
expect(view.getByText('还有 1 个')).toBeTruthy()
fireEvent.click(chip)
expect(openFile).toHaveBeenCalledWith('deep/a.html')
const showFolder = view.getByRole('button', { name: '在文件夹中显示' })
fireEvent.click(showFolder)
expect(openFile).toHaveBeenLastCalledWith('.')
available = 150
act(() => { resize?.([], {} as ResizeObserver) })
expect(within(row).getAllByRole('button')).toHaveLength(1)
expect(within(row).getByText('+ 6 个文件')).toBeTruthy()
// A missing/unsupported computed gap falls back to zero rather than NaN.
vi.stubGlobal('getComputedStyle', () => ({ columnGap: '', gap: '' } as CSSStyleDeclaration))
available = 165
act(() => { resize?.([], {} as ResizeObserver) })
expect(within(row).getAllByRole('button')).toHaveLength(2)
// Ref callbacks leave nulls in the probe arrays when the candidate set
// shrinks; the replacement observer must skip those stale slots.
observeNode.mockClear()
view.rerender(
<ProducedFiles matched={paths.slice(0, 1)} openFile={openFile} {...capability(true)} t={t} />,
)
expect(within(row).getAllByRole('button')).toHaveLength(1)
expect(observeNode).toHaveBeenCalledTimes(3)
view.unmount()
expect(disconnect).toHaveBeenCalledTimes(2)
bounds.mockRestore()
})
it('keeps the folder action absent without overflow or a local native opener', () => {
const openFile = vi.fn<(path: string) => void>()
const view = render(
<ProducedFiles matched={['a.md']} openFile={openFile} {...capability(true)} t={t} />,
)
const overflowing = ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']
expect(view.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
for (const unavailable of [capability(false), capability(true, false), capability(undefined)]) {
view.rerender(<ProducedFiles matched={overflowing} openFile={openFile} {...unavailable} t={t} />)
expect(view.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
}
})
it('uses singular English copy when exactly one file is hidden', () => {
const view = render(
<ProducedFiles
matched={['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']}
openFile={() => {}}
{...capability(false)}
t={makeTranslate(en)}
/>,
)
const row = view.container.querySelector('[data-produced-files-row]')
if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
expect(within(row).getByText('+ 1 file')).toBeTruthy()
})
})
@@ -340,7 +463,12 @@ describe('plugin registration', () => {
name: 'root',
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
} as never, () => null)
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
const hostDescription = { getSnapshot: () => undefined, subscribe: () => () => {} }
ctx.provide('connection', {
api: { settings: {} },
isLoopback: false,
hostDescription,
} as never)
// ui-theme's Appearance row binds a durable scope through these two.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
@@ -348,7 +476,9 @@ describe('plugin registration', () => {
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
const [entry] = ctx.slots.entries('conversation.chat.turnTail')
expect(entry).toBeDefined()
expect(entry?.inject?.()).toEqual({ isLoopback: false, hooks: { hostDescription } })
// The prose face is live while the plugin is: a produced turn yields a
// resolver whose matches open through the owner-supplied opener.

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/ui-plugin-config/README.md
README.md: 7e530d70f6573d619378e43b0245345b45d6db18
README.zh.md: fd4f980fcf71c00c2357017fb40c76a9ca7a72cc

View File

@@ -0,0 +1,40 @@
# dsh-client-ui-plugin-config
English | [中文](README.zh.md)
The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
## What appears here
A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs.
The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
## Extension point
The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`.
## Writes
A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped.
Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct.
A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the forwarded `credentials/updated` event for the reference it watches.
A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key.
## Model Experience
None, as the section renders a browser configuration UI; the values it writes reach a model only through the plugins that own them, each documenting that effect itself.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job.
- **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`.
- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the card edits the same two fields on both, and a deployment composing neither shows no card.
- **The empty line counts registered cards, not visible ones** — a card whose namespace this deployment does not expose renders nothing, but still counts, so a deployment that exposes none shows an empty list rather than the empty line. The count is also read once, because the renderer caches a root entry's inject face; a card registered later does not raise it.

View File

@@ -0,0 +1,40 @@
# dsh-client-ui-plugin-config
[English](README.md) | 中文
**插件**设置分区:每个配置由用户拥有的 Host 插件占一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
## 这里会出现什么
只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。
第一批覆盖 shell 执行器(`bash`、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
## 扩展点
本分区声明了根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`
## 写入
卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。
保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。
密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在转发来的 `credentials/updated` 事件报告它所关注的引用时重读。
字段是否被覆盖取决于它是否出现在原始用户层中而非取决于它的值重置会清除该字段使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。
## 模型体验
无。该分区渲染浏览器配置 UI它写入的值只通过拥有这些值的插件到达模型而这些效应各由其拥有方的包记录。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。
- **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里。
- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段;而两者都不组装的部署不会显示这张卡片。
- **空态数的是已注册卡片,不是可见卡片**——命名空间未被本部署暴露的卡片什么都不渲染,但仍计入数量,因此一个都不暴露的部署看到的是空列表而非那行空态文案。该计数还只读取一次,因为渲染器会缓存根级 entry 的 inject face之后注册的卡片不会让它变大。

View File

@@ -0,0 +1,86 @@
{
"name": "@deepseek-ai/dsh-client-ui-plugin-config",
"description": "Plugin configuration section: host-plane plugin settings as expandable cards",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-plugin-config"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "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:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-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",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
],
"dependencies": {
"clsx": "^2.0.0"
}
}

View File

@@ -0,0 +1,47 @@
/** The agent loop's card: how many tool calls one step may run at once. */
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { ValueField } from './fields.tsx'
import { PluginCard } from './PluginCard.tsx'
import type { AgentLoopCardFace } from './agent-loop-store.ts'
import type {} from './slot-contract.ts'
/** Props the renderer binds for the agent-loop card. */
export type AgentLoopCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.pluginConfig'>
& InjectFace<AgentLoopCardFace>
/**
* Render the agent-loop card.
* @param props - locale copy, the card snapshot, and its form actions.
* @returns the card.
*/
export function AgentLoopCard(props: AgentLoopCardProps) {
const { t } = props
const state = props.useAgentLoopCard(snapshot => snapshot)
return (
<PluginCard
t={t}
titleKey="agentLoopTitle"
descriptionKey="agentLoopDescription"
state={state}
onSave={props.save}
onDiscard={props.discard}
>
<ValueField
id="plugin-config-agent-loop-parallel"
label={t('agentLoopMaxParallel')}
hint={t('agentLoopMaxParallelHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
numeric
disabled={!state.writable}
{...state.maxParallelToolCalls}
onEdit={(text) => { props.edit('maxParallelToolCalls', text) }}
onReset={() => { props.resetField('maxParallelToolCalls') }}
/>
</PluginCard>
)
}

View File

@@ -0,0 +1,61 @@
/** The shell plugin's card: the limits every command the agent runs is bound by. */
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { ValueField } from './fields.tsx'
import { PluginCard } from './PluginCard.tsx'
import type { BashCardFace } from './bash-store.ts'
import type {} from './slot-contract.ts'
/** Props the renderer binds for the shell card. */
export type BashCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.pluginConfig'>
& InjectFace<BashCardFace>
/**
* Render the shell card.
* @param props - locale copy, the card snapshot, and its form actions.
* @returns the card.
*/
export function BashCard(props: BashCardProps) {
const { t } = props
const state = props.useBashCard(snapshot => snapshot)
const disabled = !state.writable
return (
<PluginCard
t={t}
titleKey="bashTitle"
descriptionKey="bashDescription"
state={state}
onSave={props.save}
onDiscard={props.discard}
>
<ValueField
id="plugin-config-bash-timeout"
label={t('bashTimeoutMs')}
hint={t('bashTimeoutMsHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
numeric
disabled={disabled}
{...state.timeoutMs}
onEdit={(text) => { props.edit('timeoutMs', text) }}
onReset={() => { props.resetField('timeoutMs') }}
/>
<ValueField
id="plugin-config-bash-output"
label={t('bashMaxOutputBytes')}
hint={t('bashMaxOutputBytesHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
numeric
disabled={disabled}
{...state.maxOutputBytes}
onEdit={(text) => { props.edit('maxOutputBytes', text) }}
onReset={() => { props.resetField('maxOutputBytes') }}
/>
</PluginCard>
)
}

View File

@@ -0,0 +1,157 @@
/* Plugin card: a header that names the plugin, disclosing its controls in place. */
.card {
list-style: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
transition: border-color .16s, background .16s;
}
.card:hover {
border-color: var(--dsw-alias-label-dimmed);
}
/* An open card reads as the one being worked on, not merely taller. */
.cardOpen {
background: var(--dsw-alias-bg-layer-2);
border-color: var(--dsw-alias-label-dimmed);
}
.header {
width: 100%;
appearance: none;
border: 0;
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
}
.header:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: -2px;
}
/* Name over description: the description is what tells two plugins apart, so
it gets its own line rather than trailing the name. */
.headText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.name {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--dsw-alias-label-primary);
}
.description {
font-size: 13px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}
.chevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
transition: transform .16s;
}
.chevronOpen {
transform: rotate(180deg);
}
.body {
border-top: 1px solid var(--dsw-alias-border-l2);
margin: 0 16px;
padding-bottom: 8px;
}
.readOnly {
margin: 12px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}
/* Carried on the header so a collapsed card still says it holds edits. */
.pending {
flex: none;
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
font-weight: 500;
white-space: nowrap;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 0 4px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.failed {
flex: 1;
min-width: 0;
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-error);
}
.discard,
.save {
appearance: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 5px 14px;
font: inherit;
font-size: 13px;
line-height: 1.5;
cursor: pointer;
}
.discard {
border-color: var(--dsw-alias-border-l2);
background: none;
color: var(--dsw-alias-label-secondary);
}
.discard:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
border-color: var(--dsw-alias-label-dimmed);
}
.save {
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
}
.discard:disabled,
.save:disabled {
opacity: 0.4;
cursor: default;
}
.discard:focus-visible,
.save:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 1px;
}

View File

@@ -0,0 +1,98 @@
/**
* One plugin's card: a header naming the plugin and what its settings govern,
* disclosing that plugin's controls in place, with the save that writes them.
*
* The header is its own button rather than a shared disclosure row because a
* card stacks its name over its description, while that row lays the two side
* by side — the layout, not the behavior, is what differs. Disclosure is
* card-local state: which card a user has open is a reading gesture, not
* something the Host or the section has any stake in. Staged edits outlive
* collapsing, so the header marks a card holding unsaved edits.
*
* A card renders nothing while its namespace is unavailable: a deployment that
* does not compose the owning plugin should show no trace of it, rather than a
* disabled card the user cannot act on.
*/
import { useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { CardShell } from './card-store.ts'
import type { PluginConfigKey } from './locales.ts'
import css from './PluginCard.module.css'
/** Card chrome shared by every plugin section. */
export interface PluginCardProps {
/** Locale reader for this section's copy. */
t: (key: PluginConfigKey) => string
/** Locale key of the plugin's name. */
titleKey: PluginConfigKey
/** Locale key of the line describing what this plugin's settings govern. */
descriptionKey: PluginConfigKey
/** The card's form state: availability, writability, and what a save would do. */
state: CardShell
/** Write every staged edit. */
onSave: () => void
/** Drop every staged edit. */
onDiscard: () => void
/** The plugin's controls. */
children: ReactNode
}
/**
* Render one plugin card.
* @param props - the plugin's copy keys, its form state, and its controls.
* @returns the card, or nothing when the namespace is unavailable.
*/
export function PluginCard(props: PluginCardProps) {
const [open, setOpen] = useState(false)
const { state } = props
if (!state.available) return null
const title = props.t(props.titleKey)
const blocked = !state.dirty || state.invalid || state.saving
return (
<li className={clsx(css.card, open && css.cardOpen)}>
<button
type="button"
className={css.header}
aria-expanded={open}
aria-label={`${props.t(open ? 'collapse' : 'expand')}: ${title}`}
onClick={() => { setOpen(!open) }}
>
<span className={css.headText}>
<span className={css.name}>{title}</span>
<span className={css.description}>{props.t(props.descriptionKey)}</span>
</span>
{state.dirty ? <span className={css.pending}>{props.t('unsaved')}</span> : null}
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open
? (
<div className={css.body}>
{!state.writable ? <p className={css.readOnly} role="status">{props.t('readOnly')}</p> : null}
{props.children}
<div className={css.footer}>
{state.failed ? <p className={css.failed} role="status">{props.t('saveFailed')}</p> : null}
<button
type="button"
className={css.discard}
disabled={!state.dirty || state.saving}
onClick={props.onDiscard}
>
{props.t('discard')}
</button>
<button
type="button"
className={css.save}
disabled={blocked}
onClick={props.onSave}
>
{props.t(state.saving ? 'saving' : 'save')}
</button>
</div>
</div>
)
: null}
</li>
)
}

View File

@@ -0,0 +1,36 @@
/* Plugin configuration section: heading, intro, and the card list. */
.section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
}
.heading {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.intro {
margin: 0;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}
.cards {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.empty {
margin: 0;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,49 @@
/**
* Plugin configuration section: the shell around the per-plugin cards. It
* enumerates nothing itself — cards arrive through the `settings.plugin.item`
* slot it declares, so a plugin that ships a browser half owns its own card
* and this section never learns what a namespace means.
*/
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from './slot-contract.ts'
import type { PluginConfigKey } from './locales.ts'
import css from './PluginConfigSection.module.css'
/** Registration-side business face for the section. */
export interface PluginConfigSectionInjected {
/** How many cards the slot ledger currently holds; zero renders the empty line. */
cardCount: number
}
/** Props the renderer binds for the section. */
export type PluginConfigSectionProps =
PropsRuntime<'settings.section'>
& PropsLocale<'settings.pluginConfig'>
& PropsRenderSlots<'settings.plugin.item'>
& InjectFace<PluginConfigSectionInjected>
/**
* Render the plugin configuration section.
* @param props - runtime slot rendering, locale copy, and the card count.
* @returns the section.
*/
export function PluginConfigSection(props: PluginConfigSectionProps) {
const { t, renderSlot, cardCount } = props
return (
<div className={css.section}>
<h2 className={css.heading}>{t('title')}</h2>
<p className={css.intro}>{t('intro')}</p>
{cardCount === 0
? <p className={css.empty}>{t('empty')}</p>
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>}
</div>
)
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Plugin configuration section and card copy. */
'settings.pluginConfig': PluginConfigKey
}
}

View File

@@ -0,0 +1,78 @@
/**
* The web-search provider's card: its endpoint, its per-request search budget,
* and the key — which is written through the credentials domain, never into
* the settings section, so the literal never rides a response.
*/
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { SecretField, ValueField } from './fields.tsx'
import { PluginCard } from './PluginCard.tsx'
import type { WebSearchCardFace } from './web-search-store.ts'
import type {} from './slot-contract.ts'
/** Props the renderer binds for the web-search card. */
export type WebSearchCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.pluginConfig'>
& InjectFace<WebSearchCardFace>
/**
* Render the web-search card.
* @param props - locale copy, the card snapshot, and its form actions.
* @returns the card.
*/
export function WebSearchCard(props: WebSearchCardProps) {
const { t } = props
const state = props.useWebSearchCard(snapshot => snapshot)
const disabled = !state.writable
return (
<PluginCard
t={t}
titleKey="webSearchTitle"
descriptionKey="webSearchDescription"
state={state}
onSave={props.save}
onDiscard={props.discard}
>
<SecretField
id="plugin-config-web-search-key"
label={t('webSearchApiKey')}
hint={t('webSearchApiKeyHint')}
// The credentials domain accepts a key even when the settings document
// itself is read-only; they are separate stores with separate refusals.
// Its own writability is what disables this control — a key sourced
// from the process environment cannot be written from here.
disabled={!state.apiKeyWritable}
text={state.apiKey.text}
configured={state.apiKeyConfigured}
stateLabel={state.apiKeyConfigured ? t('webSearchApiKeySet') : t('webSearchApiKeyUnset')}
onEdit={(text) => { props.edit('apiKey', text) }}
/>
<ValueField
id="plugin-config-web-search-endpoint"
label={t('webSearchBaseUrl')}
hint={t('webSearchBaseUrlHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
disabled={disabled}
{...state.baseURL}
onEdit={(text) => { props.edit('baseURL', text) }}
onReset={() => { props.resetField('baseURL') }}
/>
<ValueField
id="plugin-config-web-search-max-uses"
label={t('webSearchMaxUses')}
hint={t('webSearchMaxUsesHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
numeric
disabled={disabled}
{...state.maxUses}
onEdit={(text) => { props.edit('maxUses', text) }}
onReset={() => { props.resetField('maxUses') }}
/>
</PluginCard>
)
}

View File

@@ -0,0 +1,57 @@
/** The agent-loop card's staged form over the `agent-loop` settings namespace. */
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-store.ts'
/**
* Namespace of the agent loop's user-owned settings. Spelled here rather than
* imported: a client package must not depend on a Host package.
*/
export const AGENT_LOOP_NS = 'agent-loop'
/**
* The agent-loop fields this card edits. The Host section carries only this
* field — the composed `agents` array is deliberately not part of it.
*/
export interface AgentLoopSettings {
/** Upper bound on parallel-safe tool calls in flight per step. */
maxParallelToolCalls?: number
}
/** What the agent-loop card renders. */
export interface AgentLoopCardState extends CardShell {
/** Parallel tool-call cap. */
maxParallelToolCalls: CardFieldState
}
/** The registration-side face the agent-loop card's slot entry injects. */
export interface AgentLoopCardFace extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useAgentLoopCard. */
agentLoopCard: SnapshotStore<AgentLoopCardState>
}
}
/** Bridges the `agent-loop` scope onto the card's staged form. */
export class AgentLoopCardController {
private readonly form: CardForm<AgentLoopSettings>
private readonly store: SnapshotStore<AgentLoopCardState>
/** @param scope - the bound settings scope for the `agent-loop` namespace. */
constructor(scope: SettingsScope<AgentLoopSettings>) {
this.form = new CardForm(scope, [numberField('maxParallelToolCalls')])
this.store = this.form.bind(() => this.projection())
}
private projection(): AgentLoopCardState {
return { ...this.form.shell(), maxParallelToolCalls: this.form.field('maxParallelToolCalls') }
}
/**
* Build the face the card's slot registration injects.
* @returns the card's snapshot and its form actions.
*/
inject(): AgentLoopCardFace {
return { hooks: { agentLoopCard: this.store }, ...this.form.actions() }
}
}

View File

@@ -0,0 +1,63 @@
/** The shell card's staged form over the `bash` settings namespace. */
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-store.ts'
/**
* Namespace of the shell capability. Spelled here rather than imported: a
* client package must not depend on a Host package, and the executor families
* that own it spell the same value.
*/
export const BASH_NS = 'bash'
/** The shell fields this card edits — a subset of the served schema by design. */
export interface BashSettings {
/** Foreground command timeout in milliseconds. */
timeoutMs?: number
/** Per-stream in-memory output cap in bytes. */
maxOutputBytes?: number
}
/** What the shell card renders. */
export interface BashCardState extends CardShell {
/** Command timeout in milliseconds. */
timeoutMs: CardFieldState
/** Per-stream output cap in bytes. */
maxOutputBytes: CardFieldState
}
/** The registration-side face the shell card's slot entry injects. */
export interface BashCardFace extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useBashCard. */
bashCard: SnapshotStore<BashCardState>
}
}
/** Bridges the `bash` scope onto the shell card's staged form. */
export class BashCardController {
private readonly form: CardForm<BashSettings>
private readonly store: SnapshotStore<BashCardState>
/** @param scope - the bound settings scope for the `bash` namespace. */
constructor(scope: SettingsScope<BashSettings>) {
this.form = new CardForm(scope, [numberField('timeoutMs'), numberField('maxOutputBytes')])
this.store = this.form.bind(() => this.projection())
}
private projection(): BashCardState {
return {
...this.form.shell(),
timeoutMs: this.form.field('timeoutMs'),
maxOutputBytes: this.form.field('maxOutputBytes'),
}
}
/**
* Build the face the card's slot registration injects.
* @returns the card's snapshot and its form actions.
*/
inject(): BashCardFace {
return { hooks: { bashCard: this.store }, ...this.form.actions() }
}
}

View File

@@ -0,0 +1,351 @@
/**
* Shared form model behind every plugin card.
*
* A card stages what the user types and writes it only when they save. Each
* settings write is a durable, revision-fenced document mutation, so a control
* that committed as it settled turned one edit into a write the user never
* asked for and could not preview; staged text makes what is on screen exactly
* what a save would store.
*
* A field shows its effective value — the user layer over the composition
* layer over the schema default — and whether the user layer carries it. That
* presence, not a value comparison, is what marks a field overridden: an
* override equal to the composition default is still an override.
*/
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** The write one field's staged text performs when the card is saved. */
export type FieldWrite =
| { kind: 'set'; value: unknown }
| { kind: 'clear' }
/** How one section field converts between its stored value and its draft text. */
export interface CardFieldSpec {
/** Field name inside the namespace section. */
field: string
/** Render a stored value as draft text; the empty string when the section carries none. */
format: (value: unknown) => string
/**
* The write this draft text stages, or undefined when the text is not a
* value this field accepts — which blocks the save rather than discarding it.
*/
parse: (text: string) => FieldWrite | undefined
}
/**
* A control whose value is written outside the settings section. A credential
* literal never rides a response, so its draft has nothing to seed from: it is
* blank until typed, and a blank draft writes nothing.
*/
export interface CardSecretSpec {
/** Field name addressing this control inside the card's form. */
field: string
/** Write the staged text; resolves to whether the Host accepted it. */
write: (text: string) => Promise<boolean>
}
/** One field as a card's control renders it. */
export interface CardFieldState {
/** Draft text the control renders. */
text: string
/**
* Whether saving would leave a user-layer entry for this field. A staged
* edit answers for itself, so the badge previews the save rather than
* reporting a state the pending edit already contradicts.
*/
overridden: boolean
/** Whether the draft is not a value this field accepts, which blocks saving. */
invalid: boolean
}
/** Form state every plugin card shares. */
export interface CardShell {
/** False while the namespace is not served to this client; the card renders nothing. */
available: boolean
/** Whether the Host document accepts writes. */
writable: boolean
/** Whether the form holds edits that a save would write. */
dirty: boolean
/** Whether any staged draft is invalid, which blocks the save. */
invalid: boolean
/** Whether a save is crossing the wire. */
saving: boolean
/** Whether the last save did not land as staged; cleared by the next edit or save. */
failed: boolean
}
/** The write actions every plugin card's slot entry injects. */
export interface CardActions {
/** Stage draft text for one field. */
edit: (field: string, text: string) => void
/** Stage a clear, so saving lets the field re-inherit the composition layer. */
resetField: (field: string) => void
/** Write every staged edit, then re-seed from what the Host accepted. */
save: () => void
/** Drop every staged edit. */
discard: () => void
}
/** One field's staged edit. */
interface StagedEdit {
/** Draft text the control renders. */
text: string
/** True when this edit clears the field whatever text it shows. */
clear: boolean
}
/** One staged edit resolved into the write a save performs. */
interface PlannedWrite {
/** Field this entry writes. */
field: string
/**
* Perform the write and report whether the Host holds the staged value
* afterwards; undefined when the draft is not a value the field accepts.
*/
run: (() => Promise<boolean>) | undefined
}
/**
* A whole-number field. An empty draft clears the field; any other draft that
* is not a finite number blocks the save.
* @param field - field name inside the namespace section.
* @returns the field's conversion spec.
*/
export function numberField(field: string): CardFieldSpec {
return {
field,
// A section that carries no number for this field renders empty rather
// than as a value nobody chose.
format: value => typeof value === 'number' ? String(value) : '',
parse: (text) => {
const trimmed = text.trim()
if (trimmed === '') return { kind: 'clear' }
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
},
}
}
/**
* A free-text field. An empty draft clears the field, so emptying the control
* and saving is the same gesture as resetting it.
* @param field - field name inside the namespace section.
* @returns the field's conversion spec.
*/
export function textField(field: string): CardFieldSpec {
return {
field,
format: value => typeof value === 'string' ? value : '',
parse: (text) => {
const trimmed = text.trim()
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
},
}
}
/**
* Stages one card's edits over one settings namespace and writes them on save.
*
* The form publishes through a snapshot store because slot components read
* through a snapshot selector, while both the scope and the local drafts
* change underneath; every projection is rebuilt from the two together.
*/
export class CardForm<T> {
private readonly specs: Map<string, CardFieldSpec>
private readonly secretSpecs: Map<string, CardSecretSpec>
private readonly staged = new Map<string, StagedEdit>()
private readonly listeners = new Set<() => void>()
private saving = false
private failed = false
/**
* @param scope - the bound settings scope for this card's namespace.
* @param specs - the section fields this card edits.
* @param secrets - the card's write-only controls, written outside the section.
*/
constructor(
private readonly scope: SettingsScope<T>,
specs: CardFieldSpec[],
secrets: CardSecretSpec[] = [],
) {
this.specs = new Map(specs.map(spec => [spec.field, spec]))
this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec]))
scope.subscribe(() => { this.publish() })
}
/**
* Publish a projection of this form, rebuilt whenever the scope or a draft changes.
* @param project - build the card's state from the form's current reads.
* @returns the store the card's component reads through its bound selector.
*/
bind<S>(project: () => S): SnapshotStore<S> {
const store = createSnapshotStore(project())
this.listeners.add(() => { store.set(project()) })
return store
}
/**
* Read the card-level state: what the Host serves, and what a save would do.
* @returns the form state every card shares.
*/
shell(): CardShell {
const snapshot = this.scope.getSnapshot()
const plan = this.plan()
return {
available: snapshot.status === 'ready',
writable: snapshot.writable,
dirty: plan.length > 0,
invalid: plan.some(item => item.run === undefined),
saving: this.saving,
failed: this.failed,
}
}
/**
* Read one control's state.
* @param field - field name of a section field or of a write-only control.
* @returns the draft text, whether a save would leave an override, and whether it is invalid.
*/
field(field: string): CardFieldState {
const staged = this.staged.get(field)
if (this.secretSpecs.has(field)) {
return { text: staged?.text ?? '', overridden: false, invalid: false }
}
const spec = this.spec(field)
if (staged === undefined) {
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
}
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
return {
text: staged.text,
overridden: write?.kind === 'set',
invalid: write === undefined,
}
}
/**
* Build the edit, reset, save, and discard actions bound to this form.
* @returns the actions a card's slot entry injects.
*/
actions(): CardActions {
return {
edit: (field, text) => { this.stage(field, { text, clear: false }) },
resetField: (field) => {
this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true })
},
save: () => { void this.save() },
discard: () => {
if (this.staged.size === 0 && !this.failed) return
this.staged.clear()
this.failed = false
this.publish()
},
}
}
/**
* Write every staged edit, then re-seed from what the Host accepted.
*
* The Host is the only authority on whether a value was accepted — its
* validators own the constraints no schema can express — so the outcome is
* read back from the section rather than predicted here. A save that did not
* land keeps its drafts, so the user can correct them instead of retyping.
* @returns settlement after every write and the read-back.
*/
async save(): Promise<void> {
const plan = this.plan()
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
this.saving = true
this.failed = false
this.publish()
let landed = true
for (const write of writes) {
landed = await write() && landed
}
if (landed) this.staged.clear()
this.saving = false
this.failed = !landed
this.publish()
}
/**
* Every staged edit a save would write. An entry whose draft is not a value
* its field accepts carries no write: the form is still dirty, and the save
* refuses rather than dropping the edit.
* @returns the planned writes, in the order the fields were staged.
*/
private plan(): PlannedWrite[] {
const plan: PlannedWrite[] = []
for (const [field, staged] of this.staged) {
const secret = this.secretSpecs.get(field)
if (secret !== undefined) {
const value = staged.text.trim()
if (value !== '') plan.push({ field, run: () => secret.write(value) })
continue
}
const spec = this.spec(field)
if (staged.clear) {
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
continue
}
if (staged.text === spec.format(this.sectionValue(field))) continue
const write = spec.parse(staged.text)
if (write === undefined) plan.push({ field, run: undefined })
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
else plan.push({ field, run: () => this.store(field, write.value) })
}
return plan
}
private async clear(field: string): Promise<boolean> {
await this.scope.unset(field)
return !this.stored(field)
}
private async store(field: string, value: unknown): Promise<boolean> {
await this.scope.set(field, value)
return this.userLayer()?.[field] === value
}
private stage(field: string, edit: StagedEdit): void {
this.staged.set(field, edit)
this.failed = false
this.publish()
}
private spec(field: string): CardFieldSpec {
const spec = this.specs.get(field)
// Every call site names a field this card declared; a missing one is a
// wiring mistake that must not degrade into a silently inert control.
if (spec === undefined) throw new Error(`plugin card has no field ${field}`)
return spec
}
private snapshotOf(): SettingsScopeSnapshot<T> {
return this.scope.getSnapshot()
}
private sectionValue(field: string): unknown {
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
}
private baseValue(field: string): unknown {
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
}
private userLayer(): Record<string, unknown> | undefined {
return this.snapshotOf().user as Record<string, unknown> | undefined
}
private stored(field: string): boolean {
const user = this.userLayer()
return user !== undefined && Object.hasOwn(user, field)
}
private publish(): void {
for (const listener of this.listeners) listener()
}
}

View File

@@ -0,0 +1,113 @@
/* Plugin configuration fields: label, control, override badge, and hint. */
.field {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
}
.field + .field {
border-top: 1px solid var(--dsw-alias-border-l2);
}
.head {
display: flex;
align-items: center;
gap: 8px;
}
.label {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.5;
color: var(--dsw-alias-label-primary);
}
.badges {
display: inline-flex;
align-items: center;
gap: 8px;
}
.badge {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
font-weight: 500;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
}
.badgeMuted {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
.reset {
border: none;
background: none;
padding: 0;
font: inherit;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.reset:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
}
.reset:disabled {
cursor: default;
}
.input {
height: 34px;
padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
font: inherit;
font-size: 13px;
line-height: 1.5;
color: var(--dsw-alias-label-primary);
}
.input:focus-visible {
outline: none;
border-color: var(--dsw-alias-brand-primary);
}
.input:disabled {
color: var(--dsw-alias-label-tertiary);
cursor: default;
}
.inputInvalid {
composes: input;
border-color: var(--dsw-alias-label-error);
}
.invalid {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-error);
}
.hint {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,123 @@
/**
* Hand-written controls for the plugin configuration forms. Each renders one
* field's label, its staged text, whether saving would leave an override, and
* — when one stands — the reset that stages a clear back to the composition
* layer. Nothing here writes: a control reports what the user typed, and the
* card's save is the single point where a draft becomes a document mutation.
*/
import css from './fields.module.css'
/** What every field control needs regardless of its value type. */
export interface FieldProps {
/** Stable id associating the label with its control. */
id: string
/** Visible label. */
label: string
/** One-line explanation rendered under the control. */
hint: string
/** Draft text this control renders. */
text: string
/** True when saving would leave a user-layer entry for this field. */
overridden: boolean
/** True when the draft is not a value this field accepts. */
invalid: boolean
/** Copy for the overridden badge. */
overriddenLabel: string
/** Copy for the reset control. */
resetLabel: string
/** Copy shown in place of the hint while the draft is invalid. */
invalidLabel: string
/** Disables every control (read-only document, or an unavailable namespace). */
disabled: boolean
/** Stage draft text. */
onEdit: (text: string) => void
/** Stage a clear so the field re-inherits the composition layer. */
onReset: () => void
}
/**
* A staged value field. `numeric` only hints the keypad: which drafts a field
* accepts is decided by its spec, so the control never silently rewrites what
* the user typed.
* @param props - the field's copy, its staged text, and the edit actions.
* @returns the labelled control.
*/
export function ValueField(props: FieldProps & {
/** Hints a numeric keypad without narrowing what the control accepts. */
numeric?: boolean
/** Placeholder shown while the draft is empty. */
placeholder?: string
}) {
return (
<div className={css.field}>
<div className={css.head}>
<label className={css.label} htmlFor={props.id}>{props.label}</label>
{props.overridden
? (
<span className={css.badges}>
<span className={css.badge}>{props.overriddenLabel}</span>
<button
type="button"
className={css.reset}
disabled={props.disabled}
onClick={props.onReset}
>
{props.resetLabel}
</button>
</span>
)
: null}
</div>
<input
id={props.id}
className={props.invalid ? css.inputInvalid : css.input}
type="text"
{...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
{...props.invalid ? { 'aria-invalid': true } : {}}
value={props.text}
placeholder={props.placeholder ?? ''}
disabled={props.disabled}
onChange={(event) => { props.onEdit(event.target.value) }}
/>
<p className={props.invalid ? css.invalid : css.hint}>
{props.invalid ? props.invalidLabel : props.hint}
</p>
</div>
)
}
/**
* A write-only credential control. The value never rides a response, so the
* control reports only whether one is configured and starts blank; a blank
* draft writes nothing, which keeps the stored key rather than clearing it.
* @param props - the field's copy, its staged text, and the configured state.
* @returns the labelled control.
*/
export function SecretField(props: Pick<FieldProps, 'id' | 'label' | 'hint' | 'text' | 'disabled' | 'onEdit'> & {
/** Whether the Host reports a configured credential for this reference. */
configured: boolean
/** Copy describing the configured state. */
stateLabel: string
}) {
return (
<div className={css.field}>
<div className={css.head}>
<label className={css.label} htmlFor={props.id}>{props.label}</label>
<span className={css.badges}>
<span className={props.configured ? css.badge : css.badgeMuted}>{props.stateLabel}</span>
</span>
</div>
<input
id={props.id}
className={css.input}
type="password"
autoComplete="off"
value={props.text}
disabled={props.disabled}
onChange={(event) => { props.onEdit(event.target.value) }}
/>
<p className={css.hint}>{props.hint}</p>
</div>
)
}

View File

@@ -0,0 +1,110 @@
/**
* Plugin configuration surface, browser half — one settings section holding
* an expandable card per Host plugin whose configuration a user owns.
*
* The section owns no knowledge of any namespace: it declares the
* `settings.plugin.item` slot and renders whatever cards were registered into
* it, so a plugin that ships a browser half contributes its own card and its
* own controls. The three cards this package registers are the host-plane
* sections the deployment already exposes; each binds its namespace through
* the client settings scope, which keeps them unaware of one another.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: the settings shell's SlotMap merge (the 'settings.section' entry)
// and the ctx.settingsScope Context merge. Cross-plugin collaboration goes
// through the service, never a value import (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.remote Context merge and the forwarded-event key face.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { AgentLoopCard } from './AgentLoopCard.tsx'
import { BashCard } from './BashCard.tsx'
import { PluginConfigSection } from './PluginConfigSection.tsx'
import { WebSearchCard } from './WebSearchCard.tsx'
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts'
import { BASH_NS, BashCardController } from './bash-store.ts'
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts'
import { en, zh } from './locales.ts'
export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx'
export type { PluginCardProps } from './PluginCard.tsx'
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
export type { FieldProps } from './fields.tsx'
export type {
CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell,
} from './card-store.ts'
export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-store.ts'
export type { BashCardFace, BashCardState } from './bash-store.ts'
export type { WebSearchCardFace, WebSearchCardState } from './web-search-store.ts'
/** Dictionary namespace owned by this plugin. */
const NS = 'settings.pluginConfig'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
/**
* Mount the plugin configuration section and the cards this package ships.
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const { api } = ctx.get('connection') as ConnectionHandle
const t = ctx.locale.bind(NS)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-config: section dictionaries')
const bash = new BashCardController(ctx.settingsScope.bind({ namespace: BASH_NS }))
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
const webSearch = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), api)
// The credential a card reports is not part of any settings section, so its
// scope publishes nothing when one is written. This is the only signal that
// a key written on another surface reached the Host.
ctx.effect(
() => ctx.remote.$on('credentials/updated', (ref) => { webSearch.refreshCredential(ref) }),
'ui-plugin-config: credential invalidations',
)
// The section renders the empty line rather than an empty list when no plugin
// contributed a card. The count is read once: the renderer caches a root
// entry's inject face per registration, so this reports what was registered
// when the section mounted, not what is visible now. Both gaps are bounded by
// this deployment always registering the three cards below — a card that
// arrives later would not raise the count, and a namespace this deployment
// does not expose leaves its card rendering nothing inside a non-empty list.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'plugins',
order: 30,
label: () => t('nav'),
locale: NS,
inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }),
children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
}, PluginConfigSection))
ctx.slots.inject('settings.plugin.item', function* () {
yield ctx.slots.register({
name: 'settings.plugin.item',
id: 'bash',
order: 0,
locale: NS,
inject: () => bash.inject(),
}, BashCard)
yield ctx.slots.register({
name: 'settings.plugin.item',
id: 'agent-loop',
order: 10,
locale: NS,
inject: () => agentLoop.inject(),
}, AgentLoopCard)
yield ctx.slots.register({
name: 'settings.plugin.item',
id: 'web-search',
order: 20,
locale: NS,
inject: () => webSearch.inject(),
}, WebSearchCard)
})
}

View File

@@ -0,0 +1,91 @@
/** Locale bundles for the plugin configuration section and its plugin cards. */
/** Locale keys these surfaces render. */
export type PluginConfigKey =
| 'nav' | 'title' | 'intro' | 'empty'
| 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse'
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber'
| 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint'
| 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint'
| 'agentLoopTitle' | 'agentLoopDescription' | 'agentLoopMaxParallel' | 'agentLoopMaxParallelHint'
| 'webSearchTitle' | 'webSearchDescription'
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
/** English copy. */
export const en: Record<PluginConfigKey, string> = {
nav: 'Plugin config',
title: 'Plugin configuration',
intro: 'Configure the plugins this deployment installed.',
empty: 'This deployment exposes no plugin settings.',
overridden: 'Overridden',
reset: 'Reset to default',
readOnly: 'This deployment stores settings read-only.',
expand: 'Show settings',
collapse: 'Hide settings',
save: 'Save',
saving: 'Saving…',
discard: 'Discard',
unsaved: 'Unsaved',
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
invalidNumber: 'Enter a number, or leave blank to use the default.',
bashTitle: 'Shell',
bashDescription: 'Limits every command the agent runs.',
bashTimeoutMs: 'Command timeout (ms)',
bashTimeoutMsHint: 'How long one command may run before it is terminated.',
bashMaxOutputBytes: 'Output cap per stream (bytes)',
bashMaxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.',
agentLoopTitle: 'Agent loop',
agentLoopDescription: 'How the agent dispatches tool calls.',
agentLoopMaxParallel: 'Parallel tool calls',
agentLoopMaxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.',
webSearchTitle: 'Web search',
webSearchDescription: 'The DeepSeek search provider.',
webSearchApiKey: 'API key',
webSearchApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.',
webSearchApiKeySet: 'A key is configured.',
webSearchApiKeyUnset: 'No key is configured; search is unavailable until one is.',
webSearchBaseUrl: 'Endpoint',
webSearchBaseUrlHint: 'Leave blank to use the provider default.',
webSearchMaxUses: 'Max searches per request',
webSearchMaxUsesHint: 'How many times one request may search before it must answer.',
}
/** Simplified Chinese copy. */
export const zh: Record<PluginConfigKey, string> = {
nav: '插件配置',
title: '插件配置',
intro: '配置本部署已安装的插件。',
empty: '本部署没有开放任何插件设置。',
overridden: '已覆盖',
reset: '恢复默认',
readOnly: '本部署的设置为只读。',
expand: '展开设置',
collapse: '收起设置',
save: '保存',
saving: '保存中…',
discard: '放弃修改',
unsaved: '未保存',
saveFailed: '本部署没有接受这些值,已保留供你修改。',
invalidNumber: '请填数字;留空表示使用默认值。',
bashTitle: '终端',
bashDescription: '限制 agent 运行的每一条命令。',
bashTimeoutMs: '命令超时(毫秒)',
bashTimeoutMsHint: '单条命令允许运行多久,超时即终止。',
bashMaxOutputBytes: '单流输出上限(字节)',
bashMaxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。',
agentLoopTitle: 'Agent 循环',
agentLoopDescription: 'Agent 如何派发工具调用。',
agentLoopMaxParallel: '并行工具调用数',
agentLoopMaxParallelHint: '同一步内最多同时运行多少个可并行的调用。',
webSearchTitle: '网页搜索',
webSearchDescription: 'DeepSeek 搜索提供方。',
webSearchApiKey: 'API Key',
webSearchApiKeyHint: '不写入设置文件。留空表示保持当前密钥。',
webSearchApiKeySet: '已配置密钥。',
webSearchApiKeyUnset: '未配置密钥;配置之前搜索不可用。',
webSearchBaseUrl: '接口地址',
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
webSearchMaxUses: '单次请求最多搜索次数',
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
}

View File

@@ -0,0 +1,24 @@
/**
* The `settings.plugin.item` slot type — one plugin's card inside the plugin
* configuration section. Options: `id` (card key), `order` (card position).
* A card draws its own internals; the section only stacks them and reports
* how many there are.
*
* TYPE HOME RATIONALE: unlike `settings.general.item`, whose registrants span
* packages that cannot reference its declarer, every current registrant of
* this slot ships in this package, and a plugin registering its own card
* already depends on this package for the card chrome. The type therefore
* lives with the section that declares it at runtime.
*/
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** One plugin's card inside the plugin configuration section (see module JSDoc). */
'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
}
}
/** Owner share of a plugin card (the section supplies nothing). */
export interface SettingsPluginItemOwnerProps {
/** Marker field: card owner props are intentionally empty. */
children?: never
}

View File

@@ -0,0 +1,192 @@
/**
* The web-search card's staged form over the `web-search-deepseek` settings
* namespace.
*
* The key is the one control that does not live in the section: its literal
* never rides a response, so the card learns only whether one is configured
* and writes it through the credentials domain, addressed by the reference the
* section names. It is still staged with the rest of the form, so one save
* covers everything the card shows.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import {
CardForm, numberField, textField,
type CardActions, type CardFieldState, type CardShell,
} from './card-store.ts'
/**
* Namespace of the DeepSeek search provider. Spelled here rather than
* imported: a client package must not depend on a Host package.
*/
export const WEB_SEARCH_NS = 'web-search-deepseek'
/** Credential reference the provider resolves when the section names none. */
const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY'
/** Form field the credential control stages under. */
const API_KEY_FIELD = 'apiKey'
/** The search-provider fields this card edits. */
export interface WebSearchSettings {
/** Credential reference naming the environment key. */
apiKeyEnv?: string
/** Provider endpoint; blank inherits the provider default. */
baseURL?: string
/** Maximum searches served within one request. */
maxUses?: number
}
/** What the credentials domain last reported, and for which reference. */
interface CredentialState {
/** Reference this answer describes; a stale response for another one is dropped. */
ref: string
/** Whether any layer supplies a value for it. */
configured: boolean
/** Whether `credentials.set` can affect it; false disables the control. */
writable: boolean
}
/** What the web-search card renders. */
export interface WebSearchCardState extends CardShell {
/** Provider endpoint. */
baseURL: CardFieldState
/** Searches allowed per request. */
maxUses: CardFieldState
/** The staged credential, which starts blank on every load. */
apiKey: CardFieldState
/** Whether the Host reports a credential configured for the referenced key. */
apiKeyConfigured: boolean
/** Whether the credentials domain accepts a write for it; false disables the control. */
apiKeyWritable: boolean
}
/** The registration-side face the web-search card's slot entry injects. */
export interface WebSearchCardFace extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useWebSearchCard. */
webSearchCard: SnapshotStore<WebSearchCardState>
}
}
/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */
export class WebSearchCardController {
private readonly form: CardForm<WebSearchSettings>
private readonly store: SnapshotStore<WebSearchCardState>
private credential: CredentialState = { ref: '', configured: false, writable: true }
/**
* @param scope - the bound settings scope for the `web-search-deepseek` namespace.
* @param api - wire face used for the credential the section references.
*/
constructor(
private readonly scope: SettingsScope<WebSearchSettings>,
private readonly api: Pick<IApiClient, 'credentials'>,
) {
this.form = new CardForm(
scope,
[textField('baseURL'), numberField('maxUses')],
[{ field: API_KEY_FIELD, write: text => this.writeKey(text) }],
)
this.store = this.form.bind(() => this.projection())
scope.subscribe(() => { void this.readCredential() })
void this.readCredential()
}
private projection(): WebSearchCardState {
return {
...this.form.shell(),
baseURL: this.form.field('baseURL'),
maxUses: this.form.field('maxUses'),
apiKey: this.form.field(API_KEY_FIELD),
apiKeyConfigured: this.credential.configured,
apiKeyWritable: this.credential.writable,
}
}
/**
* Ask the credentials domain about the reference the section currently names.
*
* The answer is stored with the reference it describes: `apiKeyEnv` can
* change between the request and its response, and two reads can settle out
* of order, so a response is published only while it still answers for the
* reference in force.
*/
private async readCredential(): Promise<void> {
const ref = refOf(this.scope.getSnapshot())
if (ref !== this.credential.ref) {
// A new reference knows nothing yet; keeping the old answer would claim
// the key is configured under a name nobody has checked.
this.credential = { ref, configured: false, writable: true }
this.store.set(this.projection())
}
let response: Awaited<ReturnType<IApiClient['credentials']['describe']>>
try {
response = await this.api.credentials.describe({ refs: [ref] })
} catch (_credentialReadFailure) {
// The card stays usable without this: the key control simply reports the
// last state it knew, and a write still reaches the Host.
return
}
if (!response.result.ok || ref !== refOf(this.scope.getSnapshot())) return
const view = response.result.value.credentials[ref]
const next: CredentialState = {
ref,
configured: view?.configured ?? false,
// An unknown reference is treated as writable: the control stays usable
// and the Host is what refuses, rather than the card guessing a refusal.
writable: view?.writable ?? true,
}
if (next.configured === this.credential.configured && next.writable === this.credential.writable) return
this.credential = next
this.store.set(this.projection())
}
/**
* Re-read after the Host reports a change to the reference this card watches.
*
* A key can be written from somewhere else — the Models page addresses the
* same reference — and the settings section does not change when it is, so
* without this the badge keeps reporting a state the Host already replaced.
* @param ref - the reference the Host reports as changed.
*/
refreshCredential(ref: string): void {
if (ref !== this.credential.ref) return
void this.readCredential()
}
/**
* Build the face the card's slot registration injects.
* @returns the card's snapshot and its form actions.
*/
inject(): WebSearchCardFace {
return { hooks: { webSearchCard: this.store }, ...this.form.actions() }
}
/**
* Write the staged key, then re-read whether the Host now holds one.
* @param value - the staged credential literal.
* @returns whether the Host reports a configured credential afterwards.
*/
private async writeKey(value: string): Promise<boolean> {
try {
await this.api.credentials.set({ ref: refOf(this.scope.getSnapshot()), value })
} catch (_credentialWriteFailure) {
// Refusals surface through the re-read below: the Host is the only
// authority on whether the key now exists.
}
await this.readCredential()
return this.credential.configured
}
}
/**
* The credential reference the section names, or the provider's default.
* @param snapshot - the current scope snapshot.
* @returns the reference to address.
*/
function refOf(snapshot: SettingsScopeSnapshot<WebSearchSettings>): string {
const declared = snapshot.value?.apiKeyEnv
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}

View File

@@ -0,0 +1,11 @@
/**
* Plugin configuration surface, node half. The empty apply exists so the
* plugin appears in the host cordis.yml / Loader; the browser half ships the
* settings section through exports["./client"], discovered from the
* package.json dsh.client declaration. Every section this page edits is owned
* by the Host plugin that registered it, so this package registers no
* namespace of its own.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-plugin-config`.
* @module @deepseek-ai/dsh-client-ui-plugin-config/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugin-config'
/** Cordis companion plugin name. */
export const name = 'client-ui-plugin-config-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this is a browser-side settings surface whose node half owns no event
* stream or mutable runtime data; the layering, write refusals, and exposure boundary are Host
* contracts covered by the owning plugins and the api-proxy.
*/
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,134 @@
/** What the browser half registers, and that it all leaves with the fiber. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
// The section binds its scopes through the Settings surface's service, and
// forwarded Host events reach it through the same `$dispatch` handoff the
// connection sink makes.
new TestRemote(ctx)
ctx.provide('connection', {
isLoopback: true,
api: {
settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
credentials: { describe: describeCredentials },
},
} as never)
await ctx.plugin(SettingsScopeService).await()
return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials }
}
function declareRoot(slots: SlotsService): () => void {
return slots.register({
name: 'root',
children: { 'settings.section': { kind: 'list', scope: 'root' } },
} as never, () => null)
}
describe('ui-plugin-config apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
})
it('registers the section and declares the per-plugin card slot', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = slots.entries('settings.section')[0]!
expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
// The nav label is a locale-following thunk; owners resolve it at read time.
expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
})
it('registers one card per host-plane section it ships, in a stable order', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
expect(slots.entries('settings.plugin.item').map(entry => entry.options.id))
.toEqual(['bash', 'agent-loop', 'web-search'])
})
it('injects a live card count and one business face per card', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = slots.entries('settings.section')[0]!
expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 })
for (const entry of slots.entries('settings.plugin.item')) {
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
// Each card injects exactly one snapshot store plus its own actions.
expect(Object.keys(face.hooks)).toHaveLength(1)
}
})
it('re-reads the credential when the Host reports the watched reference changed', async () => {
const { ctx, slots, describeCredentials } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
describeCredentials.mockClear()
// A key written on another surface changes no settings section, so this
// event is the only thing that reaches the card.
ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
})
it('ignores a credential change for a reference no card watches', async () => {
const { ctx, slots, describeCredentials } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
describeCredentials.mockClear()
ctx.remote.$dispatch('credentials/updated', ['SOME_OTHER_KEY'])
await Promise.resolve()
expect(describeCredentials).not.toHaveBeenCalled()
})
it('registers into a declaration that arrives after apply', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
declareRoot(slots)
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
})
it('collapses every contribution on teardown', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('settings.plugin.item')).toHaveLength(3)
await fiber.dispose()
expect(slots.entries('settings.section')).toHaveLength(0)
expect(slots.spec('settings.plugin.item')).toBeUndefined()
})
})

View File

@@ -0,0 +1,156 @@
// @vitest-environment jsdom
/**
* Field-control behavior: what a control renders for a staged draft, how an
* overridden field offers its reset, and that a control never writes on its own.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SecretField, ValueField } from '../src/client/fields.tsx'
afterEach(cleanup)
const frame = {
id: 'field',
label: 'Command timeout',
hint: 'How long one command may run.',
overriddenLabel: 'Overridden',
resetLabel: 'Reset to default',
invalidLabel: 'Enter a number.',
disabled: false,
overridden: false,
invalid: false,
}
describe('ValueField', () => {
it('stages every keystroke without writing', () => {
const onEdit = vi.fn()
render(<ValueField {...frame} text="60000" onEdit={onEdit} onReset={vi.fn()} />)
fireEvent.change(screen.getByLabelText('Command timeout'), { target: { value: '9000' } })
expect(onEdit).toHaveBeenCalledWith('9000')
})
it('renders the staged text it is given rather than a draft of its own', () => {
const { rerender } = render(<ValueField {...frame} text="60000" onEdit={vi.fn()} onReset={vi.fn()} />)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000')
rerender(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
})
it('offers the reset only while an override would stand', () => {
const onReset = vi.fn()
const { rerender } = render(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={onReset} />)
expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull()
rerender(<ValueField {...frame} overridden text="9000" onEdit={vi.fn()} onReset={onReset} />)
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }))
expect(screen.getByText('Overridden')).toBeTruthy()
expect(onReset).toHaveBeenCalledOnce()
})
it('replaces the hint with the reason an invalid draft cannot be saved', () => {
render(<ValueField {...frame} invalid text="soon" onEdit={vi.fn()} onReset={vi.fn()} />)
expect(screen.getByText('Enter a number.')).toBeTruthy()
expect(screen.queryByText('How long one command may run.')).toBeNull()
expect(screen.getByLabelText('Command timeout').getAttribute('aria-invalid')).toBe('true')
})
it('hints a numeric keypad and renders a placeholder when asked', () => {
render(
<ValueField
{...frame}
numeric
placeholder="https://api.deepseek.com"
text=""
onEdit={vi.fn()}
onReset={vi.fn()}
/>,
)
const input = screen.getByLabelText('Command timeout')
expect(input.getAttribute('inputmode')).toBe('numeric')
expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com')
})
it('disables the control and its reset while the document is read-only', () => {
render(<ValueField {...frame} disabled overridden text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
})
})
describe('SecretField', () => {
const secret = {
id: 'key',
label: 'API key',
hint: 'Stored outside the settings file.',
disabled: false,
}
it('stages the draft and never renders it', () => {
const onEdit = vi.fn()
render(
<SecretField
{...secret}
text=""
configured={false}
stateLabel="No key is configured."
onEdit={onEdit}
/>,
)
const input = screen.getByLabelText('API key')
fireEvent.change(input, { target: { value: 'ds-secret' } })
expect(onEdit).toHaveBeenCalledWith('ds-secret')
expect(input).toHaveProperty('type', 'password')
})
it('reports the configured state the Host holds', () => {
const { rerender } = render(
<SecretField
{...secret}
text=""
configured={false}
stateLabel="No key is configured."
onEdit={vi.fn()}
/>,
)
expect(screen.getByText('No key is configured.')).toBeTruthy()
rerender(
<SecretField
{...secret}
text="ds-secret"
configured
stateLabel="A key is configured."
onEdit={vi.fn()}
/>,
)
expect(screen.getByText('A key is configured.')).toBeTruthy()
expect(screen.getByLabelText('API key')).toHaveProperty('value', 'ds-secret')
})
it('disables the control when it is told to', () => {
render(
<SecretField
{...secret}
disabled
text=""
configured
stateLabel="A key is configured."
onEdit={vi.fn()}
/>,
)
expect(screen.getByLabelText('API key')).toHaveProperty('disabled', true)
})
})

View File

@@ -0,0 +1,25 @@
/** The package's node half: an empty host body and an explained empty invariant companion. */
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as PluginConfigInvariant from '@deepseek-ai/dsh-client-ui-plugin-config/invariant'
describe('invariant companion', () => {
it('reserves package ownership with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(PluginConfigInvariant).await()).resolves.toBeDefined()
})
it('has an empty node half', async () => {
const { apply } = await import('@deepseek-ai/dsh-client-ui-plugin-config')
// The host body exists only so the plugin appears in the host cordis.yml;
// every surface this package ships lives in the browser half.
apply()
expect(typeof apply).toBe('function')
})
})

View File

@@ -0,0 +1,325 @@
// @vitest-environment jsdom
/**
* What the section and its cards show: the empty line when no plugin
* contributed one, a card that renders nothing while its namespace is
* unavailable, and the save footer that decides when staged edits are written.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
import { BashCard } from '../src/client/BashCard.tsx'
import type { BashCardProps } from '../src/client/BashCard.tsx'
import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx'
import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx'
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
import type { BashCardState } from '../src/client/bash-store.ts'
import type { CardFieldState, CardShell } from '../src/client/card-store.ts'
import type { WebSearchCardState } from '../src/client/web-search-store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const t = (key: keyof typeof en) => en[key]
/** A settled form: nothing staged, everything served. */
const settled: CardShell = {
available: true,
writable: true,
dirty: false,
invalid: false,
saving: false,
failed: false,
}
/** One control's state, defaulting to an inherited value. */
function field(text: string, rest: Partial<CardFieldState> = {}): CardFieldState {
return { text, overridden: false, invalid: false, ...rest }
}
function cardActions() {
return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
}
function renderSection(cardCount: number, cards = 'cards') {
const props = {
t,
cardCount,
renderSlot: () => <li>{cards}</li>,
} as unknown as PluginConfigSectionProps
render(<PluginConfigSection {...props} />)
}
function renderBash(state: Partial<BashCardState> = {}) {
const store = createSnapshotStore<BashCardState>({
...settled,
timeoutMs: field('60000'),
maxOutputBytes: field('64000'),
...state,
})
const actions = cardActions()
const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
render(<BashCard {...props} />)
return actions
}
describe('PluginConfigSection', () => {
it('says so when no plugin contributed a card', () => {
renderSection(0)
expect(screen.getByText(en.empty)).toBeTruthy()
expect(screen.queryByText('cards')).toBeNull()
})
it('renders the card list once a plugin contributed one', () => {
renderSection(1)
expect(screen.getByText('cards')).toBeTruthy()
expect(screen.queryByText(en.empty)).toBeNull()
})
it('leads with its own heading and intro', () => {
renderSection(1)
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
expect(screen.getByText(en.intro)).toBeTruthy()
})
})
describe('BashCard', () => {
it('renders nothing while its namespace is unavailable', () => {
const { container } = render(<div />)
renderBash({ available: false })
expect(container.textContent).toBe('')
expect(screen.queryByText(en.bashTitle)).toBeNull()
})
it('shows the plugin and reveals its fields only once expanded', () => {
renderBash()
expect(screen.getByText(en.bashTitle)).toBeTruthy()
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
fireEvent.click(screen.getByText(en.bashTitle))
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
})
it('stages an edit instead of writing it', () => {
const actions = renderBash()
fireEvent.click(screen.getByText(en.bashTitle))
fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } })
expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000')
expect(actions.save).not.toHaveBeenCalled()
})
it('offers the reset for an overridden field only', () => {
const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) })
fireEvent.click(screen.getByText(en.bashTitle))
// One badge and one reset: the output cap is still inherited.
expect(screen.getAllByText(en.overridden)).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: en.reset }))
expect(actions.resetField).toHaveBeenCalledWith('timeoutMs')
})
it('addresses each of its two fields separately', () => {
const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) })
fireEvent.click(screen.getByText(en.bashTitle))
fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } })
fireEvent.click(screen.getByRole('button', { name: en.reset }))
expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024')
expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes')
})
it('keeps save and discard inert until something is staged', () => {
renderBash()
fireEvent.click(screen.getByText(en.bashTitle))
expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
expect(screen.queryByText(en.unsaved)).toBeNull()
})
it('writes the staged edits when saved, and drops them when discarded', () => {
const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) })
fireEvent.click(screen.getByText(en.bashTitle))
fireEvent.click(screen.getByRole('button', { name: en.save }))
fireEvent.click(screen.getByRole('button', { name: en.discard }))
expect(actions.save).toHaveBeenCalledOnce()
expect(actions.discard).toHaveBeenCalledOnce()
})
it('marks a card holding unsaved edits, collapsed or not', () => {
renderBash({ dirty: true })
expect(screen.getByText(en.unsaved)).toBeTruthy()
})
it('blocks the save while a draft is invalid, and says why', () => {
renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) })
fireEvent.click(screen.getByText(en.bashTitle))
expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', false)
expect(screen.getByText(en.invalidNumber)).toBeTruthy()
})
it('reports a save in flight and refuses another', () => {
renderBash({ dirty: true, saving: true })
fireEvent.click(screen.getByText(en.bashTitle))
expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
})
it('reports a save the deployment did not accept', () => {
renderBash({ dirty: true, failed: true })
fireEvent.click(screen.getByText(en.bashTitle))
expect(screen.getByText(en.saveFailed)).toBeTruthy()
})
it('says the document is read-only and disables its controls', () => {
renderBash({ writable: false })
fireEvent.click(screen.getByText(en.bashTitle))
expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly)
expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true)
})
it('collapses again on a second click', () => {
renderBash()
fireEvent.click(screen.getByText(en.bashTitle))
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
fireEvent.click(screen.getByText(en.bashTitle))
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
})
})
describe('AgentLoopCard', () => {
it('stages and saves the only field it owns', () => {
const store = createSnapshotStore<AgentLoopCardState>({
...settled,
dirty: true,
maxParallelToolCalls: field('10'),
})
const actions = cardActions()
const props = {
...actions,
t,
useAgentLoopCard: bindSnapshotSelector(store),
} as unknown as AgentLoopCardProps
render(<AgentLoopCard {...props} />)
fireEvent.click(screen.getByText(en.agentLoopTitle))
fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } })
fireEvent.click(screen.getByRole('button', { name: en.save }))
expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2')
expect(actions.save).toHaveBeenCalledOnce()
})
it('stages a reset for the field it owns', () => {
const store = createSnapshotStore<AgentLoopCardState>({
...settled,
maxParallelToolCalls: field('2', { overridden: true }),
})
const actions = cardActions()
const props = {
...actions,
t,
useAgentLoopCard: bindSnapshotSelector(store),
} as unknown as AgentLoopCardProps
render(<AgentLoopCard {...props} />)
fireEvent.click(screen.getByText(en.agentLoopTitle))
fireEvent.click(screen.getByRole('button', { name: en.reset }))
expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls')
})
})
describe('WebSearchCard', () => {
function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
const store = createSnapshotStore<WebSearchCardState>({
...settled,
baseURL: field(''),
maxUses: field('5'),
apiKey: field(''),
apiKeyConfigured: false,
apiKeyWritable: true,
...state,
})
const actions = cardActions()
const props = { ...actions, t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps
render(<WebSearchCard {...props} />)
return actions
}
it('reports whether a key is configured without ever showing one', () => {
renderWebSearch({ apiKeyConfigured: true })
fireEvent.click(screen.getByText(en.webSearchTitle))
expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy()
expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password')
})
it('keeps the key control usable while the settings document is read-only', () => {
const actions = renderWebSearch({ writable: false })
fireEvent.click(screen.getByText(en.webSearchTitle))
const key = screen.getByLabelText(en.webSearchApiKey)
expect(key).toHaveProperty('disabled', false)
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
fireEvent.change(key, { target: { value: 'ds-secret' } })
expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
})
it('disables the key control when the reference itself is not writable', () => {
// A key coming from the process environment: the settings document is
// writable, the credential is not.
renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false })
fireEvent.click(screen.getByText(en.webSearchTitle))
expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true)
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false)
})
it('stages the endpoint, the search budget, and their resets', () => {
const actions = renderWebSearch({
baseURL: field('https://search.test/v1', { overridden: true }),
maxUses: field('3', { overridden: true }),
})
fireEvent.click(screen.getByText(en.webSearchTitle))
fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } })
fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } })
const resets = screen.getAllByRole('button', { name: en.reset })
expect(resets).toHaveLength(2)
for (const reset of resets) fireEvent.click(reset)
expect(actions.edit.mock.calls).toEqual([
['baseURL', 'https://other.test'],
['maxUses', '4'],
])
expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
})
})

View File

@@ -0,0 +1,540 @@
/**
* The staged card form: what a draft shows before it is written, which wire
* call a save reaches, and what happens to drafts the Host did not accept.
*/
import { describe, expect, it, vi } from 'vitest'
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { CardForm, numberField, textField } from '../src/client/card-store.ts'
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts'
import { BashCardController, type BashSettings } from '../src/client/bash-store.ts'
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts'
/** Make the stub behave like a Host that accepts every write. */
function acceptWrites<T>(host: StubSettingsScope<T>): void {
const section = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().value as object })
const layer = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().user as object })
host.set.mockImplementation((field: string, value: unknown) => {
host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } })
})
host.unset.mockImplementation((field: string) => {
const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field))
const base = host.scope.getSnapshot().base as Record<string, unknown> | undefined
host.publish({ value: { ...section(), [field]: base?.[field] } as T, user })
})
}
function credentialsApi(configured: boolean) {
const describe = vi.fn(() => Promise.resolve({
rpcId: 'c-1' as never,
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured, writable: true } } } },
}))
const set = vi.fn(() => Promise.resolve({ rpcId: 'c-2' as never, result: { ok: true as const, value: {} } }))
return { api: { credentials: { describe, set } } as never, describe, set }
}
describe('CardForm', () => {
function form() {
const host = stubSettingsScope<Record<string, unknown>>()
const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
host.publish({
status: 'ready',
writable: true,
value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
user: {},
})
return { host, subject }
}
it('shows the effective value and stays clean until something is staged', () => {
const { subject } = form()
expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false })
})
it('marks a field the user layer carries as overridden', () => {
const { host, subject } = form()
host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } })
// An override equal to the composition default is still an override.
expect(subject.field('timeoutMs').overridden).toBe(true)
})
it('writes nothing until the form is saved', async () => {
const { host, subject } = form()
acceptWrites(host)
subject.actions().edit('timeoutMs', '9000')
expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false })
expect(subject.shell().dirty).toBe(true)
expect(host.set).not.toHaveBeenCalled()
await subject.save()
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]])
expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false })
})
it('drops a draft that settles back on the value already shown', async () => {
const { host, subject } = form()
subject.actions().edit('timeoutMs', '9000')
subject.actions().edit('timeoutMs', '60000')
expect(subject.shell().dirty).toBe(false)
await subject.save()
expect(host.set).not.toHaveBeenCalled()
})
it('refuses to save while a draft is not a value the field accepts', async () => {
const { host, subject } = form()
subject.actions().edit('timeoutMs', 'soon')
expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true })
expect(subject.shell()).toMatchObject({ dirty: true, invalid: true })
await subject.save()
expect(host.set).not.toHaveBeenCalled()
expect(subject.field('timeoutMs').text).toBe('soon')
})
it('stages a reset that clears the field only once saved', async () => {
const { host, subject } = form()
acceptWrites(host)
host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } })
subject.actions().resetField('timeoutMs')
// The badge previews the save: the field will no longer be overridden.
expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
expect(host.unset).not.toHaveBeenCalled()
await subject.save()
expect(host.unset.mock.calls).toEqual([['timeoutMs']])
expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
})
it('treats resetting an inherited field as no change at all', async () => {
const { host, subject } = form()
subject.actions().resetField('timeoutMs')
expect(subject.shell().dirty).toBe(false)
await subject.save()
expect(host.unset).not.toHaveBeenCalled()
})
it('clears a number field by emptying it', async () => {
const { host, subject } = form()
acceptWrites(host)
host.publish({ user: { timeoutMs: 9_000 } })
subject.actions().edit('timeoutMs', '')
expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false })
await subject.save()
expect(host.unset.mock.calls).toEqual([['timeoutMs']])
})
it('clears a text field by emptying it', async () => {
const { host, subject } = form()
acceptWrites(host)
host.publish({ user: { baseURL: 'https://search.test/v1' } })
subject.actions().edit('baseURL', ' ')
await subject.save()
expect(host.unset.mock.calls).toEqual([['baseURL']])
})
it('writes the trimmed text of a text field', async () => {
const { host, subject } = form()
acceptWrites(host)
subject.actions().edit('baseURL', ' https://other.test ')
await subject.save()
expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']])
})
it('keeps the drafts a save did not land, and reports the failure', async () => {
const { host, subject } = form()
subject.actions().edit('timeoutMs', '9000')
await subject.save()
// The stub Host accepted the call without storing it, exactly as a
// validator that refuses the value does.
expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000)
expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false })
expect(subject.field('timeoutMs').text).toBe('9000')
})
it('reports a reset the Host did not apply as a failure', async () => {
const { host, subject } = form()
host.publish({ user: { timeoutMs: 9_000 } })
subject.actions().resetField('timeoutMs')
await subject.save()
expect(host.unset).toHaveBeenCalledWith('timeoutMs')
expect(subject.shell().failed).toBe(true)
})
it('clears the failure as soon as the user edits again', async () => {
const { subject } = form()
subject.actions().edit('timeoutMs', '9000')
await subject.save()
expect(subject.shell().failed).toBe(true)
subject.actions().edit('timeoutMs', '9001')
expect(subject.shell().failed).toBe(false)
})
it('discards every staged edit', async () => {
const { host, subject } = form()
subject.actions().edit('timeoutMs', '9000')
subject.actions().discard()
expect(subject.field('timeoutMs').text).toBe('60000')
expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
// A discard with nothing staged publishes nothing.
const before = subject.shell()
subject.actions().discard()
expect(subject.shell()).toEqual(before)
await subject.save()
expect(host.set).not.toHaveBeenCalled()
})
it('refuses a second save while one is in flight', async () => {
const { host, subject } = form()
acceptWrites(host)
subject.actions().edit('timeoutMs', '9000')
const first = subject.save()
expect(subject.shell().saving).toBe(true)
const second = subject.save()
await Promise.all([first, second])
expect(host.set).toHaveBeenCalledTimes(1)
})
it('publishes a projection whenever the scope or a draft changes', () => {
const { host, subject } = form()
const store = subject.bind(() => subject.field('timeoutMs').text)
expect(store.getSnapshot()).toBe('60000')
host.publish({ value: { timeoutMs: 1_000 } })
expect(store.getSnapshot()).toBe('1000')
subject.actions().edit('timeoutMs', '2000')
expect(store.getSnapshot()).toBe('2000')
})
it('refuses to address a field the card never declared', () => {
const { subject } = form()
expect(() => subject.field('nope')).toThrow('plugin card has no field nope')
})
it('renders an absent section value as an empty draft', () => {
const host = stubSettingsScope<Record<string, unknown>>()
const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined })
expect(subject.field('timeoutMs').text).toBe('')
expect(subject.field('baseURL').text).toBe('')
expect(subject.shell().available).toBe(true)
})
it('stays unavailable while the namespace is not served', () => {
const host = stubSettingsScope<Record<string, unknown>>()
const subject = new CardForm(host.scope, [numberField('timeoutMs')])
host.publish({ status: 'unavailable' })
expect(subject.shell()).toMatchObject({ available: false, writable: false })
})
})
describe('BashCardController', () => {
it('projects both fields and saves them in one write pass', async () => {
const host = stubSettingsScope<BashSettings>()
acceptWrites(host)
const controller = new BashCardController(host.scope)
host.publish({
status: 'ready',
writable: true,
value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
user: { timeoutMs: 5_000 },
})
const face = controller.inject()
expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
available: true,
writable: true,
dirty: false,
timeoutMs: { text: '5000', overridden: true },
maxOutputBytes: { text: '64000', overridden: false },
})
face.edit('timeoutMs', '9000')
face.edit('maxOutputBytes', '1024')
expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true)
face.save()
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false)
})
it('stages a reset and applies it on save', async () => {
const host = stubSettingsScope<BashSettings>()
acceptWrites(host)
const controller = new BashCardController(host.scope)
host.publish({
status: 'ready',
writable: true,
value: { timeoutMs: 5_000 },
base: { timeoutMs: 60_000 },
user: { timeoutMs: 5_000 },
})
const face = controller.inject()
face.resetField('timeoutMs')
expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000')
face.save()
await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') })
expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
dirty: false,
timeoutMs: { text: '60000', overridden: false },
})
})
it('discards staged edits without writing', () => {
const host = stubSettingsScope<BashSettings>()
const controller = new BashCardController(host.scope)
host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} })
const face = controller.inject()
face.edit('timeoutMs', '9000')
face.discard()
expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000')
expect(host.set).not.toHaveBeenCalled()
})
})
describe('AgentLoopCardController', () => {
it('saves the only field it owns', async () => {
const host = stubSettingsScope<AgentLoopSettings>()
acceptWrites(host)
const controller = new AgentLoopCardController(host.scope)
host.publish({
status: 'ready',
writable: true,
value: { maxParallelToolCalls: 10 },
base: { maxParallelToolCalls: 10 },
user: {},
})
const face = controller.inject()
face.edit('maxParallelToolCalls', '4')
face.save()
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) })
expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({
dirty: false,
maxParallelToolCalls: { text: '4', overridden: true },
})
})
it('reports a read-only document so the card can disable its controls', () => {
const host = stubSettingsScope<AgentLoopSettings>()
const controller = new AgentLoopCardController(host.scope)
host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false)
})
})
describe('WebSearchCardController', () => {
it('reads the credential state for the reference the section names', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const credentials = credentialsApi(true)
const controller = new WebSearchCardController(host.scope, credentials.api)
const state = () => controller.inject().hooks.webSearchCard.getSnapshot()
await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) })
expect(state()).toMatchObject({
baseURL: { text: 'https://search.test/v1', overridden: false },
apiKey: { text: '', overridden: false },
})
})
it('writes the staged key through the credentials domain, never the settings section', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const credentials = credentialsApi(false)
const controller = new WebSearchCardController(host.scope, credentials.api)
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
const face = controller.inject()
face.edit('apiKey', ' ds-secret ')
expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true)
expect(credentials.set).not.toHaveBeenCalled()
credentials.describe.mockImplementation(() => Promise.resolve({
rpcId: 'c-1' as never,
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
}))
face.save()
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' })
expect(host.set).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true })
})
})
it('keeps the stored key when the draft is left blank', () => {
const host = stubSettingsScope<WebSearchSettings>()
const credentials = credentialsApi(true)
const controller = new WebSearchCardController(host.scope, credentials.api)
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
const face = controller.inject()
face.edit('apiKey', ' ')
expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false)
face.save()
expect(credentials.set).not.toHaveBeenCalled()
})
it('re-reads when the Host reports the watched reference changed', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const credentials = credentialsApi(false)
const controller = new WebSearchCardController(host.scope, credentials.api)
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
credentials.describe.mockClear()
// Another reference is not this card's business.
controller.refreshCredential('OTHER_KEY')
expect(credentials.describe).not.toHaveBeenCalled()
// A key written on another surface reaches this card only through this signal.
credentials.describe.mockImplementation(() => Promise.resolve({
rpcId: 'c-1' as never,
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
}))
controller.refreshCredential('DEEPSEEK_API_KEY')
await vi.waitFor(() => {
expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true)
})
})
it('addresses the reference the section declares rather than the default', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const credentials = credentialsApi(false)
const controller = new WebSearchCardController(host.scope, credentials.api)
host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} })
const face = controller.inject()
face.edit('apiKey', 'ds-secret')
face.save()
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' })
})
it('reports a key the Host did not store as a failed save', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const credentials = credentialsApi(false)
const controller = new WebSearchCardController(host.scope, credentials.api)
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
const face = controller.inject()
face.edit('apiKey', 'ds-secret')
face.save()
await vi.waitFor(() => {
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
})
})
it('keeps the card usable when the credential read fails', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const describe = vi.fn(() => Promise.reject(new Error('offline')))
const set = vi.fn(() => Promise.reject(new Error('offline')))
const controller = new WebSearchCardController(host.scope, { credentials: { describe, set } } as never)
const face = controller.inject()
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
face.edit('apiKey', 'ds-secret')
face.save()
await vi.waitFor(() => { expect(set).toHaveBeenCalled() })
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({
available: true,
apiKeyConfigured: false,
baseURL: { text: 'https://search.test/v1' },
})
})
it('ignores a credential read the Host refused', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const describe = vi.fn(() => Promise.resolve({
rpcId: 'c-1' as never,
result: { ok: false as const, error: { code: 'credentials-unavailable', message: 'no provider' } },
}))
const controller = new WebSearchCardController(host.scope, { credentials: { describe, set: vi.fn() } } as never)
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false)
})
it('saves the endpoint and the search budget together', async () => {
const host = stubSettingsScope<WebSearchSettings>()
acceptWrites(host)
const credentials = credentialsApi(true)
const controller = new WebSearchCardController(host.scope, credentials.api)
host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} })
const face = controller.inject()
face.edit('baseURL', 'https://other.test')
face.edit('maxUses', '3')
face.save()
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]])
expect(credentials.set).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,45 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../runtime"
},
{
"path": "../test-runtime"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-settings"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../ui-slots"
},
{
"path": "../web-react"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-plugin-config', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -14,7 +14,8 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16,
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16,
IconPersonalizationOutline16, IconSettingsOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts'
import css from './SettingsRoot.module.css'
@@ -23,6 +24,7 @@ import css from './SettingsRoot.module.css'
function navIcon(id: string) {
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconAgentPresetOutline16 className={css.navIcon} size={16} />
if (id === 'plugins') return <IconPersonalizationOutline16 className={css.navIcon} size={16} />
return <IconSettingsOutline16 className={css.navIcon} size={16} />
}

View File

@@ -170,6 +170,28 @@ describe('SettingsPanel navigation', () => {
expect(screen.getByTestId('section-general')).toBeTruthy()
})
it('gives every section a nav glyph, distinct for the ids the shell knows', () => {
mount({
rows: [
{ id: 'general', order: 0, label: 'General' },
{ id: 'models', order: 10, label: 'Models' },
{ id: 'agent-presets', order: 20, label: 'Agent presets' },
{ id: 'plugins', order: 30, label: 'Plugins' },
{ id: 'contributed', order: 40, label: 'Contributed' },
],
})
openPanel()
// Glyphs carry no id of their own, so the drawn paths are what tells them apart.
const glyphs = ['General', 'Models', 'Agent presets', 'Plugins', 'Contributed']
.map(name => screen.getByRole('button', { name }).querySelector('svg')?.innerHTML)
expect(glyphs.every(glyph => glyph !== undefined && glyph !== '')).toBe(true)
// The three ids the shell names get their own glyph; every other section —
// including one this package never heard of — shares the gear.
expect(new Set(glyphs.slice(0, 4)).size).toBe(4)
expect(glyphs[4]).toBe(glyphs[0])
})
it('switches the rendered section on nav click', () => {
mount()
openPanel()

View File

@@ -77,7 +77,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
}
}
/** Owner share of a General preference row (the section supplies nothing). */
export interface SettingsGeneralItemOwnerProps {
/** Marker field: item owner props are intentionally empty. */

View File

@@ -8,7 +8,7 @@
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView,
ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView,
} from '@deepseek-ai/dsh-client-connection/client'
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
import {
@@ -59,6 +59,8 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
status: persistence === 'host' ? 'loading' : 'unavailable',
value: undefined,
base: undefined,
user: undefined,
revision: undefined,
writable: false,
mode: persistence,
@@ -96,6 +98,20 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void> {
return this.write({ op: 'set', path: [field], value })
}
/**
* Queue one field clear; see {@link SettingsScope.unset} for the ordering,
* revision, and recovery contract.
* @param field - scalar field inside the namespace section.
* @returns settlement after the clear and any latest-write recovery read.
*/
unset(field: string): Promise<void> {
return this.write({ op: 'unset', path: [field] })
}
private write(op: SettingsPathOpView): Promise<void> {
this.readGeneration += 1
const generation = ++this.writeGeneration
return this.enqueue(async () => {
@@ -104,7 +120,7 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
try {
response = await this.api.settings.mutate({
ns: this.spec.namespace,
ops: [{ op: 'set', path: [field], value }],
ops: [op],
...(revision === undefined ? {} : { expectedRevision: revision }),
})
} catch (_settingsWriteFailure) {
@@ -169,6 +185,8 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
const decoded = publish ? this.decode(view) : undefined
this.store.update((draft) => {
draft.revision = view.revision
draft.base = view.base
draft.user = view.user
if (writable !== undefined) draft.writable = writable
if (decoded === undefined) return
draft.status = 'ready'

View File

@@ -293,8 +293,78 @@ describe('SettingsScopeController', () => {
expect(describeCall).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
})
it('carries the composition base and the user layer into the snapshot', async () => {
const layered: SettingsNamespaceView = {
...view({ preference: 'dark' }, 3),
base: { preference: 'system' },
user: { preference: 'dark' },
}
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] }))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({
value: { preference: 'dark' },
base: { preference: 'system' },
user: { preference: 'dark' },
})
})
it('reports an inherited field as absent from the user layer', async () => {
const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } }
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] }))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot().user).toBeUndefined()
})
it('clears one field through an unset op fenced by the held revision', async () => {
const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4)))
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
await scope.unset('preference')
expect(mutate).toHaveBeenCalledWith({
ns: 'ui-test',
ops: [{ op: 'unset', path: ['preference'] }],
expectedRevision: 3,
})
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 4 })
})
it('recovers the Host state when the latest clear is refused', async () => {
const mutate = vi.fn().mockResolvedValueOnce(rejected())
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
.mockResolvedValueOnce(described({ preference: 'light' }, 5))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
await scope.unset('preference')
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 })
})
})
describe('SettingsScopeService.bind', () => {
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
const initial = deferred<ReturnType<typeof described>>()