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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '命令超时(毫秒)',

View File

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