feat(web): combine plugin settings into tabs
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user