refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
6
packages/client/ui-settings-plugins/README.i18n.yaml
Normal file
6
packages/client/ui-settings-plugins/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md
|
||||
README.md: 53418f8939649bae4413625a37630e46e7060d15
|
||||
README.zh.md: 8d4a2bf8d1af662e9f8e05ccc607cc8b66731731
|
||||
40
packages/client/ui-settings-plugins/README.md
Normal file
40
packages/client/ui-settings-plugins/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# dsh-client-ui-settings-plugins
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **Plugins** settings section and its **Plugin configuration** tab. The section owns the heading and compact tab chrome; feature plugins contribute pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
|
||||
|
||||
## What appears here
|
||||
|
||||
A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the configurable tab reflects what this deployment actually runs.
|
||||
|
||||
The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
|
||||
|
||||
## Extension point
|
||||
|
||||
The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs. It keeps a tab mounted after its first selection, so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` list slot. A plugin that ships a browser half registers its own card into that nested slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Both levels follow the contribution's `order`.
|
||||
|
||||
## Writes
|
||||
|
||||
A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped.
|
||||
|
||||
Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct.
|
||||
|
||||
A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the forwarded `credentials/updated` event for the reference it watches.
|
||||
|
||||
A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the section renders a browser configuration UI; the values it writes reach a model only through the plugins that own them, each documenting that effect itself.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job.
|
||||
- **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`.
|
||||
- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the card edits the same two fields on both, and a deployment composing neither shows no card.
|
||||
- **The empty line counts registered cards, not visible ones** — a card whose namespace this deployment does not expose renders nothing, but still counts, so a deployment that exposes none shows an empty list rather than the empty line. The count is also read once, because the renderer caches a root entry's inject face; a card registered later does not raise it.
|
||||
40
packages/client/ui-settings-plugins/README.zh.md
Normal file
40
packages/client/ui-settings-plugins/README.zh.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# dsh-client-ui-settings-plugins
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**插件**设置分区及其**插件配置**标签页。该分区拥有标题与紧凑的标签栏;功能插件通过 `settings.plugins.tab` 贡献页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
|
||||
|
||||
## 这里会出现什么
|
||||
|
||||
只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此“插件配置”标签页反映的是该部署实际运行的东西。
|
||||
|
||||
第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
|
||||
|
||||
## 扩展点
|
||||
|
||||
本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` 列表 slot。带浏览器半侧的插件把自己的卡片注册进这个嵌套 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。两层排序都遵循贡献的 `order`。
|
||||
|
||||
## 写入
|
||||
|
||||
卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。
|
||||
|
||||
保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。
|
||||
|
||||
密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在转发来的 `credentials/updated` 事件报告它所关注的引用时重读。
|
||||
|
||||
字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该分区渲染浏览器配置 UI;它写入的值只通过拥有这些值的插件到达模型,而这些效应各由其拥有方的包记录。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。
|
||||
- **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里。
|
||||
- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段;而两者都不组装的部署不会显示这张卡片。
|
||||
- **空态数的是已注册卡片,不是可见卡片**——命名空间未被本部署暴露的卡片什么都不渲染,但仍计入数量,因此一个都不暴露的部署看到的是空列表而非那行空态文案。该计数还只读取一次,因为渲染器会缓存根级 entry 的 inject face;之后注册的卡片不会让它变大。
|
||||
86
packages/client/ui-settings-plugins/package.json
Normal file
86
packages/client/ui-settings-plugins/package.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-plugins",
|
||||
"description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-settings-plugins"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** The agent loop's card: how many tool calls one step may run at once. */
|
||||
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { ValueField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { AgentLoopCardFace } from './agent-loop-card-controller.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Props the renderer binds for the agent-loop card. */
|
||||
export type AgentLoopCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.plugins'>
|
||||
& InjectFace<AgentLoopCardFace>
|
||||
|
||||
/**
|
||||
* Render the agent-loop card.
|
||||
* @param props - locale copy, the card snapshot, and its form actions.
|
||||
* @returns the card.
|
||||
*/
|
||||
export function AgentLoopCard(props: AgentLoopCardProps) {
|
||||
const { t } = props
|
||||
const state = props.useAgentLoopCard(snapshot => snapshot)
|
||||
return (
|
||||
<PluginCard
|
||||
t={t}
|
||||
titleKey="agentLoopTitle"
|
||||
descriptionKey="agentLoopDescription"
|
||||
state={state}
|
||||
onSave={props.save}
|
||||
onDiscard={props.discard}
|
||||
>
|
||||
<ValueField
|
||||
id="plugin-config-agent-loop-parallel"
|
||||
label={t('agentLoopMaxParallel')}
|
||||
hint={t('agentLoopMaxParallelHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
numeric
|
||||
disabled={!state.writable}
|
||||
{...state.maxParallelToolCalls}
|
||||
onEdit={(text) => { props.edit('maxParallelToolCalls', text) }}
|
||||
onReset={() => { props.resetField('maxParallelToolCalls') }}
|
||||
/>
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
61
packages/client/ui-settings-plugins/src/client/BashCard.tsx
Normal file
61
packages/client/ui-settings-plugins/src/client/BashCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/** The shell plugin's card: the limits every command the agent runs is bound by. */
|
||||
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { ValueField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { BashCardFace } from './bash-card-controller.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Props the renderer binds for the shell card. */
|
||||
export type BashCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.plugins'>
|
||||
& InjectFace<BashCardFace>
|
||||
|
||||
/**
|
||||
* Render the shell card.
|
||||
* @param props - locale copy, the card snapshot, and its form actions.
|
||||
* @returns the card.
|
||||
*/
|
||||
export function BashCard(props: BashCardProps) {
|
||||
const { t } = props
|
||||
const state = props.useBashCard(snapshot => snapshot)
|
||||
const disabled = !state.writable
|
||||
return (
|
||||
<PluginCard
|
||||
t={t}
|
||||
titleKey="bashTitle"
|
||||
descriptionKey="bashDescription"
|
||||
state={state}
|
||||
onSave={props.save}
|
||||
onDiscard={props.discard}
|
||||
>
|
||||
<ValueField
|
||||
id="plugin-config-bash-timeout"
|
||||
label={t('bashTimeoutMs')}
|
||||
hint={t('bashTimeoutMsHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
numeric
|
||||
disabled={disabled}
|
||||
{...state.timeoutMs}
|
||||
onEdit={(text) => { props.edit('timeoutMs', text) }}
|
||||
onReset={() => { props.resetField('timeoutMs') }}
|
||||
/>
|
||||
<ValueField
|
||||
id="plugin-config-bash-output"
|
||||
label={t('bashMaxOutputBytes')}
|
||||
hint={t('bashMaxOutputBytesHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
numeric
|
||||
disabled={disabled}
|
||||
{...state.maxOutputBytes}
|
||||
onEdit={(text) => { props.edit('maxOutputBytes', text) }}
|
||||
onReset={() => { props.resetField('maxOutputBytes') }}
|
||||
/>
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Configurable Host plugins contributed to the shared Plugins section. */
|
||||
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from './slot-contract.ts'
|
||||
import css from './PluginsSettingsSection.module.css'
|
||||
|
||||
/** Registration-side business face for the configurable tab. */
|
||||
export interface ConfigurablePluginsTabInjected {
|
||||
/** How many cards the slot ledger held when the tab registration mounted. */
|
||||
cardCount: number
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the configurable tab. */
|
||||
export type ConfigurablePluginsTabProps =
|
||||
PropsRuntime<'settings.plugins.tab'>
|
||||
& PropsLocale<'settings.plugins'>
|
||||
& PropsRenderSlots<'settings.plugin.item'>
|
||||
& InjectFace<ConfigurablePluginsTabInjected>
|
||||
|
||||
/** Render cards registered by plugins that expose editable settings. */
|
||||
export function ConfigurablePluginsTab({ t, renderSlot, cardCount }: ConfigurablePluginsTabProps) {
|
||||
return cardCount === 0
|
||||
? <p className={css.empty}>{t('empty')}</p>
|
||||
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* Plugin card: a header that names the plugin, disclosing its controls in place. */
|
||||
|
||||
.card {
|
||||
list-style: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
transition: border-color .16s, background .16s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* An open card reads as the one being worked on, not merely taller. */
|
||||
.cardOpen {
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.header:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Name over description: the description is what tells two plugins apart, so
|
||||
it gets its own line rather than trailing the name. */
|
||||
.headText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
transition: transform .16s;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.body {
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
margin: 0 16px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Carried on the header so a collapsed card still says it holds edits. */
|
||||
.pending {
|
||||
flex: none;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 0 4px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.failed {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-error);
|
||||
}
|
||||
|
||||
.discard,
|
||||
.save {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 5px 14px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.discard {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.discard:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.save {
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.discard:disabled,
|
||||
.save:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.discard:focus-visible,
|
||||
.save:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* One plugin's card: a header naming the plugin and what its settings govern,
|
||||
* disclosing that plugin's controls in place, with the save that writes them.
|
||||
*
|
||||
* The header is its own button rather than a shared disclosure row because a
|
||||
* card stacks its name over its description, while that row lays the two side
|
||||
* by side — the layout, not the behavior, is what differs. Disclosure is
|
||||
* card-local state: which card a user has open is a reading gesture, not
|
||||
* something the Host or the section has any stake in. Staged edits outlive
|
||||
* collapsing, so the header marks a card holding unsaved edits.
|
||||
*
|
||||
* A card renders nothing while its namespace is unavailable: a deployment that
|
||||
* does not compose the owning plugin should show no trace of it, rather than a
|
||||
* disabled card the user cannot act on.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { CardShell } from './card-form.ts'
|
||||
import type { PluginsSettingsLocaleKey } from './locales.ts'
|
||||
import css from './PluginCard.module.css'
|
||||
|
||||
/** Card chrome shared by every plugin section. */
|
||||
export interface PluginCardProps {
|
||||
/** Locale reader for this section's copy. */
|
||||
t: (key: PluginsSettingsLocaleKey) => string
|
||||
/** Locale key of the plugin's name. */
|
||||
titleKey: PluginsSettingsLocaleKey
|
||||
/** Locale key of the line describing what this plugin's settings govern. */
|
||||
descriptionKey: PluginsSettingsLocaleKey
|
||||
/** The card's form state: availability, writability, and what a save would do. */
|
||||
state: CardShell
|
||||
/** Write every staged edit. */
|
||||
onSave: () => void
|
||||
/** Drop every staged edit. */
|
||||
onDiscard: () => void
|
||||
/** The plugin's controls. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one plugin card.
|
||||
* @param props - the plugin's copy keys, its form state, and its controls.
|
||||
* @returns the card, or nothing when the namespace is unavailable.
|
||||
*/
|
||||
export function PluginCard(props: PluginCardProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { state } = props
|
||||
if (!state.available) return null
|
||||
const title = props.t(props.titleKey)
|
||||
const blocked = !state.dirty || state.invalid || state.saving
|
||||
return (
|
||||
<li className={clsx(css.card, open && css.cardOpen)}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.header}
|
||||
aria-expanded={open}
|
||||
aria-label={`${props.t(open ? 'collapse' : 'expand')}: ${title}`}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<span className={css.headText}>
|
||||
<span className={css.name}>{title}</span>
|
||||
<span className={css.description}>{props.t(props.descriptionKey)}</span>
|
||||
</span>
|
||||
{state.dirty ? <span className={css.pending}>{props.t('unsaved')}</span> : null}
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
|
||||
</button>
|
||||
{open
|
||||
? (
|
||||
<div className={css.body}>
|
||||
{!state.writable ? <p className={css.readOnly} role="status">{props.t('readOnly')}</p> : null}
|
||||
{props.children}
|
||||
<div className={css.footer}>
|
||||
{state.failed ? <p className={css.failed} role="status">{props.t('saveFailed')}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
className={css.discard}
|
||||
disabled={!state.dirty || state.saving}
|
||||
onClick={props.onDiscard}
|
||||
>
|
||||
{props.t('discard')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.save}
|
||||
disabled={blocked}
|
||||
onClick={props.onSave}
|
||||
>
|
||||
{props.t(state.saving ? 'saving' : 'save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* Plugins section: compact tabs plus the configurable plugin card list. */
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 760px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 22px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
border: 0;
|
||||
padding: 7px 1px 9px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab:hover,
|
||||
.tab[data-active='true'] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.tab[data-active='true']::after,
|
||||
.tab:focus-visible::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/** Plugins settings section: localized tabs around feature-owned pages. */
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import type {
|
||||
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PluginsSettingsLocaleKey } from './locales.ts'
|
||||
import css from './PluginsSettingsSection.module.css'
|
||||
|
||||
/** One tab projected from a `settings.plugins.tab` contribution. */
|
||||
export interface PluginsSettingsTabEntry {
|
||||
id: string
|
||||
order: number
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Registration-side business face for the section. */
|
||||
export interface PluginsSettingsSectionInjected {
|
||||
hooks: {
|
||||
/** Ordered, locale-aware projection of the Plugins tab ledger. */
|
||||
tabs: HostObservable<readonly PluginsSettingsTabEntry[]>
|
||||
}
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the section. */
|
||||
export type PluginsSettingsSectionProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
& PropsLocale<'settings.plugins'>
|
||||
& PropsRenderSlots<'settings.plugins.tab'>
|
||||
& InjectFace<PluginsSettingsSectionInjected>
|
||||
|
||||
/** Render one Plugins page whose contents arrive from feature-owned tabs. */
|
||||
export function PluginsSettingsSection({ t, renderSlot, useTabs }: PluginsSettingsSectionProps) {
|
||||
const tabsId = useId()
|
||||
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const rows = useTabs(value => value)
|
||||
const [activeId, setActiveId] = useState<string>()
|
||||
const [visitedIds, setVisitedIds] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const active = rows.find(row => row.id === activeId)?.id ?? rows[0]?.id
|
||||
|
||||
// A tab mounts only when first selected, then stays mounted while hidden so
|
||||
// local drafts, disclosure state, search, and the inventory snapshot survive
|
||||
// switching between the two views.
|
||||
useEffect(() => {
|
||||
if (active === undefined) return
|
||||
setVisitedIds((previous) => {
|
||||
if (previous.has(active)) return previous
|
||||
return new Set([...previous, active])
|
||||
})
|
||||
}, [active])
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.heading}>{t('title')}</h2>
|
||||
<p className={css.intro}>{t('intro')}</p>
|
||||
{rows.length === 0 ? <p className={css.empty}>{t('empty')}</p> : (
|
||||
<>
|
||||
<div className={css.tabs} role="tablist" aria-label={t('tabs')}>
|
||||
{rows.map((row, index) => {
|
||||
const selected = row.id === active
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
ref={(element) => { tabRefs.current[index] = element }}
|
||||
id={`${tabsId}-tab-${row.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
className={css.tab}
|
||||
aria-selected={selected}
|
||||
aria-controls={`${tabsId}-panel-${row.id}`}
|
||||
data-active={selected ? 'true' : undefined}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => { setActiveId(row.id) }}
|
||||
onKeyDown={(event) => {
|
||||
let nextIndex: number
|
||||
switch (event.key) {
|
||||
case 'ArrowRight': nextIndex = (index + 1) % rows.length; break
|
||||
case 'ArrowLeft': nextIndex = (index - 1 + rows.length) % rows.length; break
|
||||
case 'Home': nextIndex = 0; break
|
||||
case 'End': nextIndex = rows.length - 1; break
|
||||
default: return
|
||||
}
|
||||
event.preventDefault()
|
||||
const nextRow = rows[nextIndex] as PluginsSettingsTabEntry
|
||||
const nextTab = tabRefs.current[nextIndex] as HTMLButtonElement
|
||||
setActiveId(nextRow.id)
|
||||
nextTab.focus()
|
||||
}}
|
||||
>
|
||||
{row.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{rows
|
||||
.filter(row => row.id === active || visitedIds.has(row.id))
|
||||
.map((row) => {
|
||||
const selected = row.id === active
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
id={`${tabsId}-panel-${row.id}`}
|
||||
className={css.panel}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${tabsId}-tab-${row.id}`}
|
||||
hidden={!selected}
|
||||
>
|
||||
{renderSlot('settings.plugins.tab', {}, { only: row.id })}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Plugins section, configurable-tab, and card copy. */
|
||||
'settings.plugins': PluginsSettingsLocaleKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The web-search provider's card: its endpoint, its per-request search budget,
|
||||
* and the key — which is written through the credentials domain, never into
|
||||
* the settings section, so the literal never rides a response.
|
||||
*/
|
||||
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SecretField, ValueField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { WebSearchCardFace } from './web-search-card-controller.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Props the renderer binds for the web-search card. */
|
||||
export type WebSearchCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.plugins'>
|
||||
& InjectFace<WebSearchCardFace>
|
||||
|
||||
/**
|
||||
* Render the web-search card.
|
||||
* @param props - locale copy, the card snapshot, and its form actions.
|
||||
* @returns the card.
|
||||
*/
|
||||
export function WebSearchCard(props: WebSearchCardProps) {
|
||||
const { t } = props
|
||||
const state = props.useWebSearchCard(snapshot => snapshot)
|
||||
const disabled = !state.writable
|
||||
return (
|
||||
<PluginCard
|
||||
t={t}
|
||||
titleKey="webSearchTitle"
|
||||
descriptionKey="webSearchDescription"
|
||||
state={state}
|
||||
onSave={props.save}
|
||||
onDiscard={props.discard}
|
||||
>
|
||||
<SecretField
|
||||
id="plugin-config-web-search-key"
|
||||
label={t('webSearchApiKey')}
|
||||
hint={t('webSearchApiKeyHint')}
|
||||
// The credentials domain accepts a key even when the settings document
|
||||
// itself is read-only; they are separate stores with separate refusals.
|
||||
// Its own writability is what disables this control — a key sourced
|
||||
// from the process environment cannot be written from here.
|
||||
disabled={!state.apiKeyWritable}
|
||||
text={state.apiKey.text}
|
||||
configured={state.apiKeyConfigured}
|
||||
stateLabel={state.apiKeyConfigured ? t('webSearchApiKeySet') : t('webSearchApiKeyUnset')}
|
||||
onEdit={(text) => { props.edit('apiKey', text) }}
|
||||
/>
|
||||
<ValueField
|
||||
id="plugin-config-web-search-endpoint"
|
||||
label={t('webSearchBaseUrl')}
|
||||
hint={t('webSearchBaseUrlHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
disabled={disabled}
|
||||
{...state.baseURL}
|
||||
onEdit={(text) => { props.edit('baseURL', text) }}
|
||||
onReset={() => { props.resetField('baseURL') }}
|
||||
/>
|
||||
<ValueField
|
||||
id="plugin-config-web-search-max-uses"
|
||||
label={t('webSearchMaxUses')}
|
||||
hint={t('webSearchMaxUsesHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
numeric
|
||||
disabled={disabled}
|
||||
{...state.maxUses}
|
||||
onEdit={(text) => { props.edit('maxUses', text) }}
|
||||
onReset={() => { props.resetField('maxUses') }}
|
||||
/>
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/** The agent-loop card's staged form over the `agent-loop` settings namespace. */
|
||||
|
||||
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts'
|
||||
|
||||
/**
|
||||
* Namespace of the agent loop's user-owned settings. Spelled here rather than
|
||||
* imported: a client package must not depend on a Host package.
|
||||
*/
|
||||
export const AGENT_LOOP_NS = 'agent-loop'
|
||||
|
||||
/**
|
||||
* The agent-loop fields this card edits. The Host section carries only this
|
||||
* field — the composed `agents` array is deliberately not part of it.
|
||||
*/
|
||||
export interface AgentLoopSettings {
|
||||
/** Upper bound on parallel-safe tool calls in flight per step. */
|
||||
maxParallelToolCalls?: number
|
||||
}
|
||||
|
||||
/** What the agent-loop card renders. */
|
||||
export interface AgentLoopCardState extends CardShell {
|
||||
/** Parallel tool-call cap. */
|
||||
maxParallelToolCalls: CardFieldState
|
||||
}
|
||||
|
||||
/** The registration-side face the agent-loop card's slot entry injects. */
|
||||
export interface AgentLoopCardFace extends CardActions {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useAgentLoopCard. */
|
||||
agentLoopCard: SnapshotStore<AgentLoopCardState>
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridges the `agent-loop` scope onto the card's staged form. */
|
||||
export class AgentLoopCardController {
|
||||
private readonly form: CardForm<AgentLoopSettings>
|
||||
private readonly store: SnapshotStore<AgentLoopCardState>
|
||||
|
||||
/** @param scope - the bound settings scope for the `agent-loop` namespace. */
|
||||
constructor(scope: SettingsScope<AgentLoopSettings>) {
|
||||
this.form = new CardForm(scope, [numberField('maxParallelToolCalls')])
|
||||
this.store = this.form.bind(() => this.projection())
|
||||
}
|
||||
|
||||
private projection(): AgentLoopCardState {
|
||||
return { ...this.form.shell(), maxParallelToolCalls: this.form.field('maxParallelToolCalls') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the card's slot registration injects.
|
||||
* @returns the card's snapshot and its form actions.
|
||||
*/
|
||||
inject(): AgentLoopCardFace {
|
||||
return { hooks: { agentLoopCard: this.store }, ...this.form.actions() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/** The shell card's staged form over the `bash` settings namespace. */
|
||||
|
||||
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts'
|
||||
|
||||
/**
|
||||
* Namespace of the shell capability. Spelled here rather than imported: a
|
||||
* client package must not depend on a Host package, and the executor families
|
||||
* that own it spell the same value.
|
||||
*/
|
||||
export const SHELL_NS = 'shell'
|
||||
|
||||
/** The shell fields this card edits — a subset of the served schema by design. */
|
||||
export interface BashSettings {
|
||||
/** Foreground command timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Per-stream in-memory output cap in bytes. */
|
||||
maxOutputBytes?: number
|
||||
}
|
||||
|
||||
/** What the shell card renders. */
|
||||
export interface BashCardState extends CardShell {
|
||||
/** Command timeout in milliseconds. */
|
||||
timeoutMs: CardFieldState
|
||||
/** Per-stream output cap in bytes. */
|
||||
maxOutputBytes: CardFieldState
|
||||
}
|
||||
|
||||
/** The registration-side face the shell card's slot entry injects. */
|
||||
export interface BashCardFace extends CardActions {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useBashCard. */
|
||||
bashCard: SnapshotStore<BashCardState>
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridges the `bash` scope onto the shell card's staged form. */
|
||||
export class BashCardController {
|
||||
private readonly form: CardForm<BashSettings>
|
||||
private readonly store: SnapshotStore<BashCardState>
|
||||
|
||||
/** @param scope - the bound settings scope for the `bash` namespace. */
|
||||
constructor(scope: SettingsScope<BashSettings>) {
|
||||
this.form = new CardForm(scope, [numberField('timeoutMs'), numberField('maxOutputBytes')])
|
||||
this.store = this.form.bind(() => this.projection())
|
||||
}
|
||||
|
||||
private projection(): BashCardState {
|
||||
return {
|
||||
...this.form.shell(),
|
||||
timeoutMs: this.form.field('timeoutMs'),
|
||||
maxOutputBytes: this.form.field('maxOutputBytes'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the card's slot registration injects.
|
||||
* @returns the card's snapshot and its form actions.
|
||||
*/
|
||||
inject(): BashCardFace {
|
||||
return { hooks: { bashCard: this.store }, ...this.form.actions() }
|
||||
}
|
||||
}
|
||||
351
packages/client/ui-settings-plugins/src/client/card-form.ts
Normal file
351
packages/client/ui-settings-plugins/src/client/card-form.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Shared form model behind every plugin card.
|
||||
*
|
||||
* A card stages what the user types and writes it only when they save. Each
|
||||
* settings write is a durable, revision-fenced document mutation, so a control
|
||||
* that committed as it settled turned one edit into a write the user never
|
||||
* asked for and could not preview; staged text makes what is on screen exactly
|
||||
* what a save would store.
|
||||
*
|
||||
* A field shows its effective value — the user layer over the composition
|
||||
* layer over the schema default — and whether the user layer carries it. That
|
||||
* presence, not a value comparison, is what marks a field overridden: an
|
||||
* override equal to the composition default is still an override.
|
||||
*/
|
||||
|
||||
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** The write one field's staged text performs when the card is saved. */
|
||||
export type FieldWrite =
|
||||
| { kind: 'set'; value: unknown }
|
||||
| { kind: 'clear' }
|
||||
|
||||
/** How one section field converts between its stored value and its draft text. */
|
||||
export interface CardFieldSpec {
|
||||
/** Field name inside the namespace section. */
|
||||
field: string
|
||||
/** Render a stored value as draft text; the empty string when the section carries none. */
|
||||
format: (value: unknown) => string
|
||||
/**
|
||||
* The write this draft text stages, or undefined when the text is not a
|
||||
* value this field accepts — which blocks the save rather than discarding it.
|
||||
*/
|
||||
parse: (text: string) => FieldWrite | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A control whose value is written outside the settings section. A credential
|
||||
* literal never rides a response, so its draft has nothing to seed from: it is
|
||||
* blank until typed, and a blank draft writes nothing.
|
||||
*/
|
||||
export interface CardSecretSpec {
|
||||
/** Field name addressing this control inside the card's form. */
|
||||
field: string
|
||||
/** Write the staged text; resolves to whether the Host accepted it. */
|
||||
write: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
/** One field as a card's control renders it. */
|
||||
export interface CardFieldState {
|
||||
/** Draft text the control renders. */
|
||||
text: string
|
||||
/**
|
||||
* Whether saving would leave a user-layer entry for this field. A staged
|
||||
* edit answers for itself, so the badge previews the save rather than
|
||||
* reporting a state the pending edit already contradicts.
|
||||
*/
|
||||
overridden: boolean
|
||||
/** Whether the draft is not a value this field accepts, which blocks saving. */
|
||||
invalid: boolean
|
||||
}
|
||||
|
||||
/** Form state every plugin card shares. */
|
||||
export interface CardShell {
|
||||
/** False while the namespace is not served to this client; the card renders nothing. */
|
||||
available: boolean
|
||||
/** Whether the Host document accepts writes. */
|
||||
writable: boolean
|
||||
/** Whether the form holds edits that a save would write. */
|
||||
dirty: boolean
|
||||
/** Whether any staged draft is invalid, which blocks the save. */
|
||||
invalid: boolean
|
||||
/** Whether a save is crossing the wire. */
|
||||
saving: boolean
|
||||
/** Whether the last save did not land as staged; cleared by the next edit or save. */
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
/** The write actions every plugin card's slot entry injects. */
|
||||
export interface CardActions {
|
||||
/** Stage draft text for one field. */
|
||||
edit: (field: string, text: string) => void
|
||||
/** Stage a clear, so saving lets the field re-inherit the composition layer. */
|
||||
resetField: (field: string) => void
|
||||
/** Write every staged edit, then re-seed from what the Host accepted. */
|
||||
save: () => void
|
||||
/** Drop every staged edit. */
|
||||
discard: () => void
|
||||
}
|
||||
|
||||
/** One field's staged edit. */
|
||||
interface StagedEdit {
|
||||
/** Draft text the control renders. */
|
||||
text: string
|
||||
/** True when this edit clears the field whatever text it shows. */
|
||||
clear: boolean
|
||||
}
|
||||
|
||||
/** One staged edit resolved into the write a save performs. */
|
||||
interface PlannedWrite {
|
||||
/** Field this entry writes. */
|
||||
field: string
|
||||
/**
|
||||
* Perform the write and report whether the Host holds the staged value
|
||||
* afterwards; undefined when the draft is not a value the field accepts.
|
||||
*/
|
||||
run: (() => Promise<boolean>) | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole-number field. An empty draft clears the field; any other draft that
|
||||
* is not a finite number blocks the save.
|
||||
* @param field - field name inside the namespace section.
|
||||
* @returns the field's conversion spec.
|
||||
*/
|
||||
export function numberField(field: string): CardFieldSpec {
|
||||
return {
|
||||
field,
|
||||
// A section that carries no number for this field renders empty rather
|
||||
// than as a value nobody chose.
|
||||
format: value => typeof value === 'number' ? String(value) : '',
|
||||
parse: (text) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return { kind: 'clear' }
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A free-text field. An empty draft clears the field, so emptying the control
|
||||
* and saving is the same gesture as resetting it.
|
||||
* @param field - field name inside the namespace section.
|
||||
* @returns the field's conversion spec.
|
||||
*/
|
||||
export function textField(field: string): CardFieldSpec {
|
||||
return {
|
||||
field,
|
||||
format: value => typeof value === 'string' ? value : '',
|
||||
parse: (text) => {
|
||||
const trimmed = text.trim()
|
||||
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages one card's edits over one settings namespace and writes them on save.
|
||||
*
|
||||
* The form publishes through a snapshot store because slot components read
|
||||
* through a snapshot selector, while both the scope and the local drafts
|
||||
* change underneath; every projection is rebuilt from the two together.
|
||||
*/
|
||||
export class CardForm<T> {
|
||||
private readonly specs: Map<string, CardFieldSpec>
|
||||
private readonly secretSpecs: Map<string, CardSecretSpec>
|
||||
private readonly staged = new Map<string, StagedEdit>()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private saving = false
|
||||
private failed = false
|
||||
|
||||
/**
|
||||
* @param scope - the bound settings scope for this card's namespace.
|
||||
* @param specs - the section fields this card edits.
|
||||
* @param secrets - the card's write-only controls, written outside the section.
|
||||
*/
|
||||
constructor(
|
||||
private readonly scope: SettingsScope<T>,
|
||||
specs: CardFieldSpec[],
|
||||
secrets: CardSecretSpec[] = [],
|
||||
) {
|
||||
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
||||
this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec]))
|
||||
scope.subscribe(() => { this.publish() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a projection of this form, rebuilt whenever the scope or a draft changes.
|
||||
* @param project - build the card's state from the form's current reads.
|
||||
* @returns the store the card's component reads through its bound selector.
|
||||
*/
|
||||
bind<S>(project: () => S): SnapshotStore<S> {
|
||||
const store = createSnapshotStore(project())
|
||||
this.listeners.add(() => { store.set(project()) })
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the card-level state: what the Host serves, and what a save would do.
|
||||
* @returns the form state every card shares.
|
||||
*/
|
||||
shell(): CardShell {
|
||||
const snapshot = this.scope.getSnapshot()
|
||||
const plan = this.plan()
|
||||
return {
|
||||
available: snapshot.status === 'ready',
|
||||
writable: snapshot.writable,
|
||||
dirty: plan.length > 0,
|
||||
invalid: plan.some(item => item.run === undefined),
|
||||
saving: this.saving,
|
||||
failed: this.failed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one control's state.
|
||||
* @param field - field name of a section field or of a write-only control.
|
||||
* @returns the draft text, whether a save would leave an override, and whether it is invalid.
|
||||
*/
|
||||
field(field: string): CardFieldState {
|
||||
const staged = this.staged.get(field)
|
||||
if (this.secretSpecs.has(field)) {
|
||||
return { text: staged?.text ?? '', overridden: false, invalid: false }
|
||||
}
|
||||
const spec = this.spec(field)
|
||||
if (staged === undefined) {
|
||||
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
||||
}
|
||||
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
|
||||
return {
|
||||
text: staged.text,
|
||||
overridden: write?.kind === 'set',
|
||||
invalid: write === undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the edit, reset, save, and discard actions bound to this form.
|
||||
* @returns the actions a card's slot entry injects.
|
||||
*/
|
||||
actions(): CardActions {
|
||||
return {
|
||||
edit: (field, text) => { this.stage(field, { text, clear: false }) },
|
||||
resetField: (field) => {
|
||||
this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true })
|
||||
},
|
||||
save: () => { void this.save() },
|
||||
discard: () => {
|
||||
if (this.staged.size === 0 && !this.failed) return
|
||||
this.staged.clear()
|
||||
this.failed = false
|
||||
this.publish()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write every staged edit, then re-seed from what the Host accepted.
|
||||
*
|
||||
* The Host is the only authority on whether a value was accepted — its
|
||||
* validators own the constraints no schema can express — so the outcome is
|
||||
* read back from the section rather than predicted here. A save that did not
|
||||
* land keeps its drafts, so the user can correct them instead of retyping.
|
||||
* @returns settlement after every write and the read-back.
|
||||
*/
|
||||
async save(): Promise<void> {
|
||||
const plan = this.plan()
|
||||
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
|
||||
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
|
||||
this.saving = true
|
||||
this.failed = false
|
||||
this.publish()
|
||||
let landed = true
|
||||
for (const write of writes) {
|
||||
landed = await write() && landed
|
||||
}
|
||||
if (landed) this.staged.clear()
|
||||
this.saving = false
|
||||
this.failed = !landed
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every staged edit a save would write. An entry whose draft is not a value
|
||||
* its field accepts carries no write: the form is still dirty, and the save
|
||||
* refuses rather than dropping the edit.
|
||||
* @returns the planned writes, in the order the fields were staged.
|
||||
*/
|
||||
private plan(): PlannedWrite[] {
|
||||
const plan: PlannedWrite[] = []
|
||||
for (const [field, staged] of this.staged) {
|
||||
const secret = this.secretSpecs.get(field)
|
||||
if (secret !== undefined) {
|
||||
const value = staged.text.trim()
|
||||
if (value !== '') plan.push({ field, run: () => secret.write(value) })
|
||||
continue
|
||||
}
|
||||
const spec = this.spec(field)
|
||||
if (staged.clear) {
|
||||
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
|
||||
continue
|
||||
}
|
||||
if (staged.text === spec.format(this.sectionValue(field))) continue
|
||||
const write = spec.parse(staged.text)
|
||||
if (write === undefined) plan.push({ field, run: undefined })
|
||||
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
||||
else plan.push({ field, run: () => this.store(field, write.value) })
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
private async clear(field: string): Promise<boolean> {
|
||||
await this.scope.unset(field)
|
||||
return !this.stored(field)
|
||||
}
|
||||
|
||||
private async store(field: string, value: unknown): Promise<boolean> {
|
||||
await this.scope.set(field, value)
|
||||
return this.userLayer()?.[field] === value
|
||||
}
|
||||
|
||||
private stage(field: string, edit: StagedEdit): void {
|
||||
this.staged.set(field, edit)
|
||||
this.failed = false
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private spec(field: string): CardFieldSpec {
|
||||
const spec = this.specs.get(field)
|
||||
// Every call site names a field this card declared; a missing one is a
|
||||
// wiring mistake that must not degrade into a silently inert control.
|
||||
if (spec === undefined) throw new Error(`plugin card has no field ${field}`)
|
||||
return spec
|
||||
}
|
||||
|
||||
private snapshotOf(): SettingsScopeSnapshot<T> {
|
||||
return this.scope.getSnapshot()
|
||||
}
|
||||
|
||||
private sectionValue(field: string): unknown {
|
||||
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
|
||||
}
|
||||
|
||||
private baseValue(field: string): unknown {
|
||||
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
|
||||
}
|
||||
|
||||
private userLayer(): Record<string, unknown> | undefined {
|
||||
return this.snapshotOf().user as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
private stored(field: string): boolean {
|
||||
const user = this.userLayer()
|
||||
return user !== undefined && Object.hasOwn(user, field)
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
}
|
||||
113
packages/client/ui-settings-plugins/src/client/fields.module.css
Normal file
113
packages/client/ui-settings-plugins/src/client/fields.module.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* Plugin configuration fields: label, control, override badge, and hint. */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.field + .field {
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.badgeMuted {
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.reset {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.reset:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.inputInvalid {
|
||||
composes: input;
|
||||
border-color: var(--dsw-alias-label-error);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-error);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
123
packages/client/ui-settings-plugins/src/client/fields.tsx
Normal file
123
packages/client/ui-settings-plugins/src/client/fields.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Hand-written controls for the plugin configuration forms. Each renders one
|
||||
* field's label, its staged text, whether saving would leave an override, and
|
||||
* — when one stands — the reset that stages a clear back to the composition
|
||||
* layer. Nothing here writes: a control reports what the user typed, and the
|
||||
* card's save is the single point where a draft becomes a document mutation.
|
||||
*/
|
||||
|
||||
import css from './fields.module.css'
|
||||
|
||||
/** What every field control needs regardless of its value type. */
|
||||
export interface FieldProps {
|
||||
/** Stable id associating the label with its control. */
|
||||
id: string
|
||||
/** Visible label. */
|
||||
label: string
|
||||
/** One-line explanation rendered under the control. */
|
||||
hint: string
|
||||
/** Draft text this control renders. */
|
||||
text: string
|
||||
/** True when saving would leave a user-layer entry for this field. */
|
||||
overridden: boolean
|
||||
/** True when the draft is not a value this field accepts. */
|
||||
invalid: boolean
|
||||
/** Copy for the overridden badge. */
|
||||
overriddenLabel: string
|
||||
/** Copy for the reset control. */
|
||||
resetLabel: string
|
||||
/** Copy shown in place of the hint while the draft is invalid. */
|
||||
invalidLabel: string
|
||||
/** Disables every control (read-only document, or an unavailable namespace). */
|
||||
disabled: boolean
|
||||
/** Stage draft text. */
|
||||
onEdit: (text: string) => void
|
||||
/** Stage a clear so the field re-inherits the composition layer. */
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A staged value field. `numeric` only hints the keypad: which drafts a field
|
||||
* accepts is decided by its spec, so the control never silently rewrites what
|
||||
* the user typed.
|
||||
* @param props - the field's copy, its staged text, and the edit actions.
|
||||
* @returns the labelled control.
|
||||
*/
|
||||
export function ValueField(props: FieldProps & {
|
||||
/** Hints a numeric keypad without narrowing what the control accepts. */
|
||||
numeric?: boolean
|
||||
/** Placeholder shown while the draft is empty. */
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={css.field}>
|
||||
<div className={css.head}>
|
||||
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
||||
{props.overridden
|
||||
? (
|
||||
<span className={css.badges}>
|
||||
<span className={css.badge}>{props.overriddenLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.reset}
|
||||
disabled={props.disabled}
|
||||
onClick={props.onReset}
|
||||
>
|
||||
{props.resetLabel}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<input
|
||||
id={props.id}
|
||||
className={props.invalid ? css.inputInvalid : css.input}
|
||||
type="text"
|
||||
{...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
|
||||
{...props.invalid ? { 'aria-invalid': true } : {}}
|
||||
value={props.text}
|
||||
placeholder={props.placeholder ?? ''}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => { props.onEdit(event.target.value) }}
|
||||
/>
|
||||
<p className={props.invalid ? css.invalid : css.hint}>
|
||||
{props.invalid ? props.invalidLabel : props.hint}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A write-only credential control. The value never rides a response, so the
|
||||
* control reports only whether one is configured and starts blank; a blank
|
||||
* draft writes nothing, which keeps the stored key rather than clearing it.
|
||||
* @param props - the field's copy, its staged text, and the configured state.
|
||||
* @returns the labelled control.
|
||||
*/
|
||||
export function SecretField(props: Pick<FieldProps, 'id' | 'label' | 'hint' | 'text' | 'disabled' | 'onEdit'> & {
|
||||
/** Whether the Host reports a configured credential for this reference. */
|
||||
configured: boolean
|
||||
/** Copy describing the configured state. */
|
||||
stateLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div className={css.field}>
|
||||
<div className={css.head}>
|
||||
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
||||
<span className={css.badges}>
|
||||
<span className={props.configured ? css.badge : css.badgeMuted}>{props.stateLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id={props.id}
|
||||
className={css.input}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={props.text}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => { props.onEdit(event.target.value) }}
|
||||
/>
|
||||
<p className={css.hint}>{props.hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
packages/client/ui-settings-plugins/src/client/index.ts
Normal file
158
packages/client/ui-settings-plugins/src/client/index.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Plugins settings surface, browser half — one section whose feature-owned
|
||||
* tabs include configurable Host plugin cards and read-only inventory.
|
||||
*
|
||||
* The section declares `settings.plugins.tab`; its own `configurable` tab then
|
||||
* declares `settings.plugin.item` and renders whatever cards were registered
|
||||
* into it. The three cards this package ships are the host-plane sections the
|
||||
* deployment already exposes; each binds its namespace through the client
|
||||
* settings scope, which keeps them unaware of one another and of other tabs.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the settings shell's SlotMap merge (the 'settings.section' entry)
|
||||
// and the ctx.settingsScope Context merge. Cross-plugin collaboration goes
|
||||
// through the service, never a value import (client bundle purity gate).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: the ctx.remote Context merge and the forwarded-event key face.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { AgentLoopCard } from './AgentLoopCard.tsx'
|
||||
import { BashCard } from './BashCard.tsx'
|
||||
import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
|
||||
import type { ConfigurablePluginsTabInjected } from './ConfigurablePluginsTab.tsx'
|
||||
import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
|
||||
import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
|
||||
import { WebSearchCard } from './WebSearchCard.tsx'
|
||||
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts'
|
||||
import { SHELL_NS, BashCardController } from './bash-card-controller.ts'
|
||||
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
export type { PluginsSettingsSectionInjected, PluginsSettingsSectionProps } from './PluginsSettingsSection.tsx'
|
||||
export type { ConfigurablePluginsTabInjected, ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx'
|
||||
export type { PluginCardProps } from './PluginCard.tsx'
|
||||
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
|
||||
export type { FieldProps } from './fields.tsx'
|
||||
export type {
|
||||
CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell,
|
||||
} from './card-form.ts'
|
||||
export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts'
|
||||
export type { BashCardFace, BashCardState } from './bash-card-controller.ts'
|
||||
export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts'
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'settings.plugins'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Mount the plugin configuration section and the cards this package ships.
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const { api } = ctx.get('connection') as ConnectionHandle
|
||||
const t = ctx.locale.bind(NS)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries')
|
||||
|
||||
const bash = new BashCardController(ctx.settingsScope.bind({ namespace: SHELL_NS }))
|
||||
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
|
||||
const webSearch = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), api)
|
||||
|
||||
// The credential a card reports is not part of any settings section, so its
|
||||
// scope publishes nothing when one is written. This is the only signal that
|
||||
// a key written on another surface reached the Host.
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('credentials/updated', (ref) => { webSearch.refreshCredential(ref) }),
|
||||
'ui-settings-plugins: credential invalidations',
|
||||
)
|
||||
|
||||
let tabsVersion = -1
|
||||
let tabsRevision = -1
|
||||
let tabs: readonly PluginsSettingsTabEntry[] = []
|
||||
const sectionInjected = (): PluginsSettingsSectionInjected => ({
|
||||
hooks: {
|
||||
tabs: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.plugins.tab')
|
||||
const revision = ctx.locale.getSnapshot().revision
|
||||
if (version !== tabsVersion || revision !== tabsRevision) {
|
||||
tabsVersion = version
|
||||
tabsRevision = revision
|
||||
tabs = ctx.slots.entries('settings.plugins.tab')
|
||||
.map(entry => ({
|
||||
/* v8 ignore next -- list-slot registration requires id */
|
||||
id: entry.options.id ?? '',
|
||||
order: entry.options.order ?? 0,
|
||||
label: resolveSlotLabel(entry.options.label) ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return tabs
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
const offLedger = ctx.slots.subscribe('settings.plugins.tab', listener)
|
||||
const offLocale = ctx.locale.subscribe(listener)
|
||||
return () => {
|
||||
offLedger()
|
||||
offLocale()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// This package owns the one Plugins navigation entry and the tab chrome;
|
||||
// feature plugins contribute pages without competing for Settings nav rows.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'plugins',
|
||||
order: 15,
|
||||
label: () => t('nav'),
|
||||
locale: NS,
|
||||
inject: sectionInjected,
|
||||
children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
|
||||
}, PluginsSettingsSection))
|
||||
|
||||
// The existing configuration page is one ordinary tab. It keeps ownership
|
||||
// of the card slot and the three shipped card contributions below.
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'configurable',
|
||||
order: 0,
|
||||
label: () => t('configurableTab'),
|
||||
locale: NS,
|
||||
inject: (): ConfigurablePluginsTabInjected => ({
|
||||
cardCount: ctx.slots.entries('settings.plugin.item').length,
|
||||
}),
|
||||
children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
|
||||
}, ConfigurablePluginsTab))
|
||||
|
||||
ctx.slots.inject('settings.plugin.item', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
id: 'bash',
|
||||
order: 0,
|
||||
locale: NS,
|
||||
inject: () => bash.inject(),
|
||||
}, BashCard)
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
id: 'agent-loop',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: () => agentLoop.inject(),
|
||||
}, AgentLoopCard)
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
id: 'web-search',
|
||||
order: 20,
|
||||
locale: NS,
|
||||
inject: () => webSearch.inject(),
|
||||
}, WebSearchCard)
|
||||
})
|
||||
}
|
||||
95
packages/client/ui-settings-plugins/src/client/locales.ts
Normal file
95
packages/client/ui-settings-plugins/src/client/locales.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/** Locale bundles for the plugin configuration section and its plugin cards. */
|
||||
|
||||
/** Locale keys these surfaces render. */
|
||||
export type PluginsSettingsLocaleKey =
|
||||
| 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty'
|
||||
| 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse'
|
||||
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber'
|
||||
| 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint'
|
||||
| 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint'
|
||||
| 'agentLoopTitle' | 'agentLoopDescription' | 'agentLoopMaxParallel' | 'agentLoopMaxParallelHint'
|
||||
| 'webSearchTitle' | 'webSearchDescription'
|
||||
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
|
||||
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<PluginsSettingsLocaleKey, string> = {
|
||||
nav: 'Plugins',
|
||||
title: 'Plugins',
|
||||
intro: 'Configure and inspect the plugins installed in this deployment.',
|
||||
tabs: 'Plugin views',
|
||||
configurableTab: 'Plugin configuration',
|
||||
empty: 'This deployment exposes no plugin settings.',
|
||||
overridden: 'Overridden',
|
||||
reset: 'Reset to default',
|
||||
readOnly: 'This deployment stores settings read-only.',
|
||||
expand: 'Show settings',
|
||||
collapse: 'Hide settings',
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
discard: 'Discard',
|
||||
unsaved: 'Unsaved',
|
||||
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
|
||||
invalidNumber: 'Enter a number, or leave blank to use the default.',
|
||||
bashTitle: 'Shell',
|
||||
bashDescription: 'Limits every command the agent runs.',
|
||||
bashTimeoutMs: 'Command timeout (ms)',
|
||||
bashTimeoutMsHint: 'How long one command may run before it is terminated.',
|
||||
bashMaxOutputBytes: 'Output cap per stream (bytes)',
|
||||
bashMaxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.',
|
||||
agentLoopTitle: 'Agent loop',
|
||||
agentLoopDescription: 'How the agent dispatches tool calls.',
|
||||
agentLoopMaxParallel: 'Parallel tool calls',
|
||||
agentLoopMaxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.',
|
||||
webSearchTitle: 'Web search',
|
||||
webSearchDescription: 'The DeepSeek search provider.',
|
||||
webSearchApiKey: 'API key',
|
||||
webSearchApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.',
|
||||
webSearchApiKeySet: 'A key is configured.',
|
||||
webSearchApiKeyUnset: 'No key is configured; search is unavailable until one is.',
|
||||
webSearchBaseUrl: 'Endpoint',
|
||||
webSearchBaseUrlHint: 'Leave blank to use the provider default.',
|
||||
webSearchMaxUses: 'Max searches per request',
|
||||
webSearchMaxUsesHint: 'How many times one request may search before it must answer.',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
export const zh: Record<PluginsSettingsLocaleKey, string> = {
|
||||
nav: '插件',
|
||||
title: '插件',
|
||||
intro: '配置和查看本部署已安装的插件。',
|
||||
tabs: '插件视图',
|
||||
configurableTab: '插件配置',
|
||||
empty: '本部署没有开放任何插件设置。',
|
||||
overridden: '已覆盖',
|
||||
reset: '恢复默认',
|
||||
readOnly: '本部署的设置为只读。',
|
||||
expand: '展开设置',
|
||||
collapse: '收起设置',
|
||||
save: '保存',
|
||||
saving: '保存中…',
|
||||
discard: '放弃修改',
|
||||
unsaved: '未保存',
|
||||
saveFailed: '本部署没有接受这些值,已保留供你修改。',
|
||||
invalidNumber: '请填数字;留空表示使用默认值。',
|
||||
bashTitle: '终端',
|
||||
bashDescription: '限制 agent 运行的每一条命令。',
|
||||
bashTimeoutMs: '命令超时(毫秒)',
|
||||
bashTimeoutMsHint: '单条命令允许运行多久,超时即终止。',
|
||||
bashMaxOutputBytes: '单流输出上限(字节)',
|
||||
bashMaxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。',
|
||||
agentLoopTitle: 'Agent 循环',
|
||||
agentLoopDescription: 'Agent 如何派发工具调用。',
|
||||
agentLoopMaxParallel: '并行工具调用数',
|
||||
agentLoopMaxParallelHint: '同一步内最多同时运行多少个可并行的调用。',
|
||||
webSearchTitle: '网页搜索',
|
||||
webSearchDescription: 'DeepSeek 搜索提供方。',
|
||||
webSearchApiKey: 'API Key',
|
||||
webSearchApiKeyHint: '不写入设置文件。留空表示保持当前密钥。',
|
||||
webSearchApiKeySet: '已配置密钥。',
|
||||
webSearchApiKeyUnset: '未配置密钥;配置之前搜索不可用。',
|
||||
webSearchBaseUrl: '接口地址',
|
||||
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
|
||||
webSearchMaxUses: '单次请求最多搜索次数',
|
||||
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The `settings.plugin.item` slot type — one plugin's card inside the plugin
|
||||
* configuration section. Options: `id` (card key), `order` (card position).
|
||||
* A card draws its own internals; the section only stacks them and reports
|
||||
* how many there are.
|
||||
*
|
||||
* TYPE HOME RATIONALE: unlike `settings.general.item`, whose registrants span
|
||||
* packages that cannot reference its declarer, every current registrant of
|
||||
* this slot ships in this package, and a plugin registering its own card
|
||||
* already depends on this package for the card chrome. The type therefore
|
||||
* lives with the section that declares it at runtime.
|
||||
*/
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** One plugin's card inside the plugin configuration section (see module JSDoc). */
|
||||
'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of a plugin card (the section supplies nothing). */
|
||||
export interface SettingsPluginItemOwnerProps {
|
||||
/** Marker field: card owner props are intentionally empty. */
|
||||
children?: never
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The web-search card's staged form over the `web-search-deepseek` settings
|
||||
* namespace.
|
||||
*
|
||||
* The key is the one control that does not live in the section: its literal
|
||||
* never rides a response, so the card learns only whether one is configured
|
||||
* and writes it through the credentials domain, addressed by the reference the
|
||||
* section names. It is still staged with the rest of the form, so one save
|
||||
* covers everything the card shows.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
CardForm, numberField, textField,
|
||||
type CardActions, type CardFieldState, type CardShell,
|
||||
} from './card-form.ts'
|
||||
|
||||
/**
|
||||
* Namespace of the DeepSeek search provider. Spelled here rather than
|
||||
* imported: a client package must not depend on a Host package.
|
||||
*/
|
||||
export const WEB_SEARCH_NS = 'web-search-deepseek'
|
||||
|
||||
/** Credential reference the provider resolves when the section names none. */
|
||||
const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY'
|
||||
|
||||
/** Form field the credential control stages under. */
|
||||
const API_KEY_FIELD = 'apiKey'
|
||||
|
||||
/** The search-provider fields this card edits. */
|
||||
export interface WebSearchSettings {
|
||||
/** Credential reference naming the environment key. */
|
||||
apiKeyEnv?: string
|
||||
/** Provider endpoint; blank inherits the provider default. */
|
||||
baseURL?: string
|
||||
/** Maximum searches served within one request. */
|
||||
maxUses?: number
|
||||
}
|
||||
|
||||
/** What the credentials domain last reported, and for which reference. */
|
||||
interface CredentialState {
|
||||
/** Reference this answer describes; a stale response for another one is dropped. */
|
||||
ref: string
|
||||
/** Whether any layer supplies a value for it. */
|
||||
configured: boolean
|
||||
/** Whether `credentials.set` can affect it; false disables the control. */
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
/** What the web-search card renders. */
|
||||
export interface WebSearchCardState extends CardShell {
|
||||
/** Provider endpoint. */
|
||||
baseURL: CardFieldState
|
||||
/** Searches allowed per request. */
|
||||
maxUses: CardFieldState
|
||||
/** The staged credential, which starts blank on every load. */
|
||||
apiKey: CardFieldState
|
||||
/** Whether the Host reports a credential configured for the referenced key. */
|
||||
apiKeyConfigured: boolean
|
||||
/** Whether the credentials domain accepts a write for it; false disables the control. */
|
||||
apiKeyWritable: boolean
|
||||
}
|
||||
|
||||
/** The registration-side face the web-search card's slot entry injects. */
|
||||
export interface WebSearchCardFace extends CardActions {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useWebSearchCard. */
|
||||
webSearchCard: SnapshotStore<WebSearchCardState>
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */
|
||||
export class WebSearchCardController {
|
||||
private readonly form: CardForm<WebSearchSettings>
|
||||
private readonly store: SnapshotStore<WebSearchCardState>
|
||||
private credential: CredentialState = { ref: '', configured: false, writable: true }
|
||||
|
||||
/**
|
||||
* @param scope - the bound settings scope for the `web-search-deepseek` namespace.
|
||||
* @param api - wire face used for the credential the section references.
|
||||
*/
|
||||
constructor(
|
||||
private readonly scope: SettingsScope<WebSearchSettings>,
|
||||
private readonly api: Pick<IApiClient, 'credentials'>,
|
||||
) {
|
||||
this.form = new CardForm(
|
||||
scope,
|
||||
[textField('baseURL'), numberField('maxUses')],
|
||||
[{ field: API_KEY_FIELD, write: text => this.writeKey(text) }],
|
||||
)
|
||||
this.store = this.form.bind(() => this.projection())
|
||||
scope.subscribe(() => { void this.readCredential() })
|
||||
void this.readCredential()
|
||||
}
|
||||
|
||||
private projection(): WebSearchCardState {
|
||||
return {
|
||||
...this.form.shell(),
|
||||
baseURL: this.form.field('baseURL'),
|
||||
maxUses: this.form.field('maxUses'),
|
||||
apiKey: this.form.field(API_KEY_FIELD),
|
||||
apiKeyConfigured: this.credential.configured,
|
||||
apiKeyWritable: this.credential.writable,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the credentials domain about the reference the section currently names.
|
||||
*
|
||||
* The answer is stored with the reference it describes: `apiKeyEnv` can
|
||||
* change between the request and its response, and two reads can settle out
|
||||
* of order, so a response is published only while it still answers for the
|
||||
* reference in force.
|
||||
*/
|
||||
private async readCredential(): Promise<void> {
|
||||
const ref = refOf(this.scope.getSnapshot())
|
||||
if (ref !== this.credential.ref) {
|
||||
// A new reference knows nothing yet; keeping the old answer would claim
|
||||
// the key is configured under a name nobody has checked.
|
||||
this.credential = { ref, configured: false, writable: true }
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
let response: Awaited<ReturnType<IApiClient['credentials']['describe']>>
|
||||
try {
|
||||
response = await this.api.credentials.describe({ refs: [ref] })
|
||||
} catch (_credentialReadFailure) {
|
||||
// The card stays usable without this: the key control simply reports the
|
||||
// last state it knew, and a write still reaches the Host.
|
||||
return
|
||||
}
|
||||
if (!response.result.ok || ref !== refOf(this.scope.getSnapshot())) return
|
||||
const view = response.result.value.credentials[ref]
|
||||
const next: CredentialState = {
|
||||
ref,
|
||||
configured: view?.configured ?? false,
|
||||
// An unknown reference is treated as writable: the control stays usable
|
||||
// and the Host is what refuses, rather than the card guessing a refusal.
|
||||
writable: view?.writable ?? true,
|
||||
}
|
||||
if (next.configured === this.credential.configured && next.writable === this.credential.writable) return
|
||||
this.credential = next
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read after the Host reports a change to the reference this card watches.
|
||||
*
|
||||
* A key can be written from somewhere else — the Models page addresses the
|
||||
* same reference — and the settings section does not change when it is, so
|
||||
* without this the badge keeps reporting a state the Host already replaced.
|
||||
* @param ref - the reference the Host reports as changed.
|
||||
*/
|
||||
refreshCredential(ref: string): void {
|
||||
if (ref !== this.credential.ref) return
|
||||
void this.readCredential()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the card's slot registration injects.
|
||||
* @returns the card's snapshot and its form actions.
|
||||
*/
|
||||
inject(): WebSearchCardFace {
|
||||
return { hooks: { webSearchCard: this.store }, ...this.form.actions() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the staged key, then re-read whether the Host now holds one.
|
||||
* @param value - the staged credential literal.
|
||||
* @returns whether the Host reports a configured credential afterwards.
|
||||
*/
|
||||
private async writeKey(value: string): Promise<boolean> {
|
||||
try {
|
||||
await this.api.credentials.set({ ref: refOf(this.scope.getSnapshot()), value })
|
||||
} catch (_credentialWriteFailure) {
|
||||
// Refusals surface through the re-read below: the Host is the only
|
||||
// authority on whether the key now exists.
|
||||
}
|
||||
await this.readCredential()
|
||||
return this.credential.configured
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential reference the section names, or the provider's default.
|
||||
* @param snapshot - the current scope snapshot.
|
||||
* @returns the reference to address.
|
||||
*/
|
||||
function refOf(snapshot: SettingsScopeSnapshot<WebSearchSettings>): string {
|
||||
const declared = snapshot.value?.apiKeyEnv
|
||||
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF
|
||||
}
|
||||
4
packages/client/ui-settings-plugins/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-settings-plugins/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
11
packages/client/ui-settings-plugins/src/index.ts
Normal file
11
packages/client/ui-settings-plugins/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Plugins settings surface, node half. The empty apply exists so the plugin
|
||||
* appears in the host cordis.yml / Loader; the browser half owns the section
|
||||
* and its configurable tab through exports["./client"], discovered from the
|
||||
* package.json dsh.client declaration. Every section this page edits is owned
|
||||
* by the Host plugin that registered it, so this package registers no
|
||||
* namespace of its own.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
31
packages/client/ui-settings-plugins/src/invariant.ts
Normal file
31
packages/client/ui-settings-plugins/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-plugins`.
|
||||
* @module @deepseek-ai/dsh-client-ui-settings-plugins/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-plugins'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-settings-plugins-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this is a browser-side settings surface whose node half owns no event
|
||||
* stream or mutable runtime data; the layering, write refusals, and exposure boundary are Host
|
||||
* contracts covered by the owning plugins and the api-proxy.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
159
packages/client/ui-settings-plugins/tests/apply.client.spec.ts
Normal file
159
packages/client/ui-settings-plugins/tests/apply.client.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/** What the browser half registers, and that it all leaves with the fiber. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
import type {
|
||||
ConfigurablePluginsTabInjected, PluginsSettingsSectionInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
|
||||
// The section binds its scopes through the Settings surface's service, and
|
||||
// forwarded Host events reach it through the same `$dispatch` handoff the
|
||||
// connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
ctx.provide('connection', {
|
||||
isLoopback: true,
|
||||
api: {
|
||||
settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
|
||||
credentials: { describe: describeCredentials },
|
||||
},
|
||||
} as never)
|
||||
await ctx.plugin(SettingsScopeBinder).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials }
|
||||
}
|
||||
|
||||
function declareRoot(slots: SlotRegistry): () => void {
|
||||
return slots.register({
|
||||
name: 'root',
|
||||
children: { 'settings.section': { kind: 'list', scope: 'root' } },
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
describe('ui-settings-plugins apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers one Plugins section and declares the tab and card slots', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
expect(section.options).toMatchObject({ id: 'plugins', order: 15 })
|
||||
// The nav label is a locale-following thunk; owners resolve it at read time.
|
||||
expect(resolveSlotLabel(section.options.label)).toBe('插件')
|
||||
expect(slots.spec('settings.plugins.tab')).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
const tab = slots.entries('settings.plugins.tab')[0]!
|
||||
expect(tab.options).toMatchObject({ id: 'configurable', order: 0 })
|
||||
expect(resolveSlotLabel(tab.options.label)).toBe('插件配置')
|
||||
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
})
|
||||
|
||||
it('registers one card per host-plane section it ships, in a stable order', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
expect(slots.entries('settings.plugin.item').map(entry => entry.options.id))
|
||||
.toEqual(['bash', 'agent-loop', 'web-search'])
|
||||
})
|
||||
|
||||
it('injects a live tab projection, a card count, and one business face per card', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
const sectionFace = (section.inject as unknown as () => PluginsSettingsSectionInjected)()
|
||||
const initialTabs = sectionFace.hooks.tabs.getSnapshot()
|
||||
expect(initialTabs).toEqual([
|
||||
{ id: 'configurable', order: 0, label: '插件配置' },
|
||||
])
|
||||
expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs)
|
||||
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = sectionFace.hooks.tabs.subscribe(listener)
|
||||
slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null)
|
||||
expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([
|
||||
{ id: 'configurable', order: 0, label: '插件配置' },
|
||||
{ id: 'plain', order: 0, label: '' },
|
||||
])
|
||||
unsubscribe()
|
||||
|
||||
const tab = slots.entries('settings.plugins.tab')[0]!
|
||||
expect((tab.inject as unknown as () => ConfigurablePluginsTabInjected)()).toEqual({ cardCount: 3 })
|
||||
for (const entry of slots.entries('settings.plugin.item')) {
|
||||
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
|
||||
// Each card injects exactly one snapshot store plus its own actions.
|
||||
expect(Object.keys(face.hooks)).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('re-reads the credential when the Host reports the watched reference changed', async () => {
|
||||
const { ctx, slots, describeCredentials } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
|
||||
describeCredentials.mockClear()
|
||||
|
||||
// A key written on another surface changes no settings section, so this
|
||||
// event is the only thing that reaches the card.
|
||||
ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
|
||||
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('ignores a credential change for a reference no card watches', async () => {
|
||||
const { ctx, slots, describeCredentials } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
|
||||
describeCredentials.mockClear()
|
||||
|
||||
ctx.remote.$dispatch('credentials/updated', ['SOME_OTHER_KEY'])
|
||||
await Promise.resolve()
|
||||
|
||||
expect(describeCredentials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers into a declaration that arrives after apply', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
declareRoot(slots)
|
||||
|
||||
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
|
||||
})
|
||||
|
||||
it('collapses every contribution on teardown', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('settings.plugin.item')).toHaveLength(3)
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
expect(slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(slots.spec('settings.plugins.tab')).toBeUndefined()
|
||||
expect(slots.spec('settings.plugin.item')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
156
packages/client/ui-settings-plugins/tests/fields.client.spec.tsx
Normal file
156
packages/client/ui-settings-plugins/tests/fields.client.spec.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Field-control behavior: what a control renders for a staged draft, how an
|
||||
* overridden field offers its reset, and that a control never writes on its own.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SecretField, ValueField } from '../src/client/fields.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const frame = {
|
||||
id: 'field',
|
||||
label: 'Command timeout',
|
||||
hint: 'How long one command may run.',
|
||||
overriddenLabel: 'Overridden',
|
||||
resetLabel: 'Reset to default',
|
||||
invalidLabel: 'Enter a number.',
|
||||
disabled: false,
|
||||
overridden: false,
|
||||
invalid: false,
|
||||
}
|
||||
|
||||
describe('ValueField', () => {
|
||||
it('stages every keystroke without writing', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(<ValueField {...frame} text="60000" onEdit={onEdit} onReset={vi.fn()} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Command timeout'), { target: { value: '9000' } })
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith('9000')
|
||||
})
|
||||
|
||||
it('renders the staged text it is given rather than a draft of its own', () => {
|
||||
const { rerender } = render(<ValueField {...frame} text="60000" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000')
|
||||
|
||||
rerender(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
|
||||
})
|
||||
|
||||
it('offers the reset only while an override would stand', () => {
|
||||
const onReset = vi.fn()
|
||||
const { rerender } = render(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={onReset} />)
|
||||
expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull()
|
||||
|
||||
rerender(<ValueField {...frame} overridden text="9000" onEdit={vi.fn()} onReset={onReset} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }))
|
||||
|
||||
expect(screen.getByText('Overridden')).toBeTruthy()
|
||||
expect(onReset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('replaces the hint with the reason an invalid draft cannot be saved', () => {
|
||||
render(<ValueField {...frame} invalid text="soon" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Enter a number.')).toBeTruthy()
|
||||
expect(screen.queryByText('How long one command may run.')).toBeNull()
|
||||
expect(screen.getByLabelText('Command timeout').getAttribute('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('hints a numeric keypad and renders a placeholder when asked', () => {
|
||||
render(
|
||||
<ValueField
|
||||
{...frame}
|
||||
numeric
|
||||
placeholder="https://api.deepseek.com"
|
||||
text=""
|
||||
onEdit={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
expect(input.getAttribute('inputmode')).toBe('numeric')
|
||||
expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com')
|
||||
})
|
||||
|
||||
it('disables the control and its reset while the document is read-only', () => {
|
||||
render(<ValueField {...frame} disabled overridden text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretField', () => {
|
||||
const secret = {
|
||||
id: 'key',
|
||||
label: 'API key',
|
||||
hint: 'Stored outside the settings file.',
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
it('stages the draft and never renders it', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(
|
||||
<SecretField
|
||||
{...secret}
|
||||
text=""
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'ds-secret' } })
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith('ds-secret')
|
||||
expect(input).toHaveProperty('type', 'password')
|
||||
})
|
||||
|
||||
it('reports the configured state the Host holds', () => {
|
||||
const { rerender } = render(
|
||||
<SecretField
|
||||
{...secret}
|
||||
text=""
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('No key is configured.')).toBeTruthy()
|
||||
|
||||
rerender(
|
||||
<SecretField
|
||||
{...secret}
|
||||
text="ds-secret"
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('A key is configured.')).toBeTruthy()
|
||||
expect(screen.getByLabelText('API key')).toHaveProperty('value', 'ds-secret')
|
||||
})
|
||||
|
||||
it('disables the control when it is told to', () => {
|
||||
render(
|
||||
<SecretField
|
||||
{...secret}
|
||||
disabled
|
||||
text=""
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('API key')).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
/** The package's node half: an empty host body and an explained empty invariant companion. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import * as PluginConfigInvariant from '@deepseek-ai/dsh-client-ui-settings-plugins/invariant'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('reserves package ownership with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(PluginConfigInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('has an empty node half', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-plugins')
|
||||
|
||||
// The host body exists only so the plugin appears in the host cordis.yml;
|
||||
// every surface this package ships lives in the browser half.
|
||||
apply()
|
||||
|
||||
expect(typeof apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,401 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* What the section and its cards show: the empty line when no plugin
|
||||
* contributed one, a card that renders nothing while its namespace is
|
||||
* unavailable, and the save footer that decides when staged edits are written.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
|
||||
import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
|
||||
import { BashCard } from '../src/client/BashCard.tsx'
|
||||
import type { BashCardProps } from '../src/client/BashCard.tsx'
|
||||
import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx'
|
||||
import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
|
||||
import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx'
|
||||
import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx'
|
||||
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
|
||||
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
|
||||
import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts'
|
||||
import type { BashCardState } from '../src/client/bash-card-controller.ts'
|
||||
import type { CardFieldState, CardShell } from '../src/client/card-form.ts'
|
||||
import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = (key: keyof typeof en) => en[key]
|
||||
|
||||
/** A settled form: nothing staged, everything served. */
|
||||
const settled: CardShell = {
|
||||
available: true,
|
||||
writable: true,
|
||||
dirty: false,
|
||||
invalid: false,
|
||||
saving: false,
|
||||
failed: false,
|
||||
}
|
||||
|
||||
/** One control's state, defaulting to an inherited value. */
|
||||
function field(text: string, rest: Partial<CardFieldState> = {}): CardFieldState {
|
||||
return { text, overridden: false, invalid: false, ...rest }
|
||||
}
|
||||
|
||||
function cardActions() {
|
||||
return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
|
||||
}
|
||||
|
||||
function renderSection(rows: readonly PluginsSettingsTabEntry[]) {
|
||||
const props = {
|
||||
t,
|
||||
useTabs: (selector: (value: readonly PluginsSettingsTabEntry[]) => unknown) => selector(rows),
|
||||
renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => (
|
||||
<span>{options.only}</span>
|
||||
),
|
||||
} as unknown as PluginsSettingsSectionProps
|
||||
render(<PluginsSettingsSection {...props} />)
|
||||
}
|
||||
|
||||
function renderConfigurable(cardCount: number, cards = 'cards') {
|
||||
const props = {
|
||||
t,
|
||||
cardCount,
|
||||
renderSlot: () => <li>{cards}</li>,
|
||||
} as unknown as ConfigurablePluginsTabProps
|
||||
render(<ConfigurablePluginsTab {...props} />)
|
||||
}
|
||||
|
||||
function renderBash(state: Partial<BashCardState> = {}) {
|
||||
const store = createSnapshotStore<BashCardState>({
|
||||
...settled,
|
||||
timeoutMs: field('60000'),
|
||||
maxOutputBytes: field('64000'),
|
||||
...state,
|
||||
})
|
||||
const actions = cardActions()
|
||||
const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
|
||||
render(<BashCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
describe('PluginsSettingsSection', () => {
|
||||
it('says so when no plugin contributed a tab', () => {
|
||||
renderSection([])
|
||||
|
||||
expect(screen.getByText(en.empty)).toBeTruthy()
|
||||
expect(screen.queryByRole('tab')).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults to the first ordered tab and mounts another only after selection', () => {
|
||||
renderSection([
|
||||
{ id: 'configurable', order: 0, label: en.configurableTab },
|
||||
{ id: 'all', order: 10, label: 'Plugin list' },
|
||||
])
|
||||
|
||||
const configurable = screen.getByRole('tab', { name: en.configurableTab })
|
||||
const all = screen.getByRole('tab', { name: 'Plugin list' })
|
||||
expect(configurable.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('configurable')).toBeTruthy()
|
||||
expect(screen.queryByText('all')).toBeNull()
|
||||
|
||||
fireEvent.click(all)
|
||||
expect(all.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('all')).toBeTruthy()
|
||||
expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
|
||||
|
||||
fireEvent.click(configurable)
|
||||
expect(configurable.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('all').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
|
||||
})
|
||||
|
||||
it('leads with its own heading and intro', () => {
|
||||
renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }])
|
||||
|
||||
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
|
||||
expect(screen.getByText(en.intro)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('moves focus and selection with standard horizontal tab keys', () => {
|
||||
renderSection([
|
||||
{ id: 'configurable', order: 0, label: en.configurableTab },
|
||||
{ id: 'all', order: 10, label: 'Plugin list' },
|
||||
{ id: 'diagnostics', order: 20, label: 'Diagnostics' },
|
||||
])
|
||||
|
||||
const configurable = screen.getByRole('tab', { name: en.configurableTab })
|
||||
const all = screen.getByRole('tab', { name: 'Plugin list' })
|
||||
const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' })
|
||||
expect(configurable.getAttribute('tabindex')).toBe('0')
|
||||
expect(all.getAttribute('tabindex')).toBe('-1')
|
||||
|
||||
configurable.focus()
|
||||
fireEvent.keyDown(configurable, { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(all)
|
||||
expect(all.getAttribute('aria-selected')).toBe('true')
|
||||
|
||||
fireEvent.keyDown(all, { key: 'End' })
|
||||
expect(document.activeElement).toBe(diagnostics)
|
||||
fireEvent.keyDown(diagnostics, { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(configurable)
|
||||
fireEvent.keyDown(configurable, { key: 'ArrowLeft' })
|
||||
expect(document.activeElement).toBe(diagnostics)
|
||||
fireEvent.keyDown(diagnostics, { key: 'Home' })
|
||||
expect(document.activeElement).toBe(configurable)
|
||||
|
||||
fireEvent.keyDown(configurable, { key: 'Escape' })
|
||||
expect(document.activeElement).toBe(configurable)
|
||||
expect(configurable.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigurablePluginsTab', () => {
|
||||
it('says so when no plugin contributed a card', () => {
|
||||
renderConfigurable(0)
|
||||
|
||||
expect(screen.getByText(en.empty)).toBeTruthy()
|
||||
expect(screen.queryByText('cards')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the card list once a plugin contributed one', () => {
|
||||
renderConfigurable(1)
|
||||
|
||||
expect(screen.getByText('cards')).toBeTruthy()
|
||||
expect(screen.queryByText(en.empty)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashCard', () => {
|
||||
it('renders nothing while its namespace is unavailable', () => {
|
||||
const { container } = render(<div />)
|
||||
renderBash({ available: false })
|
||||
|
||||
expect(container.textContent).toBe('')
|
||||
expect(screen.queryByText(en.bashTitle)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the plugin and reveals its fields only once expanded', () => {
|
||||
renderBash()
|
||||
expect(screen.getByText(en.bashTitle)).toBeTruthy()
|
||||
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('stages an edit instead of writing it', () => {
|
||||
const actions = renderBash()
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } })
|
||||
|
||||
expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000')
|
||||
expect(actions.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers the reset for an overridden field only', () => {
|
||||
const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
// One badge and one reset: the output cap is still inherited.
|
||||
expect(screen.getAllByText(en.overridden)).toHaveLength(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
||||
|
||||
expect(actions.resetField).toHaveBeenCalledWith('timeoutMs')
|
||||
})
|
||||
|
||||
it('addresses each of its two fields separately', () => {
|
||||
const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
||||
|
||||
expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024')
|
||||
expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes')
|
||||
})
|
||||
|
||||
it('keeps save and discard inert until something is staged', () => {
|
||||
renderBash()
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
|
||||
expect(screen.queryByText(en.unsaved)).toBeNull()
|
||||
})
|
||||
|
||||
it('writes the staged edits when saved, and drops them when discarded', () => {
|
||||
const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.discard }))
|
||||
|
||||
expect(actions.save).toHaveBeenCalledOnce()
|
||||
expect(actions.discard).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('marks a card holding unsaved edits, collapsed or not', () => {
|
||||
renderBash({ dirty: true })
|
||||
|
||||
expect(screen.getByText(en.unsaved)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('blocks the save while a draft is invalid, and says why', () => {
|
||||
renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', false)
|
||||
expect(screen.getByText(en.invalidNumber)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports a save in flight and refuses another', () => {
|
||||
renderBash({ dirty: true, saving: true })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('reports a save the deployment did not accept', () => {
|
||||
renderBash({ dirty: true, failed: true })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByText(en.saveFailed)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('says the document is read-only and disables its controls', () => {
|
||||
renderBash({ writable: false })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly)
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('collapses again on a second click', () => {
|
||||
renderBash()
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCard', () => {
|
||||
it('stages and saves the only field it owns', () => {
|
||||
const store = createSnapshotStore<AgentLoopCardState>({
|
||||
...settled,
|
||||
dirty: true,
|
||||
maxParallelToolCalls: field('10'),
|
||||
})
|
||||
const actions = cardActions()
|
||||
const props = {
|
||||
...actions,
|
||||
t,
|
||||
useAgentLoopCard: bindSnapshotSelector(store),
|
||||
} as unknown as AgentLoopCardProps
|
||||
render(<AgentLoopCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(en.agentLoopTitle))
|
||||
fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
||||
|
||||
expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2')
|
||||
expect(actions.save).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stages a reset for the field it owns', () => {
|
||||
const store = createSnapshotStore<AgentLoopCardState>({
|
||||
...settled,
|
||||
maxParallelToolCalls: field('2', { overridden: true }),
|
||||
})
|
||||
const actions = cardActions()
|
||||
const props = {
|
||||
...actions,
|
||||
t,
|
||||
useAgentLoopCard: bindSnapshotSelector(store),
|
||||
} as unknown as AgentLoopCardProps
|
||||
render(<AgentLoopCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(en.agentLoopTitle))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
||||
|
||||
expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSearchCard', () => {
|
||||
function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
|
||||
const store = createSnapshotStore<WebSearchCardState>({
|
||||
...settled,
|
||||
baseURL: field(''),
|
||||
maxUses: field('5'),
|
||||
apiKey: field(''),
|
||||
apiKeyConfigured: false,
|
||||
apiKeyWritable: true,
|
||||
...state,
|
||||
})
|
||||
const actions = cardActions()
|
||||
const props = { ...actions, t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps
|
||||
render(<WebSearchCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
it('reports whether a key is configured without ever showing one', () => {
|
||||
renderWebSearch({ apiKeyConfigured: true })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password')
|
||||
})
|
||||
|
||||
it('keeps the key control usable while the settings document is read-only', () => {
|
||||
const actions = renderWebSearch({ writable: false })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
const key = screen.getByLabelText(en.webSearchApiKey)
|
||||
expect(key).toHaveProperty('disabled', false)
|
||||
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
|
||||
|
||||
fireEvent.change(key, { target: { value: 'ds-secret' } })
|
||||
|
||||
expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
|
||||
})
|
||||
|
||||
it('disables the key control when the reference itself is not writable', () => {
|
||||
// A key coming from the process environment: the settings document is
|
||||
// writable, the credential is not.
|
||||
renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true)
|
||||
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false)
|
||||
})
|
||||
|
||||
it('stages the endpoint, the search budget, and their resets', () => {
|
||||
const actions = renderWebSearch({
|
||||
baseURL: field('https://search.test/v1', { overridden: true }),
|
||||
maxUses: field('3', { overridden: true }),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } })
|
||||
fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } })
|
||||
const resets = screen.getAllByRole('button', { name: en.reset })
|
||||
expect(resets).toHaveLength(2)
|
||||
for (const reset of resets) fireEvent.click(reset)
|
||||
|
||||
expect(actions.edit.mock.calls).toEqual([
|
||||
['baseURL', 'https://other.test'],
|
||||
['maxUses', '4'],
|
||||
])
|
||||
expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
|
||||
})
|
||||
})
|
||||
540
packages/client/ui-settings-plugins/tests/stores.client.spec.ts
Normal file
540
packages/client/ui-settings-plugins/tests/stores.client.spec.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* The staged card form: what a draft shows before it is written, which wire
|
||||
* call a save reaches, and what happens to drafts the Host did not accept.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { CardForm, numberField, textField } from '../src/client/card-form.ts'
|
||||
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
|
||||
import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
|
||||
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
|
||||
|
||||
/** Make the stub behave like a Host that accepts every write. */
|
||||
function acceptWrites<T>(host: StubSettingsScope<T>): void {
|
||||
const section = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().value as object })
|
||||
const layer = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().user as object })
|
||||
host.set.mockImplementation((field: string, value: unknown) => {
|
||||
host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } })
|
||||
})
|
||||
host.unset.mockImplementation((field: string) => {
|
||||
const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field))
|
||||
const base = host.scope.getSnapshot().base as Record<string, unknown> | undefined
|
||||
host.publish({ value: { ...section(), [field]: base?.[field] } as T, user })
|
||||
})
|
||||
}
|
||||
|
||||
function credentialsApi(configured: boolean) {
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured, writable: true } } } },
|
||||
}))
|
||||
const set = vi.fn(() => Promise.resolve({ rpcId: 'c-2' as never, result: { ok: true as const, value: {} } }))
|
||||
return { api: { credentials: { describe, set } } as never, describe, set }
|
||||
}
|
||||
|
||||
describe('CardForm', () => {
|
||||
function form() {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
|
||||
base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
|
||||
user: {},
|
||||
})
|
||||
return { host, subject }
|
||||
}
|
||||
|
||||
it('shows the effective value and stays clean until something is staged', () => {
|
||||
const { subject } = form()
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
|
||||
expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false })
|
||||
})
|
||||
|
||||
it('marks a field the user layer carries as overridden', () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } })
|
||||
|
||||
// An override equal to the composition default is still an override.
|
||||
expect(subject.field('timeoutMs').overridden).toBe(true)
|
||||
})
|
||||
|
||||
it('writes nothing until the form is saved', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false })
|
||||
expect(subject.shell().dirty).toBe(true)
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
|
||||
await subject.save()
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]])
|
||||
expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false })
|
||||
})
|
||||
|
||||
it('drops a draft that settles back on the value already shown', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
subject.actions().edit('timeoutMs', '60000')
|
||||
|
||||
expect(subject.shell().dirty).toBe(false)
|
||||
await subject.save()
|
||||
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to save while a draft is not a value the field accepts', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', 'soon')
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true })
|
||||
expect(subject.shell()).toMatchObject({ dirty: true, invalid: true })
|
||||
|
||||
await subject.save()
|
||||
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
expect(subject.field('timeoutMs').text).toBe('soon')
|
||||
})
|
||||
|
||||
it('stages a reset that clears the field only once saved', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } })
|
||||
|
||||
subject.actions().resetField('timeoutMs')
|
||||
|
||||
// The badge previews the save: the field will no longer be overridden.
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
|
||||
expect(host.unset).not.toHaveBeenCalled()
|
||||
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset.mock.calls).toEqual([['timeoutMs']])
|
||||
expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
|
||||
})
|
||||
|
||||
it('treats resetting an inherited field as no change at all', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().resetField('timeoutMs')
|
||||
|
||||
expect(subject.shell().dirty).toBe(false)
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a number field by emptying it', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
host.publish({ user: { timeoutMs: 9_000 } })
|
||||
|
||||
subject.actions().edit('timeoutMs', '')
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false })
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset.mock.calls).toEqual([['timeoutMs']])
|
||||
})
|
||||
|
||||
it('clears a text field by emptying it', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
host.publish({ user: { baseURL: 'https://search.test/v1' } })
|
||||
|
||||
subject.actions().edit('baseURL', ' ')
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset.mock.calls).toEqual([['baseURL']])
|
||||
})
|
||||
|
||||
it('writes the trimmed text of a text field', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
|
||||
subject.actions().edit('baseURL', ' https://other.test ')
|
||||
await subject.save()
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']])
|
||||
})
|
||||
|
||||
it('keeps the drafts a save did not land, and reports the failure', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
await subject.save()
|
||||
|
||||
// The stub Host accepted the call without storing it, exactly as a
|
||||
// validator that refuses the value does.
|
||||
expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000)
|
||||
expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false })
|
||||
expect(subject.field('timeoutMs').text).toBe('9000')
|
||||
})
|
||||
|
||||
it('reports a reset the Host did not apply as a failure', async () => {
|
||||
const { host, subject } = form()
|
||||
host.publish({ user: { timeoutMs: 9_000 } })
|
||||
|
||||
subject.actions().resetField('timeoutMs')
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset).toHaveBeenCalledWith('timeoutMs')
|
||||
expect(subject.shell().failed).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the failure as soon as the user edits again', async () => {
|
||||
const { subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
await subject.save()
|
||||
expect(subject.shell().failed).toBe(true)
|
||||
|
||||
subject.actions().edit('timeoutMs', '9001')
|
||||
|
||||
expect(subject.shell().failed).toBe(false)
|
||||
})
|
||||
|
||||
it('discards every staged edit', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
subject.actions().discard()
|
||||
|
||||
expect(subject.field('timeoutMs').text).toBe('60000')
|
||||
expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
|
||||
|
||||
// A discard with nothing staged publishes nothing.
|
||||
const before = subject.shell()
|
||||
subject.actions().discard()
|
||||
expect(subject.shell()).toEqual(before)
|
||||
|
||||
await subject.save()
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a second save while one is in flight', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
const first = subject.save()
|
||||
expect(subject.shell().saving).toBe(true)
|
||||
const second = subject.save()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(host.set).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('publishes a projection whenever the scope or a draft changes', () => {
|
||||
const { host, subject } = form()
|
||||
const store = subject.bind(() => subject.field('timeoutMs').text)
|
||||
expect(store.getSnapshot()).toBe('60000')
|
||||
|
||||
host.publish({ value: { timeoutMs: 1_000 } })
|
||||
expect(store.getSnapshot()).toBe('1000')
|
||||
|
||||
subject.actions().edit('timeoutMs', '2000')
|
||||
expect(store.getSnapshot()).toBe('2000')
|
||||
})
|
||||
|
||||
it('refuses to address a field the card never declared', () => {
|
||||
const { subject } = form()
|
||||
|
||||
expect(() => subject.field('nope')).toThrow('plugin card has no field nope')
|
||||
})
|
||||
|
||||
it('renders an absent section value as an empty draft', () => {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined })
|
||||
|
||||
expect(subject.field('timeoutMs').text).toBe('')
|
||||
expect(subject.field('baseURL').text).toBe('')
|
||||
expect(subject.shell().available).toBe(true)
|
||||
})
|
||||
|
||||
it('stays unavailable while the namespace is not served', () => {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
const subject = new CardForm(host.scope, [numberField('timeoutMs')])
|
||||
|
||||
host.publish({ status: 'unavailable' })
|
||||
|
||||
expect(subject.shell()).toMatchObject({ available: false, writable: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashCardController', () => {
|
||||
it('projects both fields and saves them in one write pass', async () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
acceptWrites(host)
|
||||
const controller = new BashCardController(host.scope)
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
|
||||
base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
|
||||
user: { timeoutMs: 5_000 },
|
||||
})
|
||||
const face = controller.inject()
|
||||
|
||||
expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
writable: true,
|
||||
dirty: false,
|
||||
timeoutMs: { text: '5000', overridden: true },
|
||||
maxOutputBytes: { text: '64000', overridden: false },
|
||||
})
|
||||
|
||||
face.edit('timeoutMs', '9000')
|
||||
face.edit('maxOutputBytes', '1024')
|
||||
expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true)
|
||||
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
|
||||
expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false)
|
||||
})
|
||||
|
||||
it('stages a reset and applies it on save', async () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
acceptWrites(host)
|
||||
const controller = new BashCardController(host.scope)
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { timeoutMs: 5_000 },
|
||||
base: { timeoutMs: 60_000 },
|
||||
user: { timeoutMs: 5_000 },
|
||||
})
|
||||
const face = controller.inject()
|
||||
|
||||
face.resetField('timeoutMs')
|
||||
expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000')
|
||||
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') })
|
||||
|
||||
expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
|
||||
dirty: false,
|
||||
timeoutMs: { text: '60000', overridden: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('discards staged edits without writing', () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('timeoutMs', '9000')
|
||||
face.discard()
|
||||
|
||||
expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000')
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCardController', () => {
|
||||
it('saves the only field it owns', async () => {
|
||||
const host = stubSettingsScope<AgentLoopSettings>()
|
||||
acceptWrites(host)
|
||||
const controller = new AgentLoopCardController(host.scope)
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { maxParallelToolCalls: 10 },
|
||||
base: { maxParallelToolCalls: 10 },
|
||||
user: {},
|
||||
})
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('maxParallelToolCalls', '4')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) })
|
||||
|
||||
expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({
|
||||
dirty: false,
|
||||
maxParallelToolCalls: { text: '4', overridden: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a read-only document so the card can disable its controls', () => {
|
||||
const host = stubSettingsScope<AgentLoopSettings>()
|
||||
const controller = new AgentLoopCardController(host.scope)
|
||||
|
||||
host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
|
||||
|
||||
expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSearchCardController', () => {
|
||||
it('reads the credential state for the reference the section names', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
const state = () => controller.inject().hooks.webSearchCard.getSnapshot()
|
||||
await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
|
||||
await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) })
|
||||
|
||||
expect(state()).toMatchObject({
|
||||
baseURL: { text: 'https://search.test/v1', overridden: false },
|
||||
apiKey: { text: '', overridden: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('writes the staged key through the credentials domain, never the settings section', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('apiKey', ' ds-secret ')
|
||||
expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true)
|
||||
expect(credentials.set).not.toHaveBeenCalled()
|
||||
|
||||
credentials.describe.mockImplementation(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
|
||||
}))
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
|
||||
|
||||
expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' })
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the stored key when the draft is left blank', () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('apiKey', ' ')
|
||||
|
||||
expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false)
|
||||
face.save()
|
||||
|
||||
expect(credentials.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-reads when the Host reports the watched reference changed', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
|
||||
credentials.describe.mockClear()
|
||||
|
||||
// Another reference is not this card's business.
|
||||
controller.refreshCredential('OTHER_KEY')
|
||||
expect(credentials.describe).not.toHaveBeenCalled()
|
||||
|
||||
// A key written on another surface reaches this card only through this signal.
|
||||
credentials.describe.mockImplementation(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
|
||||
}))
|
||||
controller.refreshCredential('DEEPSEEK_API_KEY')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('addresses the reference the section declares rather than the default', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('apiKey', 'ds-secret')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
|
||||
|
||||
expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' })
|
||||
})
|
||||
|
||||
it('reports a key the Host did not store as a failed save', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('apiKey', 'ds-secret')
|
||||
face.save()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the card usable when the credential read fails', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const describe = vi.fn(() => Promise.reject(new Error('offline')))
|
||||
const set = vi.fn(() => Promise.reject(new Error('offline')))
|
||||
const controller = new WebSearchCardController(host.scope, { credentials: { describe, set } } as never)
|
||||
const face = controller.inject()
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
|
||||
face.edit('apiKey', 'ds-secret')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(set).toHaveBeenCalled() })
|
||||
|
||||
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
apiKeyConfigured: false,
|
||||
baseURL: { text: 'https://search.test/v1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a credential read the Host refused', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: false as const, error: { code: 'credentials-unavailable', message: 'no provider' } },
|
||||
}))
|
||||
const controller = new WebSearchCardController(host.scope, { credentials: { describe, set: vi.fn() } } as never)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
|
||||
|
||||
expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false)
|
||||
})
|
||||
|
||||
it('saves the endpoint and the search budget together', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
acceptWrites(host)
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('baseURL', 'https://other.test')
|
||||
face.edit('maxUses', '3')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]])
|
||||
expect(credentials.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
42
packages/client/ui-settings-plugins/tsconfig.json
Normal file
42
packages/client/ui-settings-plugins/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../test-support/client-runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-settings-plugins/tsdown.config.ts
Normal file
3
packages/client/ui-settings-plugins/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugins', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user