feat(web): combine plugin settings into tabs
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-plugin-config/README.md
|
||||
README.md: 7e530d70f6573d619378e43b0245345b45d6db18
|
||||
README.zh.md: fd4f980fcf71c00c2357017fb40c76a9ca7a72cc
|
||||
README.md: 1bf8dd60b966ee0c0e28ae931556182c5c2b2e87
|
||||
README.zh.md: bd87e06202b2b083a6e9e089eb91681deea9d678
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
|
||||
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 section reflects what this deployment actually runs.
|
||||
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.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`.
|
||||
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
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**插件**设置分区:每个配置由用户拥有的 Host 插件占一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
|
||||
**插件**设置分区及其**插件配置**标签页。该分区拥有标题与紧凑的标签栏;功能插件通过 `settings.plugins.tab` 贡献页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
|
||||
|
||||
## 这里会出现什么
|
||||
|
||||
只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。
|
||||
只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此“插件配置”标签页反映的是该部署实际运行的东西。
|
||||
|
||||
第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
|
||||
|
||||
## 扩展点
|
||||
|
||||
本分区声明了根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`。
|
||||
本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` 列表 slot。带浏览器半侧的插件把自己的卡片注册进这个嵌套 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。两层排序都遵循贡献的 `order`。
|
||||
|
||||
## 写入
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plugin-config",
|
||||
"description": "Plugin configuration section: host-plane plugin settings as expandable cards",
|
||||
"description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
|
||||
@@ -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 './PluginConfigSection.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.pluginConfig'>
|
||||
& 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>
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/* Plugin configuration section: heading, intro, and the card list. */
|
||||
/* Plugins section: compact tabs plus the configurable plugin card list. */
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
max-width: 760px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,53 @@
|
||||
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: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -1,49 +1,105 @@
|
||||
/**
|
||||
* Plugin configuration section: the shell around the per-plugin cards. It
|
||||
* enumerates nothing itself — cards arrive through the `settings.plugin.item`
|
||||
* slot it declares, so a plugin that ships a browser half owns its own card
|
||||
* and this section never learns what a namespace means.
|
||||
*/
|
||||
/** Plugins settings section: localized tabs around feature-owned pages. */
|
||||
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from './slot-contract.ts'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import type {
|
||||
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PluginConfigKey } from './locales.ts'
|
||||
import css from './PluginConfigSection.module.css'
|
||||
|
||||
/** One tab projected from a `settings.plugins.tab` contribution. */
|
||||
export interface PluginSettingsTabRow {
|
||||
id: string
|
||||
order: number
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Registration-side business face for the section. */
|
||||
export interface PluginConfigSectionInjected {
|
||||
/** How many cards the slot ledger currently holds; zero renders the empty line. */
|
||||
cardCount: number
|
||||
hooks: {
|
||||
/** Ordered, locale-aware projection of the Plugins tab ledger. */
|
||||
tabs: HostObservable<readonly PluginSettingsTabRow[]>
|
||||
}
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the section. */
|
||||
export type PluginConfigSectionProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& PropsRenderSlots<'settings.plugin.item'>
|
||||
& PropsRenderSlots<'settings.plugins.tab'>
|
||||
& InjectFace<PluginConfigSectionInjected>
|
||||
|
||||
/**
|
||||
* Render the plugin configuration section.
|
||||
* @param props - runtime slot rendering, locale copy, and the card count.
|
||||
* @returns the section.
|
||||
*/
|
||||
export function PluginConfigSection(props: PluginConfigSectionProps) {
|
||||
const { t, renderSlot, cardCount } = props
|
||||
/** Render one Plugins page whose contents arrive from feature-owned tabs. */
|
||||
export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSectionProps) {
|
||||
const tabsId = useId()
|
||||
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>
|
||||
{cardCount === 0
|
||||
? <p className={css.empty}>{t('empty')}</p>
|
||||
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>}
|
||||
{rows.length === 0 ? <p className={css.empty}>{t('empty')}</p> : (
|
||||
<>
|
||||
<div className={css.tabs} role="tablist" aria-label={t('tabs')}>
|
||||
{rows.map((row) => {
|
||||
const selected = row.id === active
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
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}
|
||||
onClick={() => { setActiveId(row.id) }}
|
||||
>
|
||||
{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 {
|
||||
/** Plugin configuration section and card copy. */
|
||||
/** Plugins section, configurable-tab, and card copy. */
|
||||
'settings.pluginConfig': PluginConfigKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* Plugin configuration surface, browser half — one settings section holding
|
||||
* an expandable card per Host plugin whose configuration a user owns.
|
||||
* Plugins settings surface, browser half — one section whose feature-owned
|
||||
* tabs include configurable Host plugin cards and read-only inventory.
|
||||
*
|
||||
* The section owns no knowledge of any namespace: it declares the
|
||||
* `settings.plugin.item` slot and renders whatever cards were registered into
|
||||
* it, so a plugin that ships a browser half contributes its own card and its
|
||||
* own controls. The three cards this package registers are the host-plane
|
||||
* sections the deployment already exposes; each binds its namespace through
|
||||
* the client settings scope, which keeps them unaware of one another.
|
||||
* 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'
|
||||
@@ -18,11 +17,15 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// 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 { PluginConfigSection } from './PluginConfigSection.tsx'
|
||||
import type { PluginConfigSectionInjected, PluginSettingsTabRow } from './PluginConfigSection.tsx'
|
||||
import { WebSearchCard } from './WebSearchCard.tsx'
|
||||
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts'
|
||||
import { BASH_NS, BashCardController } from './bash-store.ts'
|
||||
@@ -30,6 +33,7 @@ import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx'
|
||||
export type { ConfigurablePluginsTabInjected, ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx'
|
||||
export type { PluginCardProps } from './PluginCard.tsx'
|
||||
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
|
||||
export type { FieldProps } from './fields.tsx'
|
||||
@@ -67,23 +71,67 @@ export function apply(ctx: ClientContext): void {
|
||||
'ui-plugin-config: credential invalidations',
|
||||
)
|
||||
|
||||
// The section renders the empty line rather than an empty list when no plugin
|
||||
// contributed a card. The count is read once: the renderer caches a root
|
||||
// entry's inject face per registration, so this reports what was registered
|
||||
// when the section mounted, not what is visible now. Both gaps are bounded by
|
||||
// this deployment always registering the three cards below — a card that
|
||||
// arrives later would not raise the count, and a namespace this deployment
|
||||
// does not expose leaves its card rendering nothing inside a non-empty list.
|
||||
let tabsVersion = -1
|
||||
let tabsRevision = -1
|
||||
let tabs: readonly PluginSettingsTabRow[] = []
|
||||
const sectionInjected = (): PluginConfigSectionInjected => ({
|
||||
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: 30,
|
||||
order: 15,
|
||||
label: () => t('nav'),
|
||||
locale: NS,
|
||||
inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }),
|
||||
children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
|
||||
inject: sectionInjected,
|
||||
children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
|
||||
}, PluginConfigSection))
|
||||
|
||||
// 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',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/** Locale keys these surfaces render. */
|
||||
export type PluginConfigKey =
|
||||
| 'nav' | 'title' | 'intro' | 'empty'
|
||||
| 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty'
|
||||
| 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse'
|
||||
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber'
|
||||
| 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint'
|
||||
@@ -14,9 +14,11 @@ export type PluginConfigKey =
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<PluginConfigKey, string> = {
|
||||
nav: 'Plugin config',
|
||||
title: 'Plugin configuration',
|
||||
intro: 'Configure the plugins this deployment installed.',
|
||||
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',
|
||||
@@ -53,9 +55,11 @@ export const en: Record<PluginConfigKey, string> = {
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
export const zh: Record<PluginConfigKey, string> = {
|
||||
nav: '插件配置',
|
||||
title: '插件配置',
|
||||
intro: '配置本部署已安装的插件。',
|
||||
nav: '插件',
|
||||
title: '插件',
|
||||
intro: '配置和查看本部署已安装的插件。',
|
||||
tabs: '插件视图',
|
||||
configurableTab: '插件配置',
|
||||
empty: '本部署没有开放任何插件设置。',
|
||||
overridden: '已覆盖',
|
||||
reset: '恢复默认',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Plugin configuration surface, node half. The empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader; the browser half ships the
|
||||
* settings section through exports["./client"], discovered from the
|
||||
* 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.
|
||||
|
||||
@@ -8,6 +8,9 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
|
||||
import type {
|
||||
ConfigurablePluginsTabInjected, PluginConfigSectionInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-plugin-config/client'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
@@ -46,16 +49,20 @@ describe('ui-plugin-config apply', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers the section and declares the per-plugin card slot', async () => {
|
||||
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: 30 })
|
||||
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(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' })
|
||||
})
|
||||
|
||||
@@ -69,13 +76,18 @@ describe('ui-plugin-config apply', () => {
|
||||
.toEqual(['bash', 'agent-loop', 'web-search'])
|
||||
})
|
||||
|
||||
it('injects a live card count and one business face per card', async () => {
|
||||
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]!
|
||||
expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 })
|
||||
const sectionFace = (section.inject as unknown as () => PluginConfigSectionInjected)()
|
||||
expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([
|
||||
{ id: 'configurable', order: 0, label: '插件配置' },
|
||||
])
|
||||
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.
|
||||
@@ -129,6 +141,7 @@ describe('ui-plugin-config apply', () => {
|
||||
await fiber.dispose()
|
||||
|
||||
expect(slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(slots.spec('settings.plugins.tab')).toBeUndefined()
|
||||
expect(slots.spec('settings.plugin.item')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,8 +13,10 @@ 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 { PluginConfigSection } from '../src/client/PluginConfigSection.tsx'
|
||||
import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx'
|
||||
import type { PluginConfigSectionProps, PluginSettingsTabRow } from '../src/client/PluginConfigSection.tsx'
|
||||
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
|
||||
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
|
||||
import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
|
||||
@@ -46,13 +48,24 @@ function cardActions() {
|
||||
return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
|
||||
}
|
||||
|
||||
function renderSection(cardCount: number, cards = 'cards') {
|
||||
function renderSection(rows: readonly PluginSettingsTabRow[]) {
|
||||
const props = {
|
||||
t,
|
||||
useTabs: (selector: (value: readonly PluginSettingsTabRow[]) => unknown) => selector(rows),
|
||||
renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => (
|
||||
<span>{options.only}</span>
|
||||
),
|
||||
} as unknown as PluginConfigSectionProps
|
||||
render(<PluginConfigSection {...props} />)
|
||||
}
|
||||
|
||||
function renderConfigurable(cardCount: number, cards = 'cards') {
|
||||
const props = {
|
||||
t,
|
||||
cardCount,
|
||||
renderSlot: () => <li>{cards}</li>,
|
||||
} as unknown as PluginConfigSectionProps
|
||||
render(<PluginConfigSection {...props} />)
|
||||
} as unknown as ConfigurablePluginsTabProps
|
||||
render(<ConfigurablePluginsTab {...props} />)
|
||||
}
|
||||
|
||||
function renderBash(state: Partial<BashCardState> = {}) {
|
||||
@@ -69,26 +82,53 @@ function renderBash(state: Partial<BashCardState> = {}) {
|
||||
}
|
||||
|
||||
describe('PluginConfigSection', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigurablePluginsTab', () => {
|
||||
it('says so when no plugin contributed a card', () => {
|
||||
renderSection(0)
|
||||
renderConfigurable(0)
|
||||
|
||||
expect(screen.getByText(en.empty)).toBeTruthy()
|
||||
expect(screen.queryByText('cards')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the card list once a plugin contributed one', () => {
|
||||
renderSection(1)
|
||||
renderConfigurable(1)
|
||||
|
||||
expect(screen.getByText('cards')).toBeTruthy()
|
||||
expect(screen.queryByText(en.empty)).toBeNull()
|
||||
})
|
||||
|
||||
it('leads with its own heading and intro', () => {
|
||||
renderSection(1)
|
||||
|
||||
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
|
||||
expect(screen.getByText(en.intro)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashCard', () => {
|
||||
|
||||
Reference in New Issue
Block a user