refactor(client-ui-plugin-config): stage card edits behind an explicit save

Controls committed on blur, which turned leaving a field into a durable,
revision-fenced document write the user could neither preview nor undo, and
silently discarded a draft the field did not accept.

A card's form now owns the staged text every control renders, and Save is the
only point where drafts become writes. Reset stages the composed default the
same way; an invalid draft blocks the save with its reason instead of being
dropped; Discard drops the drafts; a collapsed card marks that it holds some.
The Host stays the only authority on whether a value was accepted, so the save
reads the section back and keeps the drafts of a save that did not land.
This commit is contained in:
Yichen Jiang
2026-08-10 22:51:12 +08:00
parent dae6cad065
commit 5d3f392cd3
23 changed files with 1352 additions and 787 deletions
@@ -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 .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md
2026-08-10-web-plugin-configuration.md: 146dac0684a783b170614c3320485dd8f2127a66
2026-08-10-web-plugin-configuration.zh.md: b0a1b1b91fc5a1bc0e3162c3e72d28ea057d44da
2026-08-10-web-plugin-configuration.md: cfc7108d3f241ba91c102e32f58b0b4fc2966f0c
2026-08-10-web-plugin-configuration.zh.md: cb6c5e0903a2f3034ff365fc50dd78e4fb655a06
@@ -26,6 +26,8 @@ Three host-plane plugins register their own settings namespace, and one browser-
**The section knows no namespace.** `dsh-client-ui-plugin-config` declares a `settings.plugin.item` slot and renders the cards registered into it, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw `user` layer, whose key PRESENCE is what marks a field overridden, and `unset`, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it.
**A card stages its edits and writes them on save.** Controls hold no draft of their own: the card's form owns the staged text, every control renders it, and only **Save** turns it into document mutations. A settings write is durable and revision-fenced, so a control that committed as it settled spent a revision on a value the user had not decided to store and could not preview; the reset stages the composed default the same way. Because the Host's validators own the constraints no schema can express, the form reads the section back after writing and reports a save that did not land instead of predicting the outcome, keeping those drafts for the user to correct. The credential control is staged with the rest even though it writes through the credentials domain, so one save covers everything the card shows.
## Alternatives considered
- **A registration-time exposure declaration replacing the allowlist.** The honest shape — the namespace's owner declares its own exposure, and a plugin distributed outside this repository can surface its configuration without a change in `packages/host/apiproxy`. Deferred because it changes the seam contract, every existing registration site, and the anti-enumeration semantics at once, and because a plugin exposing an arbitrary schema needs a fail-closed redaction path first: a secret reachable only through a union or transform is currently returned verbatim.
@@ -33,6 +35,8 @@ Three host-plane plugins register their own settings namespace, and one browser-
- **Editing preset-mounted plugins from this page.** Out of scope, and not merely unbuilt: a preset's rows carry their configuration inline in `agent.cordis.yml` and cannot register a settings namespace at all, because a second session mounting the same preset would fail on a duplicate registration. A user layer shared across presets would also overwrite the fields a preset uses to define its agent's identity — its persona text, its delegation wiring — which are per-preset by design.
- **One namespace per executor package instead of the capability-named `bash`.** Declined because the composed executor differs by platform while the settings document does not: a user who set a timeout on macOS would silently lose it on Windows.
- **Writing the search key into the settings section.** Declined because the literal would then have to ride a `describe` response to be rendered. The card reports only whether a key is configured and writes through the credentials domain, addressed by the reference the section names.
- **Committing each control as it settles, with no save.** Built first, and replaced: blur is not a decision. It spent a namespace revision per control, gave the user nothing to preview or undo before the write, and left an invalid draft silently discarded — a value the Host's validator refuses simply snapped back with no reason given. One save per card makes the write a gesture the user performs.
- **Validating the fields in the browser to keep the save honest.** Declined: the constraints live in the owning plugin's section validator, and restating them here would make two homes for one rule that could disagree per release. The card checks only what its own control can decide — that a numeric draft is a number — and lets the Host answer for the rest, which is why the save reads the section back.
## Consequences
@@ -26,6 +26,8 @@ Status: implemented
**该分区不认识任何命名空间。** `dsh-client-ui-plugin-config` 声明 `settings.plugin.item` slot 并渲染注册进来的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 `user` 层——键的**存在**才标记字段被覆盖——以及把单个字段清回组装层的 `unset`。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。
**卡片暂存修改,保存时才写入。** 控件不持有自己的草稿:暂存文本归卡片的表单所有,所有控件渲染的都是它,只有**保存**才把它变成文档变更。settings 写入是持久且带 revision 栅栏的,因此「失焦即提交」的控件会为用户尚未决定存储、也无从预览的值花掉一个 revision;重置同样只是暂存组装默认值。schema 表达不了的约束归 Host 的校验器所有,所以表单在写入后回读分节、报告没有落盘的保存,而不是自行预测结果,并保留这些草稿供用户修改。密钥控件虽然经由 credentials 领域写入,也和其余字段一起暂存,因此一次保存覆盖卡片上的全部内容。
## 备选方案
- **用注册期的暴露声明取代白名单。** 这才是诚实的形状——命名空间的拥有方声明自己的暴露,在本仓库之外分发的插件也无需改动 `packages/host/apiproxy` 就能呈现自己的配置。之所以暂缓,是因为它会同时改变 seam 契约、全部现有注册点与防枚举语义;而且插件要暴露任意 schema,还得先有 fail-closed 的脱敏路径:目前只能经由 union 或 transform 抵达的 secret 会被原样返回。
@@ -33,6 +35,8 @@ Status: implemented
- **在本页编辑 preset 挂载的插件。** 超出范围,而且不只是「尚未实现」:preset 的行把配置内联在 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间——同一 preset 挂载第二个会话时会因重复注册而失败。跨 preset 共享的用户层还会覆盖 preset 用来定义其 agent 身份的字段——人设文本、委派接线——而这些字段按设计就是各 preset 各自的。
- **按执行器包各取一个命名空间,而非按能力命名的 `bash`。** 否决,因为被组装的执行器随平台不同,而设置文档不随平台不同:在 macOS 上设过超时的用户,到 Windows 上会悄无声息地失去它。
- **把搜索密钥写进 settings 分节。** 否决,因为那样字面值就必须搭乘 `describe` 响应才能被渲染。卡片只报告是否已配置密钥,并按分节所命名的引用经由 credentials 领域写入。
- **每个控件失焦即提交,不设保存。** 最初就是这么做的,后被替换:失焦不是决定。它每个控件花掉一个命名空间 revision,写入前不给用户任何预览或撤销的余地,还会把无效草稿悄悄丢弃——被 Host 校验器拒绝的值只是弹回原样,不给任何理由。每张卡片一个保存,才让写入成为用户执行的动作。
- **在浏览器端校验字段,好让保存诚实。** 否决:这些约束住在拥有方插件的分节校验器里,在这里重述一遍就会让同一条规则有两个家,且可能随版本各说各话。卡片只判断自己的控件能判断的事——数字草稿是不是数字——其余交给 Host 回答,这正是保存要回读分节的原因。
## 影响
+48 -2
View File
@@ -85,7 +85,7 @@ describe('web e2e: plugin configuration section', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('writes an edited field to the settings document and marks it overridden', async () => {
it('stages an edit and writes it only when saved', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
@@ -97,12 +97,52 @@ describe('web e2e: plugin configuration section', () => {
await timeout.fill('12000')
await timeout.blur()
// Nothing crosses the wire until the user saves: leaving the control is
// not a decision to store the value.
expect(await settingsDocument()).not.toContain('timeoutMs')
const save = dialog.getByRole('button', { name: '保存', exact: true })
await expect.poll(() => save.isEnabled(), { timeout: 5_000 }).toBe(true)
await save.click()
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 })
.toBe(true)
// Presence in the user layer is what the badge reports, and the reset is
// offered only for a field that has one.
await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1)
expect(await dialog.getByRole('button', { name: '恢复默认' }).count()).toBe(1)
// A settled form offers no save to repeat.
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('drops a staged edit on discard without touching the document', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-discard'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
const timeout = dialog.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
await timeout.fill('7000')
await dialog.getByRole('button', { name: '放弃修改' }).click()
await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('12000')
expect(await settingsDocument()).toContain('timeoutMs: 12000')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('refuses to save a draft that is not a number', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-invalid'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
const timeout = dialog.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
await timeout.fill('soon')
const save = dialog.getByRole('button', { name: '保存', exact: true })
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
expect(await dialog.getByText('请填数字;留空表示使用默认值。').count()).toBe(1)
await dialog.getByRole('button', { name: '放弃修改' }).click()
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
@@ -114,11 +154,17 @@ describe('web e2e: plugin configuration section', () => {
await timeout.waitFor({ timeout: 10_000 })
expect(await timeout.inputValue()).toBe('12000')
// The reset stages the composed default; the document still carries the
// override until the save lands.
await dialog.getByRole('button', { name: '恢复默认' }).click()
await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('60000')
expect(await settingsDocument()).toContain('timeoutMs: 12000')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 })
.toBe(false)
await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('60000')
expect(await timeout.inputValue()).toBe('60000')
expect(await dialog.getByText('已覆盖').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
@@ -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-plugin-config/README.md
README.md: e830589b5e279fb0bdda23591502989bebc2a336
README.zh.md: e614d7b6858f8cbf2a38cb7397b5d8f93445ab6c
README.md: 51d86e8fbfd19d3d37f68650163180751fb27061
README.zh.md: 48a68900a20aaaabcc9763b5aa61bc23cf3d16d8
+5 -1
View File
@@ -16,7 +16,11 @@ The section declares `settings.plugin.item`, a root list slot. A plugin that shi
## Writes
Every control writes one field through the client settings scope, which fences each write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. 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 reports only whether one is configured and writes through the credentials domain rather than the settings section.
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 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
@@ -16,7 +16,11 @@
## 写入
每个控件都通过客户端 settings scope 写入单个字段,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件只报告是否已配置,并经由 credentials 领域而非 settings 分节写入
卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃
保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。
字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。
## 模型体验
@@ -1,22 +1,19 @@
/** The agent-loop plugin's card: how many tool calls may run at once. */
/** The agent loop's card: how many tool calls one step may run at once. */
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { NumberField } from './fields.tsx'
import { ValueField } from './fields.tsx'
import { PluginCard } from './PluginCard.tsx'
import type { CardActions } from './card-store.ts'
import type { AgentLoopCardState } from './agent-loop-store.ts'
import type {} from './slot-contract.ts'
/** Registration-side business face for the agent-loop card. */
export interface AgentLoopCardInjected {
export interface AgentLoopCardInjected extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useAgentLoopCard. */
agentLoopCard: SnapshotStore<AgentLoopCardState>
}
/** Write the parallel tool-call cap. */
setMaxParallelToolCalls: (next: number) => void
/** Clear the cap so it re-inherits the composition layer. */
resetMaxParallelToolCalls: () => void
}
/** Props the renderer binds for the agent-loop card. */
@@ -27,32 +24,33 @@ export type AgentLoopCardProps =
/**
* Render the agent-loop card.
* @param props - locale copy, the card snapshot, and its write actions.
* @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)
const disabled = !state.writable
return (
<PluginCard
t={t}
titleKey="agentLoopTitle"
descriptionKey="agentLoopDescription"
available={state.available}
readOnly={disabled}
state={state}
onSave={props.save}
onDiscard={props.discard}
>
<NumberField
<ValueField
id="plugin-config-agent-loop-parallel"
label={t('agentLoopMaxParallel')}
hint={t('agentLoopMaxParallelHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
overridden={state.maxParallelToolCalls.overridden}
disabled={disabled}
value={state.maxParallelToolCalls.value}
onCommit={props.setMaxParallelToolCalls}
onReset={props.resetMaxParallelToolCalls}
invalidLabel={t('invalidNumber')}
numeric
disabled={!state.writable}
{...state.maxParallelToolCalls}
onEdit={(text) => { props.edit('maxParallelToolCalls', text) }}
onReset={() => { props.resetField('maxParallelToolCalls') }}
/>
</PluginCard>
)
@@ -2,25 +2,18 @@
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { NumberField } from './fields.tsx'
import { ValueField } from './fields.tsx'
import { PluginCard } from './PluginCard.tsx'
import type { CardActions } from './card-store.ts'
import type { BashCardState } from './bash-store.ts'
import type {} from './slot-contract.ts'
/** Registration-side business face for the shell card. */
export interface BashCardInjected {
export interface BashCardInjected extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useBashCard. */
bashCard: SnapshotStore<BashCardState>
}
/** Write the foreground command timeout. */
setTimeoutMs: (next: number) => void
/** Clear the timeout so it re-inherits the composition layer. */
resetTimeoutMs: () => void
/** Write the per-stream output cap. */
setMaxOutputBytes: (next: number) => void
/** Clear the output cap so it re-inherits the composition layer. */
resetMaxOutputBytes: () => void
}
/** Props the renderer binds for the shell card. */
@@ -31,7 +24,7 @@ export type BashCardProps =
/**
* Render the shell card.
* @param props - locale copy, the card snapshot, and its write actions.
* @param props - locale copy, the card snapshot, and its form actions.
* @returns the card.
*/
export function BashCard(props: BashCardProps) {
@@ -43,32 +36,35 @@ export function BashCard(props: BashCardProps) {
t={t}
titleKey="bashTitle"
descriptionKey="bashDescription"
available={state.available}
readOnly={disabled}
state={state}
onSave={props.save}
onDiscard={props.discard}
>
<NumberField
<ValueField
id="plugin-config-bash-timeout"
label={t('bashTimeoutMs')}
hint={t('bashTimeoutMsHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
overridden={state.timeoutMs.overridden}
invalidLabel={t('invalidNumber')}
numeric
disabled={disabled}
value={state.timeoutMs.value}
onCommit={props.setTimeoutMs}
onReset={props.resetTimeoutMs}
{...state.timeoutMs}
onEdit={(text) => { props.edit('timeoutMs', text) }}
onReset={() => { props.resetField('timeoutMs') }}
/>
<NumberField
<ValueField
id="plugin-config-bash-output"
label={t('bashMaxOutputBytes')}
hint={t('bashMaxOutputBytesHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
overridden={state.maxOutputBytes.overridden}
invalidLabel={t('invalidNumber')}
numeric
disabled={disabled}
value={state.maxOutputBytes.value}
onCommit={props.setMaxOutputBytes}
onReset={props.resetMaxOutputBytes}
{...state.maxOutputBytes}
onEdit={(text) => { props.edit('maxOutputBytes', text) }}
onReset={() => { props.resetField('maxOutputBytes') }}
/>
</PluginCard>
)
@@ -84,3 +84,74 @@
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;
}
@@ -1,12 +1,13 @@
/**
* One plugin's card: a header naming the plugin and what its settings govern,
* disclosing that plugin's controls in place.
* 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.
* 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
@@ -16,6 +17,7 @@
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'
@@ -27,23 +29,27 @@ export interface PluginCardProps {
titleKey: PluginConfigKey
/** Locale key of the line describing what this plugin's settings govern. */
descriptionKey: PluginConfigKey
/** False while the namespace is not served to this client. */
available: boolean
/** True when the Host document is read-only, which disables the fields. */
readOnly: boolean
/** 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 availability, and its controls.
* @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)
if (!props.available) return null
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
@@ -57,13 +63,33 @@ export function PluginCard(props: PluginCardProps) {
<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}>
{props.readOnly ? <p className={css.readOnly} role="status">{props.t('readOnly')}</p> : null}
{!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}
@@ -6,27 +6,18 @@
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { NumberField, SecretField, TextField } from './fields.tsx'
import { SecretField, ValueField } from './fields.tsx'
import { PluginCard } from './PluginCard.tsx'
import type { CardActions } from './card-store.ts'
import type { WebSearchCardState } from './web-search-store.ts'
import type {} from './slot-contract.ts'
/** Registration-side business face for the web-search card. */
export interface WebSearchCardInjected {
export interface WebSearchCardInjected extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useWebSearchCard. */
webSearchCard: SnapshotStore<WebSearchCardState>
}
/** Write the provider endpoint; the empty string clears it. */
setBaseUrl: (next: string) => void
/** Clear the endpoint so it re-inherits the composition layer. */
resetBaseUrl: () => void
/** Write the per-request search budget. */
setMaxUses: (next: number) => void
/** Clear the budget so it re-inherits the composition layer. */
resetMaxUses: () => void
/** Write the credential the section references. */
setApiKey: (next: string) => void
}
/** Props the renderer binds for the web-search card. */
@@ -37,7 +28,7 @@ export type WebSearchCardProps =
/**
* Render the web-search card.
* @param props - locale copy, the card snapshot, and its write actions.
* @param props - locale copy, the card snapshot, and its form actions.
* @returns the card.
*/
export function WebSearchCard(props: WebSearchCardProps) {
@@ -49,45 +40,46 @@ export function WebSearchCard(props: WebSearchCardProps) {
t={t}
titleKey="webSearchTitle"
descriptionKey="webSearchDescription"
available={state.available}
readOnly={disabled}
state={state}
onSave={props.save}
onDiscard={props.discard}
>
<SecretField
id="plugin-config-web-search-key"
label={t('webSearchApiKey')}
hint={t('webSearchApiKeyHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
// The credentials domain accepts a key even when the settings document
// itself is read-only; they are separate stores with separate refusals.
disabled={false}
text={state.apiKey.text}
configured={state.apiKeyConfigured}
stateLabel={state.apiKeyConfigured ? t('webSearchApiKeySet') : t('webSearchApiKeyUnset')}
onCommit={props.setApiKey}
onEdit={(text) => { props.edit('apiKey', text) }}
/>
<TextField
<ValueField
id="plugin-config-web-search-endpoint"
label={t('webSearchBaseUrl')}
hint={t('webSearchBaseUrlHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
overridden={state.baseURL.overridden}
invalidLabel={t('invalidNumber')}
disabled={disabled}
value={state.baseURL.value}
onCommit={props.setBaseUrl}
onReset={props.resetBaseUrl}
{...state.baseURL}
onEdit={(text) => { props.edit('baseURL', text) }}
onReset={() => { props.resetField('baseURL') }}
/>
<NumberField
<ValueField
id="plugin-config-web-search-max-uses"
label={t('webSearchMaxUses')}
hint={t('webSearchMaxUsesHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
overridden={state.maxUses.overridden}
invalidLabel={t('invalidNumber')}
numeric
disabled={disabled}
value={state.maxUses.value}
onCommit={props.setMaxUses}
onReset={props.resetMaxUses}
{...state.maxUses}
onEdit={(text) => { props.edit('maxUses', text) }}
onReset={() => { props.resetField('maxUses') }}
/>
</PluginCard>
)
@@ -1,7 +1,7 @@
/** The agent-loop card's state and writes over the `agent-loop` settings namespace. */
/** 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 { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts'
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
@@ -21,40 +21,37 @@ export interface AgentLoopSettings {
/** What the agent-loop card renders. */
export interface AgentLoopCardState extends CardShell {
/** Parallel tool-call cap. */
maxParallelToolCalls: CardField<number | undefined>
maxParallelToolCalls: CardFieldState
}
/** The registration-side face the agent-loop card's slot entry injects. */
export interface AgentLoopCardFace {
export interface AgentLoopCardFace extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useAgentLoopCard. */
agentLoopCard: SnapshotStore<AgentLoopCardState>
}
/** Write the parallel tool-call cap. */
setMaxParallelToolCalls: (next: number) => void
/** Clear the cap so it re-inherits the composition layer. */
resetMaxParallelToolCalls: () => void
}
/** Bridges the `agent-loop` scope onto the card's state and writes. */
export class AgentLoopCardController extends CardController<AgentLoopSettings, 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>) {
super(scope, snapshot => ({
...shellOf(snapshot),
maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', undefined),
}))
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 write actions.
* @returns the card's snapshot and its form actions.
*/
inject(): AgentLoopCardFace {
return {
hooks: { agentLoopCard: this.store },
setMaxParallelToolCalls: (next: number) => { void this.scope.set('maxParallelToolCalls', next) },
resetMaxParallelToolCalls: () => { void this.scope.unset('maxParallelToolCalls') },
}
return { hooks: { agentLoopCard: this.store }, ...this.form.actions() }
}
}
@@ -1,7 +1,7 @@
/** The shell card's state and writes over the `bash` settings namespace. */
/** The shell card's staged form over the `bash` settings namespace. */
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts'
import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-store.ts'
/**
* Namespace of the shell capability. Spelled here rather than imported: a
@@ -21,51 +21,43 @@ export interface BashSettings {
/** What the shell card renders. */
export interface BashCardState extends CardShell {
/** Command timeout in milliseconds. */
timeoutMs: CardField<number | undefined>
timeoutMs: CardFieldState
/** Per-stream output cap in bytes. */
maxOutputBytes: CardField<number | undefined>
maxOutputBytes: CardFieldState
}
/** The registration-side face the shell card's slot entry injects. */
export interface BashCardFace {
export interface BashCardFace extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useBashCard. */
bashCard: SnapshotStore<BashCardState>
}
/** Write the foreground command timeout. */
setTimeoutMs: (next: number) => void
/** Clear the timeout so it re-inherits the composition layer. */
resetTimeoutMs: () => void
/** Write the per-stream output cap. */
setMaxOutputBytes: (next: number) => void
/** Clear the output cap so it re-inherits the composition layer. */
resetMaxOutputBytes: () => void
}
/** Bridges the `bash` scope onto the shell card's state and writes. */
export class BashCardController extends CardController<BashSettings, 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>) {
super(scope, snapshot => ({
...shellOf(snapshot),
// The fallbacks only show before the Host serves a section; every served
// section is already schema-defaulted by the owning executor.
timeoutMs: fieldOf(snapshot, 'timeoutMs', undefined),
maxOutputBytes: fieldOf(snapshot, 'maxOutputBytes', undefined),
}))
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 write actions.
* @returns the card's snapshot and its form actions.
*/
inject(): BashCardFace {
return {
hooks: { bashCard: this.store },
setTimeoutMs: (next: number) => { void this.scope.set('timeoutMs', next) },
resetTimeoutMs: () => { void this.scope.unset('timeoutMs') },
setMaxOutputBytes: (next: number) => { void this.scope.set('maxOutputBytes', next) },
resetMaxOutputBytes: () => { void this.scope.unset('maxOutputBytes') },
}
return { hooks: { bashCard: this.store }, ...this.form.actions() }
}
}
@@ -1,84 +1,351 @@
/**
* Shared projection from one settings scope onto a card's fields.
* Shared form model behind every plugin card.
*
* A card shows the effective value of each field and whether the user set it.
* Both come from the scope snapshot: `value` is what the plugin resolves, and
* the presence of a key in the raw `user` layer is what makes it overridden —
* an override equal to the composition default is still an override, and
* comparing values could not tell them apart.
* 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'
/** One field as a card renders it. */
export interface CardField<V> {
/** Effective value: the user layer over the composition layer over the schema default. */
value: V
/** Whether the raw user layer carries this field. */
overridden: boolean
/** 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
}
/** State every plugin card shares. */
/**
* 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
}
/**
* Read one field out of a scope snapshot.
* @param snapshot - the scope snapshot to project.
* @param field - the section field to read.
* @param fallback - value shown before the Host serves a section.
* @returns the field as a card renders it.
* 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 fieldOf<T, V>(
snapshot: SettingsScopeSnapshot<T>,
field: string,
fallback: V,
): CardField<V> {
const section = snapshot.value as Record<string, unknown> | undefined
const user = snapshot.user as Record<string, unknown> | undefined
const value = section?.[field]
export function numberField(field: string): CardFieldSpec {
return {
value: value === undefined ? fallback : value as V,
overridden: user !== undefined && Object.hasOwn(user, field),
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
},
}
}
/**
* Project the shell every card shares.
* @param snapshot - the scope snapshot to project.
* @returns availability and writability.
* 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 shellOf<T>(snapshot: SettingsScopeSnapshot<T>): CardShell {
return { available: snapshot.status === 'ready', writable: snapshot.writable }
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 }
},
}
}
/**
* Keep a snapshot store synchronized with one settings scope.
* Stages one card's edits over one settings namespace and writes them on save.
*
* The store exists because slot components read through a snapshot selector,
* while the scope publishes its own snapshot; this bridges the two and gives
* each card a state shaped for rendering rather than for the wire.
* 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 CardController<T, S> {
/** Snapshot the card's component reads through its bound selector. */
readonly store: SnapshotStore<S>
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 project - build the card state from a scope snapshot.
* @param specs - the section fields this card edits.
* @param secrets - the card's write-only controls, written outside the section.
*/
constructor(
protected readonly scope: SettingsScope<T>,
private readonly project: (snapshot: SettingsScopeSnapshot<T>) => S,
private readonly scope: SettingsScope<T>,
specs: CardFieldSpec[],
secrets: CardSecretSpec[] = [],
) {
this.store = createSnapshotStore(project(scope.getSnapshot()))
scope.subscribe(() => {
this.store.set(this.project(this.scope.getSnapshot()))
})
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()
}
}
@@ -93,6 +93,18 @@
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;
@@ -1,12 +1,11 @@
/**
* Hand-written controls for the plugin configuration forms. Each renders one
* field's label, its current effective value, whether the user overrode it,
* and — when overridden — the reset that clears it back to the composition
* layer. Commits happen on blur and on Enter rather than per keystroke: a
* write per keystroke would burn namespace revisions and race its own reads.
* 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 { useState, type KeyboardEvent } from 'react'
import css from './fields.module.css'
/** What every field control needs regardless of its value type. */
@@ -17,20 +16,39 @@ export interface FieldProps {
label: string
/** One-line explanation rendered under the control. */
hint: string
/** True when the raw user layer carries this field. */
/** 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
/** Clear the field so it re-inherits the composition layer. */
/** Stage draft text. */
onEdit: (text: string) => void
/** Stage a clear so the field re-inherits the composition layer. */
onReset: () => void
}
/** Label, badge, and reset chrome shared by every control. */
function FieldFrame(props: FieldProps & { children: React.ReactNode }) {
/**
* 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}>
@@ -51,139 +69,37 @@ function FieldFrame(props: FieldProps & { children: React.ReactNode }) {
)
: null}
</div>
{props.children}
<p className={css.hint}>{props.hint}</p>
<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>
)
}
/**
* Keep a draft seeded from the authoritative value, re-seeding whenever that
* value changes underneath (a Host acceptance, or a reset).
* @param value - the current authoritative text.
* @returns the draft and its setter.
* 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.
*/
function useDraft(value: string): [string, (next: string) => void] {
const [draft, setDraft] = useState(value)
const [seed, setSeed] = useState(value)
if (seed !== value) {
setSeed(value)
setDraft(value)
}
return [draft, setDraft]
}
/** Blur the input so its own blur handler is the single commit path. */
function commitOnEnter(event: KeyboardEvent<HTMLInputElement>): void {
if (event.key === 'Enter') event.currentTarget.blur()
}
/**
* The text input both editable fields render: a draft seeded from the
* authoritative text, committed on blur and on Enter.
*/
function DraftInput(props: {
/** Stable id associating the label with this control. */
id: string
/** Authoritative text the draft re-seeds from. */
value: string
/** Disables editing. */
disabled: boolean
/** Placeholder shown while the draft is empty. */
placeholder?: string | undefined
/** Hints a numeric keypad without narrowing the value type. */
numeric?: boolean | undefined
/** Settle the draft; the returned text replaces it (a rejected draft restores the value). */
onSettle: (draft: string, restore: (text: string) => void) => void
}) {
const [draft, setDraft] = useDraft(props.value)
return (
<input
id={props.id}
className={css.input}
type="text"
{...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
value={draft}
placeholder={props.placeholder ?? ''}
disabled={props.disabled}
onChange={(event) => { setDraft(event.target.value) }}
onBlur={() => { props.onSettle(draft, setDraft) }}
onKeyDown={commitOnEnter}
/>
)
}
/** A whole-number field committed on blur or Enter. */
export function NumberField(props: FieldProps & {
/**
* Current effective value, or undefined when the Host served none — which
* renders empty rather than as a number nobody chose.
*/
value: number | undefined
/** Commit a parsed value; a draft that is not a finite number is discarded. */
onCommit: (next: number) => void
}) {
return (
<FieldFrame {...props}>
<DraftInput
id={props.id}
value={props.value === undefined ? '' : String(props.value)}
disabled={props.disabled}
numeric
onSettle={(draft, restore) => {
const parsed = Number(draft)
if (draft.trim() === '' || !Number.isFinite(parsed)) {
restore(props.value === undefined ? '' : String(props.value))
return
}
if (parsed === props.value) return
props.onCommit(parsed)
}}
/>
</FieldFrame>
)
}
/** A free-text field committed on blur or Enter; an empty draft clears the field. */
export function TextField(props: FieldProps & {
/** Current effective value; the empty string when the field is unset. */
value: string
/** Placeholder shown while the draft is empty. */
placeholder?: string
/** Commit the trimmed draft. */
onCommit: (next: string) => void
}) {
return (
<FieldFrame {...props}>
<DraftInput
id={props.id}
value={props.value}
disabled={props.disabled}
placeholder={props.placeholder}
onSettle={(draft) => {
const next = draft.trim()
if (next === props.value) return
props.onCommit(next)
}}
/>
</FieldFrame>
)
}
/**
* A write-only credential field. The value never rides a response, so the
* control reports only whether one is configured, and an empty draft commits
* nothing — leaving the field blank keeps the stored key rather than clearing it.
*/
export function SecretField(props: Omit<FieldProps, 'overridden' | 'onReset'> & {
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
/** Commit a non-empty draft. */
onCommit: (next: string) => void
}) {
const [draft, setDraft] = useState('')
return (
<div className={css.field}>
<div className={css.head}>
@@ -197,16 +113,9 @@ export function SecretField(props: Omit<FieldProps, 'overridden' | 'onReset'> &
className={css.input}
type="password"
autoComplete="off"
value={draft}
value={props.text}
disabled={props.disabled}
onChange={(event) => { setDraft(event.target.value) }}
onBlur={() => {
const next = draft.trim()
if (next === '') return
setDraft('')
props.onCommit(next)
}}
onKeyDown={commitOnEnter}
onChange={(event) => { props.onEdit(event.target.value) }}
/>
<p className={css.hint}>{props.hint}</p>
</div>
@@ -28,7 +28,11 @@ 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 { NumberField, SecretField, TextField, type FieldProps } from './fields.tsx'
export { SecretField, ValueField, type FieldProps } from './fields.tsx'
export {
CardForm, numberField, textField,
type CardActions, type CardFieldSpec, type CardFieldState, type CardSecretSpec, type CardShell,
} from './card-store.ts'
export { AGENT_LOOP_NS, AgentLoopCardController, type AgentLoopCardState } from './agent-loop-store.ts'
export { BASH_NS, BashCardController, type BashCardState } from './bash-store.ts'
export { WEB_SEARCH_NS, WebSearchCardController, type WebSearchCardState } from './web-search-store.ts'
@@ -4,6 +4,7 @@
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'
@@ -22,6 +23,12 @@ export const en: Record<PluginConfigKey, string> = {
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)',
@@ -55,6 +62,12 @@ export const zh: Record<PluginConfigKey, string> = {
readOnly: '本部署的设置为只读。',
expand: '展开设置',
collapse: '收起设置',
save: '保存',
saving: '保存中…',
discard: '放弃修改',
unsaved: '未保存',
saveFailed: '本部署没有接受这些值,已保留供你修改。',
invalidNumber: '请填数字;留空表示使用默认值。',
bashTitle: '终端',
bashDescription: '限制 agent 运行的每一条命令。',
bashTimeoutMs: '命令超时(毫秒)',
@@ -1,16 +1,20 @@
/**
* The web-search card's state and writes over the `web-search-deepseek`
* settings namespace.
* The web-search card's staged form over the `web-search-deepseek` settings
* namespace.
*
* The key is the one field that does not live in the section: its literal
* 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.
* 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 { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts'
import {
CardForm, numberField, textField,
type CardActions, type CardFieldState, type CardShell,
} from './card-store.ts'
/**
* Namespace of the DeepSeek search provider. Spelled here rather than
@@ -21,6 +25,9 @@ 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. */
@@ -34,59 +41,57 @@ export interface WebSearchSettings {
/** What the web-search card renders. */
export interface WebSearchCardState extends CardShell {
/** Provider endpoint. */
baseURL: CardField<string>
baseURL: CardFieldState
/** Searches allowed per request. */
maxUses: CardField<number | undefined>
/** Credential reference the key is written under. */
apiKeyRef: string
/** Whether the Host reports a credential configured for that reference. */
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
}
/** The registration-side face the web-search card's slot entry injects. */
export interface WebSearchCardFace {
export interface WebSearchCardFace extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useWebSearchCard. */
webSearchCard: SnapshotStore<WebSearchCardState>
}
/** Write the provider endpoint; the empty string clears it. */
setBaseUrl: (next: string) => void
/** Clear the endpoint so it re-inherits the composition layer. */
resetBaseUrl: () => void
/** Write the per-request search budget. */
setMaxUses: (next: number) => void
/** Clear the budget so it re-inherits the composition layer. */
resetMaxUses: () => void
/** Write the credential the section references. */
setApiKey: (next: string) => void
}
/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */
export class WebSearchCardController extends CardController<WebSearchSettings, WebSearchCardState> {
private readonly credential: { configured: boolean }
export class WebSearchCardController {
private readonly form: CardForm<WebSearchSettings>
private readonly store: SnapshotStore<WebSearchCardState>
private configured = false
/**
* @param scope - the bound settings scope for the `web-search-deepseek` namespace.
* @param api - wire face used for the credential the section references.
*/
constructor(scope: SettingsScope<WebSearchSettings>, private readonly api: Pick<IApiClient, 'credentials'>) {
// Held in its own object because the projection runs during `super()`,
// before `this` exists, and must still see the latest credential state:
// that state comes from its own domain, so a settings change must not
// silently reset it to unknown.
const credential = { configured: false }
super(scope, snapshot => ({
...shellOf(snapshot),
baseURL: fieldOf(snapshot, 'baseURL', ''),
maxUses: fieldOf(snapshot, 'maxUses', undefined),
apiKeyRef: refOf(snapshot),
apiKeyConfigured: credential.configured,
}))
this.credential = credential
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.configured,
}
}
/** Ask the credentials domain whether the referenced key exists. */
private async readCredential(): Promise<void> {
const ref = refOf(this.scope.getSnapshot())
@@ -100,35 +105,33 @@ export class WebSearchCardController extends CardController<WebSearchSettings, W
}
if (!response.result.ok) return
const next = response.result.value.credentials[ref]?.configured ?? false
if (next === this.credential.configured) return
this.credential.configured = next
this.store.set({ ...this.store.getSnapshot(), apiKeyConfigured: next })
if (next === this.configured) return
this.configured = next
this.store.set(this.projection())
}
/**
* Build the face the card's slot registration injects.
* @returns the card's snapshot and its write actions.
* @returns the card's snapshot and its form actions.
*/
inject(): WebSearchCardFace {
return {
hooks: { webSearchCard: this.store },
setBaseUrl: (next: string) => { void this.scope.set('baseURL', next) },
resetBaseUrl: () => { void this.scope.unset('baseURL') },
setMaxUses: (next: number) => { void this.scope.set('maxUses', next) },
resetMaxUses: () => { void this.scope.unset('maxUses') },
setApiKey: (next: string) => { void this.writeKey(next) },
}
return { hooks: { webSearchCard: this.store }, ...this.form.actions() }
}
private async writeKey(value: string): Promise<void> {
const ref = refOf(this.scope.getSnapshot())
/**
* 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, value })
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.configured
}
}
@@ -138,7 +141,6 @@ export class WebSearchCardController extends CardController<WebSearchSettings, W
* @returns the reference to address.
*/
function refOf(snapshot: SettingsScopeSnapshot<WebSearchSettings>): string {
const section = snapshot.value
const declared = section?.apiKeyEnv
const declared = snapshot.value?.apiKeyEnv
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF
}
@@ -1,12 +1,12 @@
// @vitest-environment jsdom
/**
* Field-control behavior: when a draft becomes a write, what a bad draft does
* instead, and how an overridden field offers its reset.
* 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 { NumberField, SecretField, TextField } from '../src/client/fields.tsx'
import { SecretField, ValueField } from '../src/client/fields.tsx'
afterEach(cleanup)
@@ -16,328 +16,138 @@ const frame = {
hint: 'How long one command may run.',
overriddenLabel: 'Overridden',
resetLabel: 'Reset to default',
invalidLabel: 'Enter a number.',
disabled: false,
overridden: false,
invalid: false,
}
describe('NumberField', () => {
it('commits a changed draft on blur', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
describe('ValueField', () => {
it('stages every keystroke without writing', () => {
const onEdit = vi.fn()
render(<ValueField {...frame} text="60000" onEdit={onEdit} onReset={vi.fn()} />)
fireEvent.change(input, { target: { value: '9000' } })
fireEvent.blur(input)
fireEvent.change(screen.getByLabelText('Command timeout'), { target: { value: '9000' } })
expect(onCommit).toHaveBeenCalledWith(9_000)
expect(onEdit).toHaveBeenCalledWith('9000')
})
it('commits on Enter through the blur the key triggers', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
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')
fireEvent.change(input, { target: { value: '1234' } })
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.blur(input)
rerender(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
expect(onCommit).toHaveBeenCalledWith(1_234)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
})
it('restores the last good value instead of committing a draft that is not a number', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
fireEvent.change(input, { target: { value: 'soon' } })
fireEvent.blur(input)
expect(onCommit).not.toHaveBeenCalled()
expect(input).toHaveProperty('value', '60000')
})
it('writes nothing when the draft settles on the value already shown', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
fireEvent.change(input, { target: { value: '60000' } })
fireEvent.blur(input)
expect(onCommit).not.toHaveBeenCalled()
})
it('offers the reset only while the field is overridden', () => {
it('offers the reset only while an override would stand', () => {
const onReset = vi.fn()
const { rerender } = render(
<NumberField {...frame} overridden={false} onReset={onReset} value={9_000} onCommit={vi.fn()} />,
)
const { rerender } = render(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={onReset} />)
expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull()
rerender(
<NumberField {...frame} overridden onReset={onReset} value={9_000} onCommit={vi.fn()} />,
)
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('re-seeds the draft when the authoritative value changes underneath', () => {
const { rerender } = render(
<NumberField {...frame} overridden onReset={vi.fn()} value={9_000} onCommit={vi.fn()} />,
)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
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()} />)
rerender(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={vi.fn()} />,
)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000')
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('ignores a keystroke that is not Enter', () => {
const onCommit = vi.fn()
it('hints a numeric keypad and renders a placeholder when asked', () => {
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
fireEvent.change(input, { target: { value: '9000' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(onCommit).not.toHaveBeenCalled()
})
it('renders an absent value as empty rather than as a number nobody chose', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={undefined} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
expect(input).toHaveProperty('value', '')
// A draft typed and then cleared restores the same emptiness, not a zero.
fireEvent.change(input, { target: { value: 'abc' } })
fireEvent.blur(input)
expect(input).toHaveProperty('value', '')
expect(onCommit).not.toHaveBeenCalled()
})
it('suppresses every interaction while disabled', () => {
const onCommit = vi.fn()
const onReset = vi.fn()
render(
<NumberField
<ValueField
{...frame}
disabled
overridden
onReset={onReset}
value={9_000}
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('Command timeout')
expect(input).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
expect(onCommit).not.toHaveBeenCalled()
expect(onReset).not.toHaveBeenCalled()
})
})
describe('TextField', () => {
it('commits the trimmed draft', () => {
const onCommit = vi.fn()
render(
<TextField
{...frame}
label="Endpoint"
overridden={false}
onReset={vi.fn()}
value=""
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('Endpoint')
fireEvent.change(input, { target: { value: ' https://search.test/v1 ' } })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith('https://search.test/v1')
})
it('commits an emptied draft, which clears the field', () => {
const onCommit = vi.fn()
render(
<TextField
{...frame}
label="Endpoint"
overridden
onReset={vi.fn()}
value="https://search.test/v1"
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('Endpoint')
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith('')
})
it('renders its placeholder and commits on Enter', () => {
const onCommit = vi.fn()
render(
<TextField
{...frame}
label="Endpoint"
numeric
placeholder="https://api.deepseek.com"
overridden={false}
text=""
onEdit={vi.fn()}
onReset={vi.fn()}
value=""
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('Endpoint')
const input = screen.getByLabelText('Command timeout')
expect(input.getAttribute('inputmode')).toBe('numeric')
expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com')
fireEvent.change(input, { target: { value: 'https://other.test' } })
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith('https://other.test')
})
it('ignores a keystroke that is not Enter and writes nothing unchanged', () => {
const onCommit = vi.fn()
render(
<TextField
{...frame}
label="Endpoint"
overridden={false}
onReset={vi.fn()}
value="https://search.test/v1"
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('Endpoint')
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()} />)
fireEvent.keyDown(input, { key: 'a' })
fireEvent.blur(input)
expect(onCommit).not.toHaveBeenCalled()
expect(screen.getByLabelText('Command timeout')).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
})
})
describe('SecretField', () => {
it('commits a non-empty draft and clears the control after writing', () => {
const onCommit = vi.fn()
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
{...frame}
label="API key"
{...secret}
text=""
configured={false}
stateLabel="No key is configured."
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('API key')
fireEvent.change(input, { target: { value: ' ds-secret ' } })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith('ds-secret')
expect(input).toHaveProperty('value', '')
})
it('keeps the stored key when the draft is left blank', () => {
const onCommit = vi.fn()
render(
<SecretField
{...frame}
label="API key"
configured
stateLabel="A key is configured."
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('API key')
fireEvent.change(input, { target: { value: ' ' } })
fireEvent.blur(input)
expect(onCommit).not.toHaveBeenCalled()
expect(screen.getByText('A key is configured.')).toBeTruthy()
})
it('ignores a keystroke that is not Enter', () => {
const onCommit = vi.fn()
render(
<SecretField
{...frame}
label="API key"
configured={false}
stateLabel="No key is configured."
onCommit={onCommit}
onEdit={onEdit}
/>,
)
const input = screen.getByLabelText('API key')
fireEvent.change(input, { target: { value: 'ds-secret' } })
fireEvent.keyDown(input, { key: 'Tab' })
expect(onCommit).not.toHaveBeenCalled()
expect(onEdit).toHaveBeenCalledWith('ds-secret')
expect(input).toHaveProperty('type', 'password')
})
it('never renders the value it writes', () => {
render(
<SecretField
{...frame}
label="API key"
configured
stateLabel="A key is configured."
onCommit={vi.fn()}
/>,
)
expect(screen.getByLabelText('API key')).toHaveProperty('type', 'password')
})
it('commits on Enter and stays disabled when the document is read-only', () => {
const onCommit = vi.fn()
it('reports the configured state the Host holds', () => {
const { rerender } = render(
<SecretField
{...frame}
label="API key"
{...secret}
text=""
configured={false}
stateLabel="No key is configured."
onCommit={onCommit}
onEdit={vi.fn()}
/>,
)
const input = screen.getByLabelText('API key')
fireEvent.change(input, { target: { value: 'ds-secret' } })
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith('ds-secret')
expect(screen.getByText('No key is configured.')).toBeTruthy()
rerender(
<SecretField
{...frame}
disabled
label="API key"
{...secret}
text="ds-secret"
configured
stateLabel="A key is configured."
onCommit={onCommit}
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()}
/>,
)
@@ -2,7 +2,7 @@
/**
* 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 read-only notice a locked document produces.
* unavailable, and the save footer that decides when staged edits are written.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
@@ -19,6 +19,7 @@ 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'
@@ -26,6 +27,25 @@ 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,
@@ -37,23 +57,13 @@ function renderSection(cardCount: number, cards = 'cards') {
function renderBash(state: Partial<BashCardState> = {}) {
const store = createSnapshotStore<BashCardState>({
available: true,
writable: true,
timeoutMs: { value: 60_000, overridden: false },
maxOutputBytes: { value: 64_000, overridden: false },
...settled,
timeoutMs: field('60000'),
maxOutputBytes: field('64000'),
...state,
})
const actions = {
setTimeoutMs: vi.fn(),
resetTimeoutMs: vi.fn(),
setMaxOutputBytes: vi.fn(),
resetMaxOutputBytes: vi.fn(),
}
const props = {
...actions,
t,
useBashCard: bindSnapshotSelector(store),
} as unknown as BashCardProps
const actions = cardActions()
const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
render(<BashCard {...props} />)
return actions
}
@@ -101,26 +111,86 @@ describe('BashCard', () => {
expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
})
it('commits an edited field through its action', () => {
it('stages an edit instead of writing it', () => {
const actions = renderBash()
fireEvent.click(screen.getByText(en.bashTitle))
const input = screen.getByLabelText(en.bashTimeoutMs)
fireEvent.change(input, { target: { value: '9000' } })
fireEvent.blur(input)
fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } })
expect(actions.setTimeoutMs).toHaveBeenCalledWith(9_000)
expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000')
expect(actions.save).not.toHaveBeenCalled()
})
it('offers the reset for an overridden field only', () => {
const actions = renderBash({ timeoutMs: { value: 9_000, overridden: true } })
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.resetTimeoutMs).toHaveBeenCalledOnce()
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', () => {
@@ -130,56 +200,73 @@ describe('BashCard', () => {
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('edits the only field it owns', () => {
it('stages and saves the only field it owns', () => {
const store = createSnapshotStore<AgentLoopCardState>({
available: true,
writable: true,
maxParallelToolCalls: { value: 10, overridden: false },
...settled,
dirty: true,
maxParallelToolCalls: field('10'),
})
const setMaxParallelToolCalls = vi.fn()
const actions = cardActions()
const props = {
...actions,
t,
useAgentLoopCard: bindSnapshotSelector(store),
setMaxParallelToolCalls,
resetMaxParallelToolCalls: vi.fn(),
} as unknown as AgentLoopCardProps
render(<AgentLoopCard {...props} />)
fireEvent.click(screen.getByText(en.agentLoopTitle))
const input = screen.getByLabelText(en.agentLoopMaxParallel)
fireEvent.change(input, { target: { value: '2' } })
fireEvent.blur(input)
fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } })
fireEvent.click(screen.getByRole('button', { name: en.save }))
expect(setMaxParallelToolCalls).toHaveBeenCalledWith(2)
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>({
available: true,
writable: true,
baseURL: { value: '', overridden: false },
maxUses: { value: 5, overridden: false },
apiKeyRef: 'DEEPSEEK_API_KEY',
...settled,
baseURL: field(''),
maxUses: field('5'),
apiKey: field(''),
apiKeyConfigured: false,
...state,
})
const actions = {
setBaseUrl: vi.fn(),
resetBaseUrl: vi.fn(),
setMaxUses: vi.fn(),
resetMaxUses: vi.fn(),
setApiKey: vi.fn(),
}
const props = {
...actions,
t,
useWebSearchCard: bindSnapshotSelector(store),
} as unknown as WebSearchCardProps
const actions = cardActions()
const props = { ...actions, t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps
render(<WebSearchCard {...props} />)
return actions
}
@@ -201,23 +288,27 @@ describe('WebSearchCard', () => {
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
fireEvent.change(key, { target: { value: 'ds-secret' } })
fireEvent.blur(key)
expect(actions.setApiKey).toHaveBeenCalledWith('ds-secret')
expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
})
it('commits the endpoint and the search budget', () => {
const actions = renderWebSearch()
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))
const endpoint = screen.getByLabelText(en.webSearchBaseUrl)
fireEvent.change(endpoint, { target: { value: 'https://search.test/v1' } })
fireEvent.blur(endpoint)
const budget = screen.getByLabelText(en.webSearchMaxUses)
fireEvent.change(budget, { target: { value: '3' } })
fireEvent.blur(budget)
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.setBaseUrl).toHaveBeenCalledWith('https://search.test/v1')
expect(actions.setMaxUses).toHaveBeenCalledWith(3)
expect(actions.edit.mock.calls).toEqual([
['baseURL', 'https://other.test'],
['maxUses', '4'],
])
expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
})
})
@@ -1,14 +1,29 @@
/**
* Card controllers: how a scope snapshot becomes card state, and which wire
* call each action reaches.
* 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 } from '@deepseek-ai/dsh-client-test-runtime'
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,
@@ -18,89 +33,340 @@ function credentialsApi(configured: boolean) {
return { api: { credentials: { describe, set } } as never, describe, set }
}
describe('BashCardController', () => {
it('publishes the effective value and marks only user-layer fields overridden', () => {
const host = stubSettingsScope<BashSettings>()
const controller = new BashCardController(host.scope)
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,
revision: 3,
value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
user: { timeoutMs: 5_000 },
})
const face = controller.inject()
expect(controller.store.getSnapshot()).toMatchObject({
expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
available: true,
writable: true,
timeoutMs: { value: 5_000, overridden: true },
maxOutputBytes: { value: 64_000, overridden: false },
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('treats an override equal to the composition default as an override', () => {
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: 60_000 },
value: { timeoutMs: 5_000 },
base: { timeoutMs: 60_000 },
user: { timeoutMs: 60_000 },
user: { timeoutMs: 5_000 },
})
const face = controller.inject()
expect(controller.store.getSnapshot().timeoutMs).toEqual({ value: 60_000, overridden: true })
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('routes each action to its field write', async () => {
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 } })
const actions = controller.inject()
host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} })
const face = controller.inject()
actions.setTimeoutMs(9_000)
actions.resetTimeoutMs()
actions.setMaxOutputBytes(1_024)
actions.resetMaxOutputBytes()
await Promise.resolve()
face.edit('timeoutMs', '9000')
face.discard()
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
expect(host.unset.mock.calls).toEqual([['timeoutMs'], ['maxOutputBytes']])
})
it('stays unavailable while the namespace is not served', () => {
const host = stubSettingsScope<BashSettings>()
const controller = new BashCardController(host.scope)
host.publish({ status: 'unavailable' })
expect(controller.store.getSnapshot().available).toBe(false)
expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000')
expect(host.set).not.toHaveBeenCalled()
})
})
describe('AgentLoopCardController', () => {
it('publishes the cap and routes its two actions', async () => {
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: 2 },
value: { maxParallelToolCalls: 10 },
base: { maxParallelToolCalls: 10 },
user: { maxParallelToolCalls: 2 },
user: {},
})
expect(controller.store.getSnapshot().maxParallelToolCalls).toEqual({ value: 2, overridden: true })
const face = controller.inject()
const actions = controller.inject()
actions.setMaxParallelToolCalls(4)
actions.resetMaxParallelToolCalls()
await Promise.resolve()
face.edit('maxParallelToolCalls', '4')
face.save()
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) })
expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4)
expect(host.unset).toHaveBeenCalledWith('maxParallelToolCalls')
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', () => {
@@ -109,7 +375,7 @@ describe('AgentLoopCardController', () => {
host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
expect(controller.store.getSnapshot().writable).toBe(false)
expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false)
})
})
@@ -118,76 +384,133 @@ describe('WebSearchCardController', () => {
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' } })
await vi.waitFor(() => {
expect(controller.store.getSnapshot().apiKeyConfigured).toBe(true)
})
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) })
expect(controller.store.getSnapshot()).toMatchObject({
baseURL: { value: 'https://search.test/v1', overridden: false },
apiKeyRef: 'DEEPSEEK_API_KEY',
expect(state()).toMatchObject({
baseURL: { text: 'https://search.test/v1', overridden: false },
apiKey: { text: '', overridden: false },
})
})
it('writes the key through the credentials domain, never the settings section', async () => {
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: {} })
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
const face = controller.inject()
controller.inject().setApiKey('ds-secret')
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.toHaveBeenCalledWith('apiKey', expect.anything())
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('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' } })
host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} })
const face = controller.inject()
controller.inject().setApiKey('ds-secret')
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('keeps the card usable when the credential read fails', async () => {
it('reports a key the Host did not store as a failed save', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const describe = vi.fn(() => Promise.reject(new Error('offline')))
const controller = new WebSearchCardController(
host.scope,
{ credentials: { describe, set: vi.fn() } } as never,
)
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
const credentials = credentialsApi(false)
const controller = new WebSearchCardController(host.scope, credentials.api)
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
const face = controller.inject()
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } })
face.edit('apiKey', 'ds-secret')
face.save()
expect(controller.store.getSnapshot()).toMatchObject({
available: true,
apiKeyConfigured: false,
baseURL: { value: 'https://search.test/v1' },
await vi.waitFor(() => {
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
})
})
it('routes the endpoint and budget actions to their field writes', async () => {
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: {} })
const actions = controller.inject()
host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} })
const face = controller.inject()
actions.setBaseUrl('https://other.test')
actions.resetBaseUrl()
actions.setMaxUses(3)
actions.resetMaxUses()
await Promise.resolve()
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(host.unset.mock.calls).toEqual([['baseURL'], ['maxUses']])
expect(credentials.set).not.toHaveBeenCalled()
})
})