From fbff97cd28a4a160fd63d41d4f4505ff2f6ed503 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 19 Oct 2026 22:06:14 +0800 Subject: [PATCH] feat(plugin-manager): compact sticky header and responsive actions --- .../header-responsive.expected.md | 10 +- apps/web/tests/plugin-manager.e2e.ts | 103 +++++++++++++++++- apps/web/tests/window-drag-coverage.e2e.ts | 69 ++++++++++-- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.module.css | 97 +++++++++++++++-- .../src/client/PluginManagerPage.tsx | 72 +++++++----- .../tests/components.client.spec.tsx | 26 ++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/SegmentedTabs.module.css | 20 +++- .../ui-primitives/src/SegmentedTabs.tsx | 7 +- .../tests/SegmentedTabs.client.spec.tsx | 4 +- 15 files changed, 355 insertions(+), 69 deletions(-) diff --git a/apps/web/tests/expected/plugin-manager/header-responsive.expected.md b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md index 7967bfe352..85d43c5307 100644 --- a/apps/web/tests/expected/plugin-manager/header-responsive.expected.md +++ b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md @@ -4,16 +4,16 @@ en 1680px: actions beside the heading, single-line; contained; information follo en 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained en 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained en 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained -en 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained -en 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 390px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +en 430px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained en 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained zh 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained zh 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained zh 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained zh 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained -zh 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained -zh 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained +zh 390px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +zh 430px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained zh 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained -A forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes. +A forced no-wrap layout at 240px fails the same measured layout check in both locales; restoring wrap and 390px passes. Add menu dismisses on Escape and returns focus in both locales. diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index aa01c4dd6b..3b376ce206 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -108,7 +108,7 @@ describe('web e2e: plugin manager', () => { try { // Pin the extremes; at intermediate widths, the rendered font and locale // determine whether the two intrinsic items fit beside each other. - const PINNED_PLACEMENT: Record = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' } + const PINNED_PLACEMENT: Record = { 1680: 'beside', 1024: 'beside' } for (const language of ['en', 'zh'] as const) { const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE, @@ -129,7 +129,7 @@ describe('web e2e: plugin manager', () => { await add.focus() const measure = () => header.evaluate((element) => { const heading = element.firstElementChild! - const actions = element.lastElementChild! + const actions = element.querySelector('[data-plugin-toolbar]')! const headingRect = heading.getBoundingClientRect() const actionRect = actions.getBoundingClientRect() const headerRect = element.getBoundingClientRect() @@ -194,7 +194,8 @@ describe('web e2e: plugin manager', () => { if (pinned !== undefined) expect(placement).toBe(pinned) facts.push(`${language} ${width}px: actions ${pinned === undefined ? 'beside or below (according to intrinsic fit)' : placement} the heading, single-line; contained; information follows subtitle; focus retained`) } - await probe.setViewportSize({ width: 390, height: 1000 }) + // Force less room than even the icon actions need beside the heading. + await probe.setViewportSize({ width: 240, height: 1000 }) const previousWrap = await header.evaluate((element) => { const style = element.style const previous = { value: style.getPropertyValue('flex-wrap'), priority: style.getPropertyPriority('flex-wrap') } @@ -212,6 +213,7 @@ describe('web e2e: plugin manager', () => { else element.style.setProperty('flex-wrap', previous.value, previous.priority) }, previousWrap) } + await probe.setViewportSize({ width: 390, height: 1000 }) await expect.poll(async () => validLayout(await measure())).toBe(true) const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' }) await menuTrigger.click() @@ -226,7 +228,7 @@ describe('web e2e: plugin manager', () => { } await compareOrRefreshGolden( join(SNAPSHOT_DIR, 'header-responsive.expected.md'), - `# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes.\nAdd menu dismisses on Escape and returns focus in both locales.`, + `# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 240px fails the same measured layout check in both locales; restoring wrap and 390px passes.\nAdd menu dismisses on Escape and returns focus in both locales.`, MODE, ) } finally { @@ -234,6 +236,63 @@ describe('web e2e: plugin manager', () => { } }) + it('uses icon actions before shrinking tabs and wraps only when both still cannot fit', async () => { + const compactScaffold = await launchWebScaffold({ + profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] }, + }) + const context = await browser.newContext({ viewport: { width: 1280, height: 480 }, locale: 'en-US', reducedMotion: 'reduce' }) + try { + const probe = await context.newPage() + await probe.goto(compactScaffold.authenticatedUrl, { waitUntil: 'load' }) + await probe.getByRole('button', { name: 'Plugins', exact: true }).click() + const panel = probe.locator('[data-plugin-panel]') + const bar = panel.locator('[data-plugin-bar]') + const body = panel.locator('[data-plugin-body]') + const refresh = bar.getByRole('button', { name: 'Refresh', exact: true }) + const add = bar.getByRole('button', { name: 'Add plugin', exact: true }) + await panel.locator('[data-plugin-package]').first().waitFor() + await bar.getByRole('tablist').waitFor() + await add.focus() + for (const [width, icons, narrowTabs, wraps] of [ + [1280, false, false, false], [600, true, false, false], + [470, true, true, false], [390, true, true, true], [300, true, true, true], + ] as const) { + await probe.setViewportSize({ width, height: 480 }) + await body.evaluate((element) => { element.scrollTop = 180 }) + await probe.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact')) + await expect.poll(() => bar.evaluate((element) => { + const tablist = element.querySelector('[role="tablist"]')! + const toolbar = element.querySelector('[data-plugin-toolbar]')! + const action = toolbar.querySelector('button')! + const tabRect = tablist.getBoundingClientRect(), actions = toolbar.getBoundingClientRect() + const actionRect = action.getBoundingClientRect() + const wrapped = actions.top >= tabRect.bottom + return { + icons: getComputedStyle(action.lastElementChild!).display === 'none', + narrowTabs: tabRect.width < 240, + wraps: wrapped, + centered: wrapped || Math.abs(tabRect.top + tabRect.height / 2 - actionRect.top - actionRect.height / 2) < 1, + } + }), { message: `compact toolbar at ${width}px` }).toEqual({ icons, narrowTabs, wraps, centered: true }) + expect(await add.evaluate(element => element === document.activeElement)).toBe(true) + } + await probe.setViewportSize({ width: 390, height: 480 }) + await refresh.hover() + await probe.getByRole('tooltip', { name: 'Refresh', exact: true }).waitFor() + await add.hover() + await probe.getByRole('tooltip', { name: 'Add plugin', exact: true }).waitFor() + await bar.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click() + await probe.getByRole('menu').waitFor() + await probe.keyboard.press('Escape') + await probe.getByRole('menu').waitFor({ state: 'hidden' }) + await add.click() + await probe.getByRole('dialog').waitFor() + } finally { + await context.close() + await compactScaffold.close() + } + }) + it('keeps the plugin page title and actions pinned while the cards scroll', async () => { await page.setViewportSize({ width: 1000, height: 480 }) const panel = await openPluginsPanel() @@ -241,12 +300,33 @@ describe('web e2e: plugin manager', () => { try { await panel.locator('[data-plugin-package]').first().waitFor() const headerTop = () => panel.locator('[data-plugin-bar] > header').evaluate(element => element.getBoundingClientRect().top) + const bar = panel.locator('[data-plugin-bar]') + const barHeight = () => bar.evaluate(element => element.getBoundingClientRect().height) const resting = await headerTop() + const restingHeight = await barHeight() + const refreshButton = bar.getByRole('button', { name: '刷新', exact: true }) + const addButton = bar.getByRole('button', { name: '添加插件', exact: true }) + const restingActions = await Promise.all([refreshButton.boundingBox(), addButton.boundingBox()]) expect(resting).toBe(await panel.evaluate(element => element.getBoundingClientRect().top)) + const restingBarBottom = await bar.evaluate(element => element.getBoundingClientRect().bottom) + expect(await body.locator('[data-plugin-group]').first().evaluate(element => element.getBoundingClientRect().top)) + .toBeCloseTo(restingBarBottom + 32, 1) await body.evaluate((element) => { element.scrollTop = element.scrollHeight }) const bottom = await body.evaluate(element => element.scrollTop) expect(bottom).toBeGreaterThan(100) expect(await headerTop()).toBe(resting) + // Scrolled, the bar collapses to one row above a divider; it keeps its place at the top. + await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact') === true) + expect(await barHeight()).toBeLessThan(restingHeight) + expect(await bar.evaluate(element => getComputedStyle(element, '::after').borderBottomWidth)).not.toBe('0px') + const barBottomAtScroll = await bar.evaluate(element => element.getBoundingClientRect().bottom) + expect(await body.evaluate(element => element.getBoundingClientRect().top)).toBeCloseTo(barBottomAtScroll, 1) + expect(await bar.evaluate(element => Number.parseFloat(getComputedStyle(element).paddingBottom))).toBe(16) + expect(await Promise.all([refreshButton.boundingBox(), addButton.boundingBox()])) + .toEqual(restingActions.map((bounds) => { + if (bounds === null) throw new Error('Plugin toolbar action is not visible') + return { ...bounds, y: bounds.y - 12 } + })) // Reverse keyboard navigation scrolls each focused control into view below the bar, never under it. await panel.locator('[data-plugin-package] [role="switch"]').last().focus() const barBottom = await panel.locator('[data-plugin-bar]').evaluate(element => element.getBoundingClientRect().bottom) @@ -268,6 +348,21 @@ describe('web e2e: plugin manager', () => { expect(hidden).toEqual([]) expect(await body.evaluate(element => element.scrollTop)).toBeLessThan(bottom) expect(await headerTop()).toBe(resting) + await page.setViewportSize({ width: 1680, height: 480 }) + await body.evaluate((element) => { element.scrollTop = element.scrollHeight }) + await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact') === true) + await expect.poll(() => panel.evaluate((element) => { + const bounds = element.getBoundingClientRect() + const barElement = element.querySelector('[data-plugin-bar]')! + const barBounds = barElement.getBoundingClientRect() + const bodyBounds = element.querySelector('[data-plugin-body]')!.getBoundingClientRect() + const dividerStyle = getComputedStyle(barElement, '::after') + return { + left: barBounds.left + Number.parseFloat(dividerStyle.left) - bounds.left, + right: barBounds.right - Number.parseFloat(dividerStyle.right) - bounds.right, + gap: bodyBounds.top - barBounds.bottom, + } + })).toEqual({ left: 0, right: 0, gap: 0 }) } finally { await body.evaluate((element) => { element.scrollTop = 0 }) await page.setViewportSize({ width: 1680, height: 1000 }) diff --git a/apps/web/tests/window-drag-coverage.e2e.ts b/apps/web/tests/window-drag-coverage.e2e.ts index cb6df06778..9777bc60b2 100644 --- a/apps/web/tests/window-drag-coverage.e2e.ts +++ b/apps/web/tests/window-drag-coverage.e2e.ts @@ -198,10 +198,11 @@ describe('web e2e: macOS window drag coverage', () => { }) /** - * Open a darwin-marked page in the shipped composition. + * Open a desktop-marked page in the shipped composition. + * @param platform - Desktop CSS and preload platform to emulate in Chromium. * @returns the page and its console tripwire. */ - async function darwinPage(): Promise<{ page: Page; tripwire: ReturnType }> { + async function desktopPage(platform: 'darwin' | 'win32' = 'darwin'): Promise<{ page: Page; tripwire: ReturnType }> { const page = await newEnglishPage(browser) await page.addInitScript(({ value, snapshot }) => { // The Desktop platform marker requires the preload's keyboard and preference capabilities. @@ -211,18 +212,68 @@ describe('web e2e: macOS window drag coverage', () => { subscribe: () => () => {}, recording: async () => {}, edit: async () => ({ status: 'not-ready', snapshot }) }, } }) - const mark = (): void => { document.documentElement.setAttribute('data-platform', value) } + const mark = (): void => { + document.documentElement.setAttribute('data-platform', value) + if (value === 'win32') { + document.documentElement.setAttribute('data-windows-titlebar', '') + document.documentElement.style.setProperty('--dsh-windows-titlebar-height', '40px') + } + } if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true }) else mark() - }, { value: 'darwin', snapshot: initialShortcutConfig() }) + }, { value: platform, snapshot: initialShortcutConfig() }) const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) return { page, tripwire } } + it.each(['darwin', 'win32'] as const)('%s keeps compact Plugins controls below native chrome in both sidebar states', async (platform) => { + const { page, tripwire } = await desktopPage(platform) + try { + await page.setViewportSize({ width: 1280, height: 480 }) + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + await page.locator('[data-plugin-package]').first().waitFor() + const bar = page.locator('[data-plugin-bar]') + const body = page.locator('[data-plugin-body]') + const toolbarGeometry = () => bar.evaluate((element) => { + const header = element.querySelector('header')! + const bounds = element.getBoundingClientRect() + const buttons = Array.from(element.querySelectorAll('button')) + .filter(button => button.textContent?.trim() === 'Refresh' || button.textContent?.trim() === 'Add plugin') + return { + top: bounds.top, + padding: Number.parseFloat(getComputedStyle(header).paddingTop), + buttons: buttons.map((button) => { const rect = button.getBoundingClientRect(); return { x: rect.x, y: rect.y } }), + } + }) + for (const collapsed of [false, true]) { + if (collapsed) { + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + } + await settled(page) + await body.evaluate((element) => { element.scrollTop = 0 }) + await page.waitForFunction(() => !document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact')) + const before = await toolbarGeometry() + expect(before.top).toBe(platform === 'darwin' ? 0 : 40) + expect(before.padding).toBe(platform === 'darwin' ? 76 : 28) + expect(before.buttons).toHaveLength(2) + await body.evaluate((element) => { element.scrollTop = 120 }) + await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact')) + const after = await toolbarGeometry() + expect(after.padding).toBe(platform === 'darwin' ? 64 : 16) + expect(after.buttons).toEqual(before.buttons.map(button => ({ ...button, y: button.y - 12 }))) + expect(after.buttons.every(button => button.y >= (platform === 'darwin' ? 48 : 40))).toBe(true) + expect(await body.evaluate(element => element.getBoundingClientRect().top)) + .toBe(await bar.evaluate(element => element.getBoundingClientRect().bottom)) + if (platform === 'darwin') expect(swallowedBoxes(await collectedRegions(page, INTERACTIVE_SELECTOR))).toEqual([]) + } + expect(tripwire.pageErrors).toEqual([]) + } finally { await page.close() } + }) + it('leaves no interactive box inside the drag surface, and keeps the panel strip row draggable', async () => { - const { page, tripwire } = await darwinPage() + const { page, tripwire } = await desktopPage() try { await connectFreshWorkspace(page, scaffold.workspaceCwd) const agent = scaffold.ctx.agents.list()[0] @@ -338,7 +389,7 @@ describe('web e2e: macOS window drag coverage', () => { }, 180_000) it('keeps the Conversation header draggable over the collapsed column, and the seat out of it', async () => { - const { page, tripwire } = await darwinPage() + const { page, tripwire } = await desktopPage() try { await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() await page.locator('[data-shell-leading]').waitFor({ state: 'visible', timeout: 15_000 }) @@ -369,7 +420,7 @@ describe('web e2e: macOS window drag coverage', () => { }, 120_000) it('keeps an entry page’s chrome row draggable', async () => { - const { page, tripwire } = await darwinPage() + const { page, tripwire } = await desktopPage() try { await page.getByRole('button', { name: 'Plugins', exact: true }).click() const head = page.locator('[class*="pageHead"]').first() @@ -406,7 +457,7 @@ describe('web e2e: macOS window drag coverage', () => { }, 120_000) it('keeps a detail page’s head row draggable and its body out of the drag surface', async () => { - const { page, tripwire } = await darwinPage() + const { page, tripwire } = await desktopPage() try { await page.getByRole('button', { name: 'Plugins', exact: true }).click() const cards = page.locator('[data-plugin-package], [data-plugin-item]') @@ -472,7 +523,7 @@ describe('web e2e: macOS window drag coverage', () => { }, 120_000) it('keeps a covering overlay out of the drag surface', async () => { - const { page, tripwire } = await darwinPage() + const { page, tripwire } = await desktopPage() try { await page.getByRole('button', { name: 'Account menu', exact: true }).click() await page.getByRole('menuitem', { name: 'Settings', exact: true }).click() diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 2236f9f664..763a5c5b7d 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: beab77eab7d5f31f - zh: 95978e08d581349d + en: 58d78e3a5bea65e0 + zh: e2e1fb97ac9dda53 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: fcb2e69c8b028c0a zh: d505a075679974eb diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 943a29f576..e05b2a49bd 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -29,7 +29,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Bundle details can show a feature preview below the description, also while the bundle is disabled; **DSH badge skill** shows one in English or Chinese following the UI language, and a decode failure shows a short notice without blocking management. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. -The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text. The title row, its actions, and the Installed/Discover tabs stay at the top while the list below scrolls; the More list's back-and-title row does the same, and detail pages scroll their head row with the content. +As available header width decreases, Refresh and Add plugin first become icon-only controls with accessible names and tooltips; the tabs then shrink from their usual minimum width to fit their labels, and the action group wraps only when necessary. The information button follows the subtitle text. The title row, its actions, and the Installed/Discover tabs stay at the top while the list below scrolls; the More list's back-and-title row does the same, and detail pages scroll their head row with the content. The Installed/Discover tabs use the compact 34px control with a 2px inset around the selection indicator. When scrolled, the bar hides the subtitle; with tabs present, it also hides the title and places the tabs beside Refresh and Add plugin where space permits. The compact bar reduces its page-top inset from 28px to 16px, moving its controls upward together while retaining 16px below them. macOS adds the frame's titlebar clearance; Windows relies on the frame's caption offset without adding it again. The divider spans the page above a directly adjoining scroll viewport; the list's initial top spacing scrolls with its content. Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index b2d0733a7c..801eceb898 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" 在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。组合包详情可在说明下方展示功能配图,未启用时也可查看;**DSH 徽章技能**按界面语言展示中文或英文配图,解码失败时显示简短提示,不影响管理操作。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 -空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。标题行、操作和“已安装/发现”标签固定在页面顶部,下方列表单独滚动;“更多”列表的返回与标题行同样固定,详情页的页头随内容滚动。 +页头可用宽度缩小时,“刷新”和“添加插件”先变为保留可访问名称与 tooltip 的图标按钮;标签再从常规最小宽度收紧为文案所需宽度,仍不足时操作组才换行。信息按钮紧随副标题文字。标题行、操作和“已安装/发现”标签固定在页面顶部,下方列表单独滚动;“更多”列表的返回与标题行同样固定,详情页的页头随内容滚动。“已安装/发现”标签使用总高 34px 的紧凑控件,选中指示块与外框之间留 2px。列表滚动后,顶栏隐藏副标题;有标签时也隐藏标题,并在空间足够时将标签与“刷新”“添加插件”放在同一行。折叠态将页面顶部留白从 28px 缩为 16px,各控件一起上移,下方保留 16px 留白。macOS 额外保留框架的标题栏安全区;Windows 使用框架已有的标题栏偏移,不重复叠加。分隔线横跨页面,滚动区域紧贴其下,列表初始的顶部留白随内容滚动。 已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index db4607ffa4..de7052f8ca 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -7,7 +7,6 @@ display: flex; flex-direction: column; align-items: center; - gap: 16px; box-sizing: border-box; height: 100%; /* Inline inset only: the 28px top inset belongs to the head row @@ -23,10 +22,23 @@ max-width: 960px; } -.pageBar { +.page > .pageBar { + --plugin-page-top-inset: 28px; + + container: plugin-page-bar / inline-size; + position: relative; display: flex; flex-direction: column; - gap: 16px; + width: calc(100% + 2 * var(--plugin-page-inset)); + max-width: none; + box-sizing: border-box; + padding-inline: var(--plugin-page-inset); +} + +.pageBar > .pageHead { + align-self: center; + width: 100%; + max-width: 960px; } /* Spans the page's full width, so its scrollbar sits at the page edge, and re-centres the column inside. */ @@ -45,9 +57,9 @@ overflow: auto; } -/* Under a head row, the page gap and this inset keep the first group 32px below it. */ +/* The initial list inset scrolls away; the scroll viewport meets the fixed bar directly. */ .page > :not(.pageBody) + .pageBody { - padding-top: 16px; + padding-top: 32px; } .pageBody > * { @@ -64,7 +76,7 @@ box-sizing: border-box; /* The page's former top inset: the row starts at the window's top edge, so the whole strip above the page content drags the window. */ - padding-top: 28px; + padding-top: var(--plugin-page-top-inset); } /* macOS hiddenInset titlebar: the frame's top clearance keeps the head below @@ -72,12 +84,13 @@ owns the window drag over its own box — clearance included — so this page's top strip drags without a frame band. */ :global([data-platform='darwin']) .pageHead { - padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px)); + padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px)); } .pageHeading { min-width: 0; max-width: 100%; + order: 1; } .pageTitle { @@ -114,6 +127,7 @@ gap: 8px 16px; min-width: 0; white-space: nowrap; + order: 2; } .addMenu { @@ -1555,9 +1569,46 @@ label.registryOption { display: none; } -/* Installed and contributed tabs under the page header, aligned with the title. */ +/* Installed and contributed tabs under the page header at rest; the scrolled bar + pulls them onto the actions' row. */ .pageTabs { display: flex; + order: 3; + flex: 1 1 100%; +} + +/* Keep spacing inside the compact bar, above its divider rather than outside the scroll viewport. */ +.pageBar[data-compact] { + --plugin-page-top-inset: 16px; + + padding-bottom: 16px; +} + +/* The collapsed bar's divider spans the page like the scrolled list and its scrollbar. */ +.pageBar[data-compact]::after { + content: ''; + position: absolute; + inset-inline: 0; + bottom: 0; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); + pointer-events: none; +} + +.pageBar[data-compact] .pageHead { + align-items: center; +} + +.pageBar[data-compact] .pageIntro { + display: none; +} + +.pageBarWithTabs[data-compact] .pageHeading { + display: none; +} + +.pageBarWithTabs[data-compact] .pageTabs { + order: 1; + flex: 0 0 auto; } .pageTabList { @@ -1565,6 +1616,36 @@ label.registryOption { min-width: 240px; } +@container plugin-page-bar (width < 600px) { + .toolbarLabel { + display: none; + } + + .pageHead .addButton, + .pageHead .addPrimary { + width: 32px; + min-width: 32px; + padding-inline: 0; + } + + .toolbar { + flex-shrink: 0; + } + + .updatesNote { + order: 3; + flex-basis: 100%; + white-space: normal; + } +} + +/* Keep the usual tab width until the icon actions and their gaps need its space. */ +@container plugin-page-bar (width < 372px) { + .pageTabList { + min-width: 0; + } +} + /* The Installed groups keep the page's row gap inside their tab panel. */ .installedPanel { display: flex; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 7e15ab01d6..babdb112c2 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -68,10 +68,12 @@ function AddPluginMenu({ t, disabled, openInstall, renderSlot }: { const onDismiss = (): void => { setOpen(false) } return ( - + + + { if (state.highlight !== null) setTab(INSTALLED_TAB) }, [state.highlight, setTab]) const tabsId = useId() const [activation, setActivation] = useState(null) + // The bar keeps one row while the list is scrolled, as the page heading only needs its resting height at the top. + const [compactBar, setCompactBar] = useState(false) useEffect(() => { ensure() }, [ensure]) // Installation highlights visible cards without changing the user's current page. const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight } @@ -1649,7 +1656,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
{showsMainList ? ( -
+
0 ? ` ${css.pageBarWithTabs}` : ''}`} + data-plugin-bar + data-compact={compactBar ? '' : undefined} + >

{t('title')}

@@ -1662,38 +1673,44 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
- ) : null} @@ -1706,7 +1723,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ) : null} {/* The page bar and the More list's head stay put; everything below scrolls. */} -
+
{ setCompactBar(event.currentTarget.scrollTop > COMPACT_BAR_SCROLL_TOP) }}> {!onInstalled ? (
diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index ebc87dce8f..a31e6e1aa9 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -230,8 +230,8 @@ describe('PluginManagerPage', () => { expect(screen.queryByRole('tablist')).toBeNull() setTabs([{ id: 'discover', label: 'Discover' }]) expect(screen.getByRole('tab', { name: en.installedTab }).getAttribute('aria-selected')).toBe('true') - // The tabs stay in the pinned page bar with the title; the tab panel scrolls in the page body. - expect(screen.getByRole('tablist').closest('[data-plugin-bar]')).not.toBeNull() + // The tabs stay in the pinned page head with the title; the tab panel scrolls in the page body. + expect(screen.getByRole('tablist').closest('header')).not.toBeNull() expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull() fireEvent.click(screen.getByRole('tab', { name: 'Discover' })) expect(navigation.getSnapshot().tab).toBe('discover') @@ -259,6 +259,28 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('tabpanel', { name: en.installedTab })).toBeTruthy() }) + it('collapses the page bar to one row while the page body is scrolled', () => { + const { setTabs } = renderTab({ packages: [pkg()] }) + const bar = document.querySelector('[data-plugin-bar]') + const body = document.querySelector('[data-plugin-body]') + expect(bar?.hasAttribute('data-compact')).toBe(false) + setTabs([{ id: 'discover', label: 'Discover' }]) + // The tabs take the collapsed row beside the actions, so they sit in the pinned head itself. + expect(screen.getByRole('tablist').closest('header')).not.toBeNull() + act(() => { + if (body === null) throw new Error('the page body is missing') + body.scrollTop = 160 + fireEvent.scroll(body) + }) + expect(bar?.hasAttribute('data-compact')).toBe(true) + act(() => { + if (body === null) throw new Error('the page body is missing') + body.scrollTop = 0 + fireEvent.scroll(body) + }) + expect(bar?.hasAttribute('data-compact')).toBe(false) + }) + it('keeps the Installed tab panel through loading and the More list out of the tabs', () => { const worktree = '@deepseek-ai/dsh-experimental-tool-worktree' const { navigation, set } = renderTab({ status: 'loading', packages: [] }, {}, { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2e59aaf715..6a08129b2b 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 6f5457b84fc9db09 - zh: 85993f1c44bfee2c + en: 55672e1a36c1ddec + zh: c68b694e833cac98 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: 28ac8f0b1edbcf81 zh: 5c4c324bf84a282f diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 5b29187be1..bbbd9422fa 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | | `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | -| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | +| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | | `PathLabel` | Single-line file path with subdued directories, a primary filename, and the full path on hover. Fitting paths align left; clipped paths preserve their suffix with a left-edge fade that updates on path and size changes. | | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero on mount and on every animation restart, such as a list reorder, so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index c1593e273b..744948886c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -49,7 +49,7 @@ kind: "package-library" | `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | | `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | -| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | +| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | | `PathLabel` | 单行文件路径:目录使用弱化颜色,文件名使用主色,悬停可查看完整路径。空间足够时靠左显示;溢出时保留尾部并在左侧渐隐,路径或尺寸变化时更新。 | | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画在挂载时以及每次动画重启(如列表重排)时固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | diff --git a/packages/client/ui-primitives/src/SegmentedTabs.module.css b/packages/client/ui-primitives/src/SegmentedTabs.module.css index f717b73dc2..be696fdd28 100644 --- a/packages/client/ui-primitives/src/SegmentedTabs.module.css +++ b/packages/client/ui-primitives/src/SegmentedTabs.module.css @@ -1,14 +1,16 @@ .tabs { + --segmented-tabs-inset: 4px; + position: relative; display: grid; - padding: 4px; + padding: var(--segmented-tabs-inset); border-radius: var(--dsw-radius-lg); background: var(--dsw-alias-bg-module-platform); } .indicator { position: absolute; - inset: 4px auto 4px 4px; + inset: var(--segmented-tabs-inset) auto var(--segmented-tabs-inset) var(--segmented-tabs-inset); box-sizing: border-box; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md); @@ -40,6 +42,20 @@ font-weight: 600; } +.tabs[data-size='sm'] { + --segmented-tabs-inset: 2px; + + border-radius: calc(var(--dsw-radius-md) + var(--segmented-tabs-inset)); +} + +.tabs[data-size='sm'] .tab { + height: 30px; +} + +.tabs[data-size='sm'] .tab[aria-selected='true'] { + font-weight: 500; +} + .tab:focus-visible { outline: none; text-decoration: underline; diff --git a/packages/client/ui-primitives/src/SegmentedTabs.tsx b/packages/client/ui-primitives/src/SegmentedTabs.tsx index 2ae51be8d5..a34f006d2e 100644 --- a/packages/client/ui-primitives/src/SegmentedTabs.tsx +++ b/packages/client/ui-primitives/src/SegmentedTabs.tsx @@ -19,14 +19,16 @@ export interface SegmentedTab { * Keyboard selection also moves focus; only the selected tab is a tab stop. * @param props.label - localized accessible name for the tab list. * @param props.className - layout placement; panels remain caller-owned. + * @param props.size - `sm` uses 30px tabs with a 2px inset; the default `md` uses 34px tabs with a 4px inset. * @returns the tab list, without its panels. */ -export function SegmentedTabs({ items, value, onChange, label, className }: { +export function SegmentedTabs({ items, value, onChange, label, className, size = 'md' }: { items: readonly [SegmentedTab, ...SegmentedTab[]] value: Value onChange: (value: Value) => void label: string className?: string | undefined + size?: 'sm' | 'md' | undefined }): ReactNode { const selectedIndex = items.findIndex(item => item.value === value) @@ -52,6 +54,7 @@ export function SegmentedTabs({ items, value, onChange, la return (
({ items, value, onChange, la