From ee558c82d776c521cb3f0ceaf6b2ca71e38d2f7d Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 12 Oct 2026 21:56:08 +0800 Subject: [PATCH] feat(web): reflow the Plugins page header at narrow widths The page header wraps its action group onto another line when the title and the actions no longer fit together, so the title keeps its width and the actions keep their labels on one line. The information button follows the subtitle text instead of a flex row, which keeps it beside the copy at every width. Cover the header in the plugin-manager browser scenario: the action group stays inline when it fits, moves below when it does not, keeps its labels on one line, stays inside the header, and retains focus across the change. --- .../header-responsive.expected.md | 18 +++++ apps/web/tests/plugin-manager.e2e.ts | 80 ++++++++++++++++++- .../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 | 14 +++- .../src/client/PluginManagerPage.tsx | 2 +- 7 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 apps/web/tests/expected/plugin-manager/header-responsive.expected.md diff --git a/apps/web/tests/expected/plugin-manager/header-responsive.expected.md b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md new file mode 100644 index 0000000000..f328fbfa83 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md @@ -0,0 +1,18 @@ +# Plugin header responsive layout + +en 1680px: actions single-line and contained; heading separate; information follows subtitle; focus retained +en 768px: actions single-line and contained; heading separate; information follows subtitle; focus retained +en 520px: actions single-line and contained; heading separate; information follows subtitle; focus retained +en 440px: actions single-line and contained; heading separate; information follows subtitle; focus retained +en 390px: actions single-line and contained; heading separate; information follows subtitle; focus retained +en 430px: actions single-line and contained; heading separate; information follows subtitle; focus retained +en 1024px: actions single-line and contained; heading separate; information follows subtitle; focus retained +zh 1680px: actions single-line and contained; heading separate; information follows subtitle; focus retained +zh 768px: actions single-line and contained; heading separate; information follows subtitle; focus retained +zh 520px: actions single-line and contained; heading separate; information follows subtitle; focus retained +zh 440px: actions single-line and contained; heading separate; information follows subtitle; focus retained +zh 390px: actions single-line and contained; heading separate; information follows subtitle; focus retained +zh 430px: actions single-line and contained; heading separate; information follows subtitle; focus retained +zh 1024px: actions single-line and contained; heading separate; information follows subtitle; focus retained + +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 fa61e50def..20a45f3abe 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -96,6 +96,84 @@ describe('web e2e: plugin manager', () => { return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '') } + it('keeps plugin header actions on one line as the available width changes', async () => { + const facts: string[] = [] + 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, + reducedMotion: 'reduce', + }) + try { + const probe = await context.newPage() + const consoleWatch = watchConsole(probe) + await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const title = language === 'en' ? 'Plugins' : '插件' + await probe.getByRole('button', { name: title, exact: true }).click() + const panel = probe.locator('[data-plugin-panel]') + const header = panel.locator(':scope > header') + await header.getByRole('heading', { name: title, exact: true }).waitFor() + await probe.evaluate(() => document.fonts.ready) + const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true }) + await expect.poll(() => add.isEnabled()).toBe(true) + await add.focus() + for (const width of [1680, 768, 520, 440, 390, 430, 1024]) { + await probe.setViewportSize({ width, height: 1000 }) + const measure = () => header.evaluate((element) => { + const heading = element.firstElementChild! + const actions = element.lastElementChild! + const headingRect = heading.getBoundingClientRect() + const actionRect = actions.getBoundingClientRect() + const headerRect = element.getBoundingClientRect() + const subtitle = heading.querySelector('h1 + div > span')! + const range = document.createRange() + range.selectNodeContents(subtitle) + const textRects = Array.from(range.getClientRects()) + const lastText = textRects.at(-1)! + const info = heading.querySelector('button')!.getBoundingClientRect() + const buttons = Array.from(actions.querySelectorAll('button')) + const singleLine = buttons.every((button) => { + const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT) + const tops: number[] = [] + while (walker.nextNode()) { + if (!walker.currentNode.textContent?.trim()) continue + range.selectNodeContents(walker.currentNode) + for (const rect of range.getClientRects()) tops.push(rect.top) + } + return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1 + }) + return { + singleLine, + separated: actionRect.top >= headingRect.bottom - 1 || actionRect.left >= headingRect.right - 1, + contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1, + infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5 + && info.top < lastText.bottom && info.bottom > lastText.top, + focused: buttons.includes(document.activeElement as HTMLButtonElement), + } + }) + await expect.poll(measure).toEqual({ + singleLine: true, separated: true, contained: true, infoFollowsText: true, focused: true, + }) + facts.push(`${language} ${width}px: actions single-line and contained; heading separate; information follows subtitle; focus retained`) + } + await probe.setViewportSize({ width: 390, height: 1000 }) + const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' }) + await menuTrigger.click() + await probe.getByRole('menu').waitFor() + await probe.keyboard.press('Escape') + await expect.poll(() => probe.getByRole('menu').count()).toBe(0) + expect(await menuTrigger.evaluate(element => element === document.activeElement)).toBe(true) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await context.close() + } + } + await compareOrRefreshGolden( + join(SNAPSHOT_DIR, 'header-responsive.expected.md'), + `# Plugin header responsive layout\n\n${facts.join('\n')}\n\nAdd menu dismisses on Escape and returns focus in both locales.`, + MODE, + ) + }) + it('aligns the first-read skeleton with the loaded plugin cards', async () => { const facts: string[] = [] let aria = '' @@ -932,7 +1010,7 @@ describe('web e2e: plugin manager', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md', - 'ime-enter.expected.md', + 'ime-enter.expected.md', 'header-responsive.expected.md', ]) }) }) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index b5992f5632..ad44971a01 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: 87e0c952a5bfcc41 - zh: 8824e1b588fae213 + en: 005ae2cf873c0a2e + zh: bc7ca9472105a6a0 /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 d7640d3de6..9f2236ff7b 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -29,6 +29,8 @@ 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. Switching a bundle on or off does not change its group or position. 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. + 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. On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. Updates preserve selection and report whether a restart is required. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 88db4ff635..a2d96ba2f2 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" 在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。启停组合包不会改变它的分组或位置。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 +空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。 + 已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新保留选择状态,并报告是否需要重启。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 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 3455127437..77ce98c504 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -22,6 +22,7 @@ .pageHead { display: flex; + flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 16px; @@ -39,6 +40,11 @@ padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px)); } +.pageHeading { + min-width: 0; + max-width: 100%; +} + .pageTitle { margin: 0; font-size: 20px; @@ -47,9 +53,6 @@ } .pageIntro { - display: flex; - align-items: center; - gap: 4px; margin: 4px 0 0; font-size: 13px; line-height: 20px; @@ -60,16 +63,19 @@ flex: none; width: 20px; height: 20px; + margin-inline-start: 4px; padding: 0; + vertical-align: middle; color: var(--dsw-alias-label-caption); } .toolbar { display: flex; - flex-wrap: wrap; + flex: none; align-items: center; justify-content: flex-end; gap: 16px; + white-space: nowrap; } .addMenu { diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 8bb9877e4a..12e877ece3 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -1484,7 +1484,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { {showsMainList ? (
-
+

{t('title')}

{t('intro')}