feat(web): combine plugin settings into tabs

This commit is contained in:
ZiyaZhang
2026-08-12 00:38:10 -07:00
parent b1c13b5ca3
commit 4f5c730032
50 changed files with 512 additions and 217 deletions

View File

@@ -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-plugins/README.md
README.md: bb487d5e2cbd34406d83867997ede4d70b190d70
README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9
README.md: a663665cc2d7ce26a2724da9aecc25f51515327a
README.zh.md: 852ecbe6546f76a76fc6d9d3a8bb891539b0844f

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md).
Read-only **Plugin list** tab for Web Settings. The browser plugin registers one localized `settings.plugins.tab` contribution with id `all`; the Plugins section owns the navigation entry and tab chrome. It performs no Remote read during plugin activation. Selecting the tab for the first time mounts it and lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md).
The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store.
The tab renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the short module name as its title and a small effective-enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals its Loader-tree entry id without a redundant field label, followed by the effective configuration and, for enabled entries, Cordis status. Disabled entries omit the redundant unmounted runtime state. The entry id remains the React key, disclosure identity, detail value, and an additional search target; it is never classified by string shape. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner.
## Model Experience
@@ -16,5 +16,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot.
- **One snapshot per Settings mount or retry** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings obtains a new one.
- **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls.

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`
Web 设置中的只读**插件列表**标签页。浏览器插件注册一个 id 为 `all` 的本地化 `settings.plugins.tab` 贡献;“插件”分区拥有导航入口与标签栏。插件激活期间不会读取 Remote首次选择该标签页时才挂载组件,并通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`
以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown不拥有另一份全局 store
该标签页以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用模块短名称作为标题,以小标签表示有效启停状态;已启用的条目还会以彩色圆点表示根 fiber 状态。展开卡片后会直接展示 Loader 树条目 id,不附加重复的字段标题,并列出有效配置状态;已启用的条目还会列出 Cordis 状态,已停用的条目则省略重复的“未挂载”运行状态。条目 id 仍作为 React key、展开标识、详情值与额外的搜索目标代码不按字符串形状对它分类。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown无需 import 分区拥有方
## 模型体验
@@ -16,5 +16,5 @@ Web 设置中的只读“插件”分区。浏览器插件在“模型”之后
## 已知限制与暂缓事项
- **每次挂载或重试只读取一份快照** —— 页不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。
- **每次 Settings 挂载或重试只读取一份快照** —— 标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 则会取得新快照。
- **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-plugins",
"description": "Read-only Cordis Loader plugin inventory in Web settings",
"description": "Read-only Cordis Loader inventory tab in Web Plugins settings",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"

View File

@@ -7,19 +7,12 @@
color: var(--dsw-alias-label-primary);
}
.heading h2,
.catalogHeading h3,
.status,
.failure p {
margin: 0;
}
.heading h2 {
font-size: 16px;
line-height: 24px;
font-weight: 600;
}
.status,
.failure {
font-size: 13px;

View File

@@ -19,7 +19,7 @@ type PluginFiberPhase = PluginInventoryEntry['fiberPhase']
/** Full component props assembled by the Settings slot renderer. */
export type PluginSettingsSectionProps =
PropsRuntime<'settings.section'>
PropsRuntime<'settings.plugins.tab'>
& PropsLocale<'settings.plugins'>
& InjectFace<PluginSettingsSectionInjected>
@@ -62,7 +62,7 @@ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean
/** Render the read-only current Loader inventory. */
export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ReactNode {
const titleId = useId()
const catalogId = useId()
const [request, setRequest] = useState(0)
const [query, setQuery] = useState('')
const [expanded, setExpanded] = useState<PluginInventoryEntry['entryId'] | null>(null)
@@ -97,10 +97,7 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
}
return (
<section className={css.section} aria-labelledby={titleId} aria-busy={state.status === 'loading'}>
<header className={css.heading}>
<h2 id={titleId}>{t('title')}</h2>
</header>
<div className={css.section} aria-busy={state.status === 'loading'}>
{state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
{state.status === 'error' ? (
<div className={css.failure}>
@@ -134,8 +131,9 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
{filteredEntries.map((entry) => {
const status = phaseLabel(entry.fiberPhase, t)
const title = moduleShortName(entry.moduleName)
const configuration = t(entry.enabled ? 'enabledTag' : 'disabledTag')
const open = expanded === entry.entryId
const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}`
const detailId = `${catalogId}-details-${encodeURIComponent(entry.entryId)}`
return (
<li
className={css.card}
@@ -148,22 +146,24 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
type="button"
aria-expanded={open}
aria-controls={detailId}
aria-label={`${title}, ${status}, ${t(entry.enabled ? 'enabledTag' : 'disabledTag')}`}
aria-label={entry.enabled ? `${title}, ${status}, ${configuration}` : `${title}, ${configuration}`}
onClick={() => {
setExpanded(current => current === entry.entryId ? null : entry.entryId)
}}
>
<strong className={css.cardTitle} title={entry.moduleName}>{title}</strong>
<span className={css.cardTrailing}>
<span
className={css.statusDot}
data-phase={entry.fiberPhase ?? 'unobserved'}
role="img"
aria-label={status}
title={status}
/>
{entry.enabled ? (
<span
className={css.statusDot}
data-phase={entry.fiberPhase ?? 'unobserved'}
role="img"
aria-label={status}
title={status}
/>
) : null}
<span className={css.configTag} data-enabled={entry.enabled ? 'true' : 'false'}>
{t(entry.enabled ? 'enabledTag' : 'disabledTag')}
{configuration}
</span>
<IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
</span>
@@ -174,12 +174,14 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
<dl className={css.details}>
<div>
<dt>{t('configuration')}</dt>
<dd>{t(entry.enabled ? 'enabledTag' : 'disabledTag')}</dd>
</div>
<div>
<dt>{t('cordis')}</dt>
<dd>{status}</dd>
<dd>{configuration}</dd>
</div>
{entry.enabled ? (
<div>
<dt>{t('cordis')}</dt>
<dd>{status}</dd>
</div>
) : null}
</dl>
</div>
) : null}
@@ -190,6 +192,6 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
) : null}
</div>
) : null}
</section>
</div>
)
}

View File

@@ -22,7 +22,7 @@ export const NS = 'settings.plugins'
/** Services required by the Settings registration and generated Remote face. */
export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory']
/** Register the lazy plugin inventory page below Models in Settings. */
/** Contribute the lazy inventory tab to the Plugins settings section. */
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries')
@@ -36,11 +36,11 @@ export function apply(ctx: ClientContext): void {
}
const injected = (): PluginSettingsSectionInjected => ({ list })
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'plugin-inventory',
order: 15,
label: () => t('nav'),
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
id: 'all',
order: 10,
label: () => t('tab'),
locale: NS,
inject: injected,
}, PluginSettingsSection))

View File

@@ -2,8 +2,7 @@
/** Simplified Chinese dictionary and key source of truth. */
export const zh = {
nav: '插件',
title: '插件',
tab: '插件列表',
loading: '正在读取插件…',
error: '暂时无法读取插件。',
retry: '重试',
@@ -28,8 +27,7 @@ export type PluginsKey = keyof typeof zh
/** English dictionary checked against the Chinese key set. */
export const en = {
nav: 'Plugins',
title: 'Plugins',
tab: 'Plugin list',
loading: 'Reading plugins…',
error: 'Plugins are temporarily unavailable.',
retry: 'Retry',

View File

@@ -1,4 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host loader entry for the inventory-tab browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the plugin settings section. */
/** Host plugin body — no host-side behavior for the plugin inventory tab. */
export function apply(): void {}

View File

@@ -38,7 +38,7 @@ async function bench() {
function declare(slots: SlotsService): () => void {
return slots.register({
name: 'root',
children: { 'settings.section': { kind: 'list', scope: 'root' } },
children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
} as never, () => null)
}
@@ -47,16 +47,16 @@ describe('ui-plugins browser plugin', () => {
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory'])
})
it('registers a localized section without reading the Remote eagerly', async () => {
it('registers a localized tab without reading the Remote eagerly', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.section')[0]!
const entry = b.slots.entries('settings.plugins.tab')[0]!
expect(entry.component).toBe(PluginSettingsSection)
expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 })
expect(entry.options).toMatchObject({ id: 'all', order: 10 })
expect(entry.locale).toBe(NS)
expect(resolveSlotLabel(entry.options.label)).toBe('插件')
expect(resolveSlotLabel(entry.options.label)).toBe('插件列表')
expect(b.list).not.toHaveBeenCalled()
const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)()
@@ -71,22 +71,22 @@ describe('ui-plugins browser plugin', () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
const stop = declare(b.slots)
await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) })
await vi.waitFor(() => { expect(b.slots.entries('settings.plugins.tab')).toHaveLength(1) })
b.locale.setLocale('en')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins')
expect(resolveSlotLabel(b.slots.entries('settings.plugins.tab')[0]!.options.label)).toBe('Plugin list')
stop()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
declare(b.slots)
await vi.waitFor(() => {
expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection)
expect(b.slots.entries('settings.plugins.tab')[0]?.component).toBe(PluginSettingsSection)
})
await fiber.dispose()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
expect(() => b.locale.register(NS, 'zh', {})).not.toThrow()
await b.ctx.fiber.dispose()
})

View File

@@ -12,16 +12,12 @@ afterEach(cleanup)
type Snapshot = Awaited<ReturnType<PluginSettingsSectionInjected['list']>>
const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t']
const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never
function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps {
return {
close: vi.fn(),
useSessions: unusedHook,
useWorkspaces: unusedHook,
t,
list,
}
} as PluginSettingsSectionProps
}
const SNAPSHOT = {
@@ -36,7 +32,7 @@ const SNAPSHOT = {
} as unknown as Snapshot
describe('PluginSettingsSection', () => {
it('renders searchable two-column-card semantics with dots and tags', async () => {
it('renders runtime status only for enabled plugins', async () => {
const deferred = Promise.withResolvers<Snapshot>()
const list = vi.fn(() => deferred.promise)
const view = render(<PluginSettingsSection {...props(list)} />)
@@ -56,10 +52,10 @@ describe('PluginSettingsSection', () => {
'Loading',
'Mount failed',
'Unloading',
'Not mounted',
]) {
expect(screen.getByRole('img', { name: value })).toBeTruthy()
}
expect(screen.queryByRole('img', { name: 'Not mounted' })).toBeNull()
const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' })
expect(active.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(active)
@@ -75,8 +71,10 @@ describe('PluginSettingsSection', () => {
target: { value: 'disabled-entry' },
})
expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' }))
fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Disabled' }))
expect(screen.getAllByText(en.disabledTag)).toHaveLength(2)
expect(screen.queryByText(en.cordis)).toBeNull()
expect(screen.queryByText(en.unobserved)).toBeNull()
})
it('filters by module name or Loader entry id', async () => {