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

@@ -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()
})
})

View File

@@ -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', () => {