fix(web): show the plugin settings a user actually gets

Three things the page got wrong.

The web-search provider's defaults lived only at their use site, so the
served section carried no value for them and the card fell back to a zero it
invented — a number the schema itself rejects. Declaring them on the schema
makes the settings service the one authority: `maxUses` now reads 5 because
that is what the Host resolves, not because the page guessed. `baseURL` keeps
its code-side default, which exists so `$DEEPSEEK_SEARCH_BASE_URL` can win.

A field the Host serves no value for now renders empty rather than as zero.

The cards were rows in a settings page of cards: name and description ran
together on one line because the shared disclosure row lays them side by side.
Each card now draws its own header, stacking the two, and the section follows
the idiom the Agent Preset page established.
This commit is contained in:
Yichen Jiang
2026-08-10 21:29:45 +08:00
parent 1b473be886
commit dae6cad065
27 changed files with 241 additions and 151 deletions
@@ -10,9 +10,9 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件":
- button "插件配置":
- img
- text: 插件
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
@@ -10,9 +10,9 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件":
- button "插件配置":
- img
- text: 插件
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
@@ -10,9 +10,9 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件":
- button "插件配置":
- img
- text: 插件
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
@@ -10,9 +10,9 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件":
- button "插件配置":
- img
- text: 插件
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
@@ -10,9 +10,9 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件":
- button "插件配置":
- img
- text: 插件
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
@@ -10,9 +10,9 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件":
- button "插件配置":
- img
- text: 插件
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
@@ -10,9 +10,9 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件":
- button "插件配置":
- img
- text: 插件
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
@@ -10,25 +10,25 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件":
- button "插件配置":
- img
- text: 插件
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "插件配置" [level=2]
- paragraph: 本部署所组装插件自己拥有的设置。你在这里设的值会覆盖组装默认值,并在下一次使用时生效
- paragraph: 配置本部署已安装的插件
- list:
- listitem:
- button "终端 限制 agent 运行的每一条命令。":
- img
- 'button "展开设置: 终端"':
- text: 终端 限制 agent 运行的每一条命令。
- listitem:
- button "Agent 循环 Agent 如何派发工具调用。":
- img
- listitem:
- 'button "展开设置: Agent 循环"':
- text: Agent 循环 Agent 如何派发工具调用。
- listitem:
- button "网页搜索 DeepSeek 搜索提供方。":
- img
- listitem:
- 'button "展开设置: 网页搜索"':
- text: 网页搜索 DeepSeek 搜索提供方。
- img
@@ -10,9 +10,9 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件":
- button "插件配置":
- img
- text: 插件
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
@@ -67,5 +67,8 @@
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
],
"dependencies": {
"clsx": "^2.0.0"
}
}
@@ -1,6 +1,5 @@
/** The agent-loop plugin's card: how many tool calls may run at once. */
import { useState } from 'react'
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'
@@ -34,17 +33,14 @@ export type AgentLoopCardProps =
export function AgentLoopCard(props: AgentLoopCardProps) {
const { t } = props
const state = props.useAgentLoopCard(snapshot => snapshot)
const [open, setOpen] = useState(false)
const disabled = !state.writable
return (
<PluginCard
title={t('agentLoopTitle')}
description={t('agentLoopDescription')}
t={t}
titleKey="agentLoopTitle"
descriptionKey="agentLoopDescription"
available={state.available}
open={open}
onToggle={() => { setOpen(!open) }}
readOnly={disabled}
readOnlyLabel={t('readOnly')}
>
<NumberField
id="plugin-config-agent-loop-parallel"
@@ -1,6 +1,5 @@
/** The shell plugin's card: the limits every command the agent runs is bound by. */
import { useState } from 'react'
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'
@@ -38,17 +37,14 @@ export type BashCardProps =
export function BashCard(props: BashCardProps) {
const { t } = props
const state = props.useBashCard(snapshot => snapshot)
const [open, setOpen] = useState(false)
const disabled = !state.writable
return (
<PluginCard
title={t('bashTitle')}
description={t('bashDescription')}
t={t}
titleKey="bashTitle"
descriptionKey="bashDescription"
available={state.available}
open={open}
onToggle={() => { setOpen(!open) }}
readOnly={disabled}
readOnlyLabel={t('readOnly')}
>
<NumberField
id="plugin-config-bash-timeout"
@@ -1,35 +1,86 @@
/* Plugin card: one expandable row per plugin, its body holding the controls. */
/* Plugin card: a header that names the plugin, disclosing its controls in place. */
.card {
list-style: none;
border-bottom: 1px solid var(--dsw-alias-border-l2);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
transition: border-color .16s, background .16s;
}
.row {
padding: 16px 0;
.card:hover {
border-color: var(--dsw-alias-label-dimmed);
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
/* An open card reads as the one being worked on, not merely taller. */
.cardOpen {
background: var(--dsw-alias-bg-layer-2);
border-color: var(--dsw-alias-label-dimmed);
}
.header {
width: 100%;
appearance: none;
border: 0;
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
}
.header:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: -2px;
}
/* Name over description: the description is what tells two plugins apart, so
it gets its own line rather than trailing the name. */
.headText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.name {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--dsw-alias-label-primary);
}
.description {
font-size: 12px;
font-weight: 400;
line-height: 18px;
font-size: 13px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}
.chevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
transition: transform .16s;
}
.chevronOpen {
transform: rotate(180deg);
}
.body {
padding: 0 0 8px 24px;
border-top: 1px solid var(--dsw-alias-border-l2);
margin: 0 16px;
padding-bottom: 8px;
}
.readOnly {
margin: 0 0 8px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}
@@ -1,29 +1,35 @@
/**
* One plugin's card: an expandable row whose body is that plugin's controls.
* 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 an empty or disabled card the user cannot act on.
* One plugin's card: a header naming the plugin and what its settings govern,
* disclosing that plugin's controls in place.
*
* 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.
*
* A card renders nothing while its namespace is unavailable: a deployment that
* does not compose the owning plugin should show no trace of it, rather than a
* disabled card the user cannot act on.
*/
import type { ReactNode } from 'react'
import { DisclosureRow } from '@deepseek-ai/dsh-client-ui-primitives'
import { useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PluginConfigKey } from './locales.ts'
import css from './PluginCard.module.css'
/** Card chrome shared by every plugin section. */
export interface PluginCardProps {
/** Plugin name shown on the row. */
title: string
/** One line describing what this plugin's settings govern. */
description: string
/** Locale reader for this section's copy. */
t: (key: PluginConfigKey) => string
/** Locale key of the plugin's name. */
titleKey: PluginConfigKey
/** Locale key of the line describing what this plugin's settings govern. */
descriptionKey: PluginConfigKey
/** False while the namespace is not served to this client. */
available: boolean
/** Whether the card body is showing. */
open: boolean
/** Toggle the card body. */
onToggle: () => void
/** Copy shown when the settings document refuses writes. */
readOnlyLabel?: string | undefined
/** True when the Host document is read-only. */
/** True when the Host document is read-only, which disables the fields. */
readOnly: boolean
/** The plugin's controls. */
children: ReactNode
@@ -31,31 +37,36 @@ export interface PluginCardProps {
/**
* Render one plugin card.
* @param props - card chrome, disclosure state, and the plugin's controls.
* @param props - the plugin's copy keys, its availability, 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 title = props.t(props.titleKey)
return (
<li className={css.card}>
<DisclosureRow
icon={null}
title={props.title}
open={props.open}
expandable
expandOnRowClick
onToggle={props.onToggle}
rowClassName={css.row}
titleClassName={css.title}
collapsedContent={<span className={css.description}>{props.description}</span>}
<li className={clsx(css.card, open && css.cardOpen)}>
<button
type="button"
className={css.header}
aria-expanded={open}
aria-label={`${props.t(open ? 'collapse' : 'expand')}: ${title}`}
onClick={() => { setOpen(!open) }}
>
<div className={css.body}>
{props.readOnly && props.readOnlyLabel !== undefined
? <p className={css.readOnly} role="status">{props.readOnlyLabel}</p>
: null}
{props.children}
</div>
</DisclosureRow>
<span className={css.headText}>
<span className={css.name}>{title}</span>
<span className={css.description}>{props.t(props.descriptionKey)}</span>
</span>
<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}
{props.children}
</div>
)
: null}
</li>
)
}
@@ -3,34 +3,34 @@
.section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
}
.heading {
margin: 0;
font-size: 16px;
font-weight: 500;
line-height: 24px;
color: var(--dsw-alias-label-primary);
font-size: 18px;
font-weight: 600;
}
.intro {
margin: 8px 0 16px;
font-size: 12px;
font-weight: 400;
line-height: 18px;
margin: 0;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}
.cards {
list-style: none;
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.empty {
margin: 0;
padding: 16px 0;
font-size: 14px;
line-height: 22px;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}
@@ -4,7 +4,6 @@
* the settings section, so the literal never rides a response.
*/
import { useState } from 'react'
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'
@@ -44,17 +43,14 @@ export type WebSearchCardProps =
export function WebSearchCard(props: WebSearchCardProps) {
const { t } = props
const state = props.useWebSearchCard(snapshot => snapshot)
const [open, setOpen] = useState(false)
const disabled = !state.writable
return (
<PluginCard
title={t('webSearchTitle')}
description={t('webSearchDescription')}
t={t}
titleKey="webSearchTitle"
descriptionKey="webSearchDescription"
available={state.available}
open={open}
onToggle={() => { setOpen(!open) }}
readOnly={disabled}
readOnlyLabel={t('readOnly')}
>
<SecretField
id="plugin-config-web-search-key"
@@ -21,7 +21,7 @@ export interface AgentLoopSettings {
/** What the agent-loop card renders. */
export interface AgentLoopCardState extends CardShell {
/** Parallel tool-call cap. */
maxParallelToolCalls: CardField<number>
maxParallelToolCalls: CardField<number | undefined>
}
/** The registration-side face the agent-loop card's slot entry injects. */
@@ -42,7 +42,7 @@ export class AgentLoopCardController extends CardController<AgentLoopSettings, A
constructor(scope: SettingsScope<AgentLoopSettings>) {
super(scope, snapshot => ({
...shellOf(snapshot),
maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', 0),
maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', undefined),
}))
}
@@ -21,9 +21,9 @@ export interface BashSettings {
/** What the shell card renders. */
export interface BashCardState extends CardShell {
/** Command timeout in milliseconds. */
timeoutMs: CardField<number>
timeoutMs: CardField<number | undefined>
/** Per-stream output cap in bytes. */
maxOutputBytes: CardField<number>
maxOutputBytes: CardField<number | undefined>
}
/** The registration-side face the shell card's slot entry injects. */
@@ -50,8 +50,8 @@ export class BashCardController extends CardController<BashSettings, BashCardSta
...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', 0),
maxOutputBytes: fieldOf(snapshot, 'maxOutputBytes', 0),
timeoutMs: fieldOf(snapshot, 'timeoutMs', undefined),
maxOutputBytes: fieldOf(snapshot, 'maxOutputBytes', undefined),
}))
}
@@ -7,6 +7,10 @@
padding: 12px 0;
}
.field + .field {
border-top: 1px solid var(--dsw-alias-border-l2);
}
.head {
display: flex;
align-items: center;
@@ -16,9 +20,9 @@
.label {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 400;
line-height: 22px;
font-size: 13px;
font-weight: 500;
line-height: 1.5;
color: var(--dsw-alias-label-primary);
}
@@ -29,19 +33,22 @@
}
.badge {
padding: 0 8px;
border-radius: 10px;
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
font-weight: 500;
background: var(--dsw-alias-bg-module-platform);
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
}
.badgeMuted {
padding: 0 8px;
border-radius: 10px;
font-size: 12px;
line-height: 20px;
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
@@ -51,7 +58,7 @@
padding: 0;
font: inherit;
font-size: 12px;
line-height: 20px;
line-height: 1.5;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
@@ -65,17 +72,22 @@
}
.input {
height: 36px;
height: 34px;
padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-module-platform);
background: var(--dsw-alias-bg-layer-3);
font: inherit;
font-size: 14px;
line-height: 22px;
font-size: 13px;
line-height: 1.5;
color: var(--dsw-alias-label-primary);
}
.input:focus-visible {
outline: none;
border-color: var(--dsw-alias-brand-primary);
}
.input:disabled {
color: var(--dsw-alias-label-tertiary);
cursor: default;
@@ -84,7 +96,6 @@
.hint {
margin: 0;
font-size: 12px;
font-weight: 400;
line-height: 18px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}
@@ -115,8 +115,11 @@ function DraftInput(props: {
/** A whole-number field committed on blur or Enter. */
export function NumberField(props: FieldProps & {
/** Current effective value. */
value: number
/**
* 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
}) {
@@ -124,13 +127,13 @@ export function NumberField(props: FieldProps & {
<FieldFrame {...props}>
<DraftInput
id={props.id}
value={String(props.value)}
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(String(props.value))
restore(props.value === undefined ? '' : String(props.value))
return
}
if (parsed === props.value) return
@@ -13,11 +13,9 @@ export type PluginConfigKey =
/** English copy. */
export const en: Record<PluginConfigKey, string> = {
nav: 'Plugins',
nav: 'Plugin config',
title: 'Plugin configuration',
intro:
'Settings owned by the plugins this deployment composes. A value you set here layers over the '
+ 'composition default and applies to the next use.',
intro: 'Configure the plugins this deployment installed.',
empty: 'This deployment exposes no plugin settings.',
overridden: 'Overridden',
reset: 'Reset to default',
@@ -48,9 +46,9 @@ export const en: Record<PluginConfigKey, string> = {
/** Simplified Chinese copy. */
export const zh: Record<PluginConfigKey, string> = {
nav: '插件',
nav: '插件配置',
title: '插件配置',
intro: '本部署所组装插件自己拥有的设置。你在这里设的值会覆盖组装默认值,并在下一次使用时生效。',
intro: '配置本部署已安装的插件。',
empty: '本部署没有开放任何插件设置。',
overridden: '已覆盖',
reset: '恢复默认',
@@ -36,7 +36,7 @@ export interface WebSearchCardState extends CardShell {
/** Provider endpoint. */
baseURL: CardField<string>
/** Searches allowed per request. */
maxUses: CardField<number>
maxUses: CardField<number | undefined>
/** Credential reference the key is written under. */
apiKeyRef: string
/** Whether the Host reports a credential configured for that reference. */
@@ -78,7 +78,7 @@ export class WebSearchCardController extends CardController<WebSearchSettings, W
super(scope, snapshot => ({
...shellOf(snapshot),
baseURL: fieldOf(snapshot, 'baseURL', ''),
maxUses: fieldOf(snapshot, 'maxUses', 0),
maxUses: fieldOf(snapshot, 'maxUses', undefined),
apiKeyRef: refOf(snapshot),
apiKeyConfigured: credential.configured,
}))
@@ -48,7 +48,7 @@ describe('ui-plugin-config apply', () => {
const section = slots.entries('settings.section')[0]!
expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
// The nav label is a locale-following thunk; owners resolve it at read time.
expect(resolveSlotLabel(section.options.label)).toBe('插件')
expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
})
@@ -116,6 +116,22 @@ describe('NumberField', () => {
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()
@@ -14,7 +14,8 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16,
IconCloseOutline16, IconDataOutline16, IconPersonalizationOutline16,
IconSettingsOutline16, IconThinkOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
import css from './SettingsRoot.module.css'
@@ -23,6 +24,7 @@ import css from './SettingsRoot.module.css'
function navIcon(id: string) {
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} />
if (id === 'plugins') return <IconPersonalizationOutline16 className={css.navIcon} size={16} />
return <IconSettingsOutline16 className={css.navIcon} size={16} />
}
@@ -63,11 +63,14 @@ export interface Config {
export const Config: z<Config> = z.object({
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
// Declared here rather than only at the use site: a configuration surface
// renders the resolved section, so a default the schema does not carry reads
// there as no value at all.
baseURL: z.string(),
model: z.string(),
apiVersion: z.string(),
maxTokens: z.number().step(1).min(1),
maxUses: z.number().step(1).min(1),
model: z.string().default(DEEPSEEK_DEFAULT_MODEL),
apiVersion: z.string().default(DEEPSEEK_DEFAULT_API_VERSION),
maxTokens: z.number().step(1).min(1).default(DEEPSEEK_DEFAULT_MAX_TOKENS),
maxUses: z.number().step(1).min(1).default(DEEPSEEK_DEFAULT_MAX_USES),
})
/**
+4
View File
@@ -2291,6 +2291,10 @@ importers:
version: 18.3.1
packages/client/ui-plugin-config:
dependencies:
clsx:
specifier: ^2.0.0
version: 2.1.1
devDependencies:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^