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.
This commit is contained in:
Yif committed 2026-10-12 21:56:08 +08:00
1 parent ebe725081c
commit ee558c82d7
7 files changed
+114 -8

No files matched your search

@@ -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.
+79 -1
View File
@@ -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',
])
})
})
@@ -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
@@ -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 `<subpath>/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.
@@ -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 目标不可用时仍可关闭。
@@ -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 {
@@ -1484,7 +1484,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
{showsMainList
? (
<header className={css.pageHead} data-window-drag>
<div>
<div className={css.pageHeading}>
<h1 className={css.pageTitle}>{t('title')}</h1>
<div className={css.pageIntro}>
<span>{t('intro')}</span>