diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml index cdfdc3afde..980999fc1a 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md -2026-08-11-plugin-settings-tabs.md: 987e9f49e750a026c38a0d73c255cd335c53a4fe -2026-08-11-plugin-settings-tabs.zh.md: 887ca20bc09871d4bac5650a5d5eb71d5fcb81ee +2026-08-11-plugin-settings-tabs.md: c46276de2d0ffee141377190ccacf0e7884ec994 +2026-08-11-plugin-settings-tabs.zh.md: 00feb44f7b612e191c5b4c7dd3675ef34bf6e5ac diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md index 987e9f49e7..c46276de2d 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md @@ -12,7 +12,7 @@ Plugin configuration and the read-only Loader inventory each registered a top-le `@deepseek-ai/dsh-client-ui-plugin-config` owns the single `settings.section` contribution with id `plugins`. It renders the shared title and compact tab chrome, declares the root-scoped list slot `settings.plugins.tab`, and projects that ledger's id, order, and locale-following label into its tabs. The slot's canonical type lives in `ui-settings`, so a tab contributor depends on the Settings domain contract rather than on another feature plugin. -The section owner contributes a `configurable` tab that declares the existing nested `settings.plugin.item` list. Configuration cards keep their namespace bindings, draft state, validation, and writes unchanged. `@deepseek-ai/dsh-client-ui-plugins` contributes an `all` tab to `settings.plugins.tab`; its Host Loader observer, generated Remote namespace, DTO, search semantics, and read-only disclosure cards remain unchanged. +The section owner contributes a `configurable` tab that declares the existing nested `settings.plugin.item` list. Configuration cards keep their namespace bindings, draft state, validation, and writes unchanged. `@deepseek-ai/dsh-client-ui-plugins` contributes an `all` tab to `settings.plugins.tab`; its Host Loader observer, generated Remote namespace, DTO, and search semantics remain unchanged. Disabled inventory entries omit the redundant unmounted runtime state from summaries and details, while enabled entries continue to expose their Cordis phase. The first ordered tab is selected by default. A tab mounts only when first selected and then remains mounted but hidden while the Plugins section stays mounted. This delays the inventory RPC until the user opens **Plugin list** and preserves drafts, search text, disclosure state, and the fetched snapshot while switching tabs. Closing Settings unmounts the section, so reopening it obtains a fresh inventory snapshot when that tab is selected again. diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md index 887ca20bc0..00feb44f7b 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md @@ -12,7 +12,7 @@ Status: implemented `@deepseek-ai/dsh-client-ui-plugin-config` 拥有唯一一个 id 为 `plugins` 的 `settings.section` 贡献。它渲染共享标题和紧凑标签栏,声明根级列表 slot `settings.plugins.tab`,并把该记录中的 id、order 与跟随语言的 label 投影成标签页。该 slot 的规范类型位于 `ui-settings`,因此标签页贡献方依赖设置领域约定,而不是依赖另一个功能插件。 -分区拥有方贡献 `configurable` 标签页,由它声明既有的嵌套 `settings.plugin.item` 列表。配置卡片原有的命名空间绑定、草稿状态、校验与写入均保持不变。`@deepseek-ai/dsh-client-ui-plugins` 向 `settings.plugins.tab` 贡献 `all` 标签页;它的 Host Loader 观察器、生成的 Remote 命名空间、DTO、搜索语义与只读折叠卡片均保持不变。 +分区拥有方贡献 `configurable` 标签页,由它声明既有的嵌套 `settings.plugin.item` 列表。配置卡片原有的命名空间绑定、草稿状态、校验与写入均保持不变。`@deepseek-ai/dsh-client-ui-plugins` 向 `settings.plugins.tab` 贡献 `all` 标签页;它的 Host Loader 观察器、生成的 Remote 命名空间、DTO 与搜索语义保持不变。已停用的清单条目会在摘要和详情中省略重复的“未挂载”运行状态,已启用条目仍显示其 Cordis 阶段。 默认选择顺序中的第一个标签页。某个标签页只有首次被选择时才挂载,之后在“插件”分区保持挂载期间只隐藏而不卸载。这样会把清单 RPC 延迟到用户打开**插件列表**时,并在切换标签页时保留草稿、搜索文本、折叠状态和已读取的快照。关闭 Settings 会卸载该分区,因此再次打开后,重新选择该标签页时会取得新的清单快照。 diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css index f5a162eee8..5760403c4a 100644 --- a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css @@ -58,7 +58,9 @@ } .tab:focus-visible { - outline: none; + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; + border-radius: 2px; color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx index a75816b92e..592e495488 100644 --- a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx @@ -1,6 +1,6 @@ /** Plugins settings section: localized tabs around feature-owned pages. */ -import { useEffect, useId, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' @@ -32,6 +32,7 @@ export type PluginConfigSectionProps = /** Render one Plugins page whose contents arrive from feature-owned tabs. */ export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSectionProps) { const tabsId = useId() + const tabRefs = useRef>([]) const rows = useTabs(value => value) const [activeId, setActiveId] = useState() const [visitedIds, setVisitedIds] = useState>(() => new Set()) @@ -55,11 +56,12 @@ export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSect {rows.length === 0 ?

{t('empty')}

: ( <>
- {rows.map((row) => { + {rows.map((row, index) => { const selected = row.id === active return ( diff --git a/packages/client/ui-plugin-config/tests/section.client.spec.tsx b/packages/client/ui-plugin-config/tests/section.client.spec.tsx index e00fd7d448..1191fc3aea 100644 --- a/packages/client/ui-plugin-config/tests/section.client.spec.tsx +++ b/packages/client/ui-plugin-config/tests/section.client.spec.tsx @@ -117,6 +117,38 @@ describe('PluginConfigSection', () => { 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', () => {