From ebe725081c8424e55497b76daf9db96b97ff1cd1 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 12 Oct 2026 21:56:01 +0800 Subject: [PATCH 1/5] feat(skills): add the responsive client UI standard Add `.agents/skills/dsh-responsive-ui`, the repository standard for adapting client surfaces to narrow available widths: the 390 CSS px acceptance minimum, the order in which space and information yield, text truncation, titles, icon-only controls, mobile hover behaviour, mobile tooltips, and focus retention across width changes. Link it from `dsh-client-ui-ux` so the existing client UI/UX guidance routes to the new standard. --- .agents/skills/dsh-client-ui-ux/SKILL.md | 4 ++ .agents/skills/dsh-responsive-ui/SKILL.md | 50 +++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .agents/skills/dsh-responsive-ui/SKILL.md diff --git a/.agents/skills/dsh-client-ui-ux/SKILL.md b/.agents/skills/dsh-client-ui-ux/SKILL.md index b37a1ca4ba..4fbfa1a876 100644 --- a/.agents/skills/dsh-client-ui-ux/SKILL.md +++ b/.agents/skills/dsh-client-ui-ux/SKILL.md @@ -54,6 +54,10 @@ Every menu, popover, and tooltip must be verified for all three before merge: Scrollbars stay inside their container: use the shared scrollbar styles rather than component-specific selectors, and verify the bar neither overflows the container nor gets clipped by its rounded corners or edges — keep a small inset instead of letting it sit flush against the container edge. +## Responsive layouts + +Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, text truncation, title wrapping, icon-only controls, and focus during width changes. + ## Platform window adaptation - Any new header element or full page must account for macOS and Windows window chrome. On macOS the titlebar clearance is `--dsh-frame-top-clearance`, consumed by Menu, Modal, and overlay primitives as their top safe margin — **never change the global variable to fix one page**. Add a scoped `[data-platform='darwin']` override on the page's own inset instead, and assert the override in the page's stylesheet spec. diff --git a/.agents/skills/dsh-responsive-ui/SKILL.md b/.agents/skills/dsh-responsive-ui/SKILL.md new file mode 100644 index 0000000000..75e3879806 --- /dev/null +++ b/.agents/skills/dsh-responsive-ui/SKILL.md @@ -0,0 +1,50 @@ +--- +name: dsh-responsive-ui +description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover and tooltips, and focus during width changes in DeepSeek Harness. +--- + +# Responsive client UI + +## 1. Minimum viewport + +Use a viewport width of 390 CSS px as the primary acceptance minimum. There must be no unintended overlap, clipping, page-wide horizontal scrolling, or inaccessible primary actions. Below 390px, layout requirements may be relaxed. + +## 2. Decision order + +1. First adjust space allocation or wrap elements onto another line; then consider text truncation, icon-only controls, or moving icons into the More menu. +2. Primary actions and primary states must not be omitted. +3. Do not hide conflicts through global clipping, reducing all font sizes, or shrinking click targets. +4. Reduce font sizes only when specifically required by product design. + +## 3. Text truncation + +1. Truncated content must be fully available on a secondary page or by opening a menu. Example: Session statistics below the input box. +2. Truncation must not lose errors or other key information. +3. Prefer single-line truncation for tab navigation, visible selectors, buttons, tags, and similar components. +4. Important information that is not available on another page must be displayed in full without truncation. + +## 4. Titles + +1. Prefer displaying page titles in full. Move the action area onto another line when necessary. Example: the Plugins page's title wrapping behavior. +2. Prefer natural wrapping for card titles. If the number of lines is limited, the full title must remain available to view. + +## 5. Icon-only controls + +1. Reducing a control to an icon requires both: + - The icon clearly conveys its meaning, or the operation is familiar to users. + - A tooltip appears on hover; add one if needed. +2. Do not reduce “More account information” to an icon, because it navigates to a separate page. +3. The model selector in the input box can be reduced to an icon because users see it frequently and can open its menu to switch models. +4. Another example is the linked conversation in an automation task. + +## 6. Mobile hover behavior + +1. Enable mouse-hover effects only on devices that support hovering. Use `@media (hover: hover)`, not viewport width, to determine this. +2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover. +3. Preserve normal selected, enabled, and pressed states, as well as focus indicators for keyboard interaction. +4. Try to preserve focus when the width changes, unless the focused element disappears from the page as a result. + +## 7. Mobile tooltips + +1. Do not show tooltips that only provide auxiliary information such as action names or keyboard shortcuts on mobile. For example, omit the “Copy” tooltip on a copy button; tapping still performs the original action directly. +2. Tooltips that provide supplementary explanations must support tap-to-show on mobile. For example, the information tooltip beside the Plugins page description. 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 2/5] 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')} From 89c4652addac2f41d5dc8ae8542cab8981b7b440 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 12 Oct 2026 21:56:15 +0800 Subject: [PATCH 3/5] feat(web): reflow the automation tasks header at narrow widths The page header keeps the title at its natural width and moves the New action onto another line once the two no longer fit, so a narrow content column never splits the title across lines. Register the new browser scenario in both web programs: the host aggregate owns files that boot the host spine, and the client-registered app project excludes them. The scenario asserts the title stays on one line, the action separates when the column narrows, and focus survives the change. --- .../expected/schedule-header.expected.md | 17 ++++ apps/web/tests/schedule-header.e2e.ts | 87 +++++++++++++++++++ apps/web/tsconfig.json | 1 + packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 + packages/client/ui-schedule/README.zh.md | 2 + .../src/client/TaskManagerPage.module.css | 7 +- tsconfig.host.json | 1 + 8 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 apps/web/tests/expected/schedule-header.expected.md create mode 100644 apps/web/tests/schedule-header.e2e.ts diff --git a/apps/web/tests/expected/schedule-header.expected.md b/apps/web/tests/expected/schedule-header.expected.md new file mode 100644 index 0000000000..7ac291a79d --- /dev/null +++ b/apps/web/tests/expected/schedule-header.expected.md @@ -0,0 +1,17 @@ +# Automation tasks header + +en 1680px: title and New single-line; no overlap or overflow; New retains focus +en 800px: title and New single-line; no overlap or overflow; New retains focus +en 520px: title and New single-line; no overlap or overflow; New retains focus +en 440px: title and New single-line; no overlap or overflow; New retains focus +en 390px: title and New single-line; no overlap or overflow; New retains focus +en 430px: title and New single-line; no overlap or overflow; New retains focus +en 1024px: title and New single-line; no overlap or overflow; New retains focus +en 509px with sidebar expanded: title single-line; New moves below the title +zh 1680px: title and New single-line; no overlap or overflow; New retains focus +zh 800px: title and New single-line; no overlap or overflow; New retains focus +zh 520px: title and New single-line; no overlap or overflow; New retains focus +zh 440px: title and New single-line; no overlap or overflow; New retains focus +zh 390px: title and New single-line; no overlap or overflow; New retains focus +zh 430px: title and New single-line; no overlap or overflow; New retains focus +zh 1024px: title and New single-line; no overlap or overflow; New retains focus diff --git a/apps/web/tests/schedule-header.e2e.ts b/apps/web/tests/schedule-header.e2e.ts new file mode 100644 index 0000000000..54435bd98c --- /dev/null +++ b/apps/web/tests/schedule-header.e2e.ts @@ -0,0 +1,87 @@ +// UI-only geometry for the Automation tasks page; no model calls or task mutations. +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts' + +it('keeps the automation title intact before moving New to another line', async () => { + const scaffold = await launchWebScaffold() + try { + const browser = await chromium.launch() + try { + 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-CN', + reducedMotion: 'reduce', + }) + try { + const page = await context.newPage() + const consoleWatch = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const title = language === 'en' ? 'Automation tasks' : '自动化任务' + await page.getByRole('button', { name: title, exact: true }).click() + const panel = page.getByTestId('task-manager-page') + const heading = panel.getByRole('heading', { name: title, exact: true }) + await heading.waitFor() + const header = heading.locator('..') + const create = header.getByRole('button', { name: language === 'en' ? 'New' : '新建', exact: true }) + await page.evaluate(() => document.fonts.ready) + await create.focus() + for (const width of [1680, 800, 520, 440, 390, 430, 1024]) { + await page.setViewportSize({ width, height: 1000 }) + await expect.poll(() => header.evaluate((element) => { + const title = element.querySelector('h1')! + const button = element.querySelector('button')! + const titleRect = title.getBoundingClientRect() + const buttonRect = button.getBoundingClientRect() + const headerRect = element.getBoundingClientRect() + const range = document.createRange() + range.selectNodeContents(title) + const titleLines = Array.from(range.getClientRects()) + return { + titleSingleLine: titleLines.length === 1, + buttonSingleLine: button.scrollHeight <= button.clientHeight, + separated: buttonRect.left >= titleRect.right - 1 || buttonRect.top >= titleRect.bottom - 1, + contained: titleRect.left >= headerRect.left - 1 && titleRect.right <= headerRect.right + 1 + && buttonRect.left >= headerRect.left - 1 && buttonRect.right <= headerRect.right + 1, + focused: document.activeElement === button, + } + })).toEqual({ + titleSingleLine: true, buttonSingleLine: true, separated: true, contained: true, focused: true, + }) + facts.push(`${language} ${width}px: title and New single-line; no overlap or overflow; New retains focus`) + } + if (language === 'en') { + await page.setViewportSize({ width: 509, height: 1000 }) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click() + await expect.poll(() => header.evaluate((element) => { + const title = element.querySelector('h1')! + const button = element.querySelector('button')! + const range = document.createRange() + range.selectNodeContents(title) + return { + titleSingleLine: range.getClientRects().length === 1, + actionBelow: button.getBoundingClientRect().top >= title.getBoundingClientRect().bottom, + } + })).toEqual({ titleSingleLine: true, actionBelow: true }) + facts.push('en 509px with sidebar expanded: title single-line; New moves below the title') + } + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await context.close() + } + } + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/schedule-header.expected.md', import.meta.url)), + `# Automation tasks header\n\n${facts.join('\n')}`, + webSnapshotMode(), + ) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 04f42721a5..02613302ff 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -78,6 +78,7 @@ "tests/plugin-config.e2e.ts", "tests/input-plugin-extensions.e2e.ts", "tests/plugin-manager.e2e.ts", + "tests/schedule-header.e2e.ts", "tests/worktree-opt-in.e2e.ts", "tests/session-inspector.e2e.ts", "tests/shared-inspector.e2e.ts", diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 50241a25d1..205e4c6b84 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: c668e3d223102e47 - zh: efedf3996679fb17 + en: edba0e1573f3ce42 + zh: 77875b469d94cc62 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 2731187fae..220af1e6c7 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -27,6 +27,8 @@ Manage active and inactive tasks from the Automation tasks page and the right Si The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The page header keeps the title at its natural width and moves New to another line when the two no longer fit together. The action label stays on one line. + Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view. Rules shows the rule in a Run time card whose rows edit a local draft: Repeat is a menu of Weekly, Monday to Friday, Every day, Every N hours, Every N minutes, Every N seconds, Once, and Custom (cron), a weekly rule adds a Weekday row, a fixed-rate choice edits only the interval quantity in the unit it names, a one-shot rule edits separate Date and Time rows, and Custom (cron) edits one Cron expression row holding a five-field expression, interpreted in the selected time zone. The card parses that expression in the browser and, under the row, shows a localized sentence describing it; an expression the Host would reject shows the localized validation message in place of that sentence and blocks Save. A stored weekly rule whose weekday set is exactly Monday through Friday selects the Monday to Friday choice. The Date row is a read-only trigger showing the staged date as `YYYY/MM/DD` and opening a month calendar with localized weekday headings, month navigation, and a marked today; the draft and the submitted text keep the ISO form; the Time row is a read-only trigger showing one 24-hour clock and opening three scrolling columns of hours, minutes, and seconds with the staged value marked and scrolled into view. A calendar or clock pick stages the draft under the same staged save as every other row, arrow keys walk the open column or grid, Enter stages the marked value, Escape closes the panel and returns focus to its row, and a disabled row cannot open one. Clock rows show whole seconds, an untouched row keeps a stored millisecond value for its submit, and editing a time submits whole-second precision. Time zone is a searchable, height-bounded menu. An unlabeled top block keeps the five most recently selected exact zones in browser-local storage; first use seeds the actual system zone and UTC rather than inferring a location from UI language. The system zone is marked, and the remaining inventory is ordered by current UTC offset and canonical IANA id. Each visible label puts `UTC±HH:MM` before the localized zone name; canonical ids stay internal but remain searchable. Zones that ICU renders with the same offset and localized name share one visible row; every grouped id remains searchable, and an already stored id remains selected instead of being rewritten. The inventory comes from `Intl.supportedValuesOf('timeZone')`, and names come from `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`: both are browser ICU/CLDR data, not a scraped or product-maintained translation table. There is therefore no crawler or generated zone-name resource to refresh. A third-party locale supplies its ordinary `time.locale` value and automatically receives the corresponding ICU names; only the selector's surrounding UI copy needs translation. When the runtime cannot enumerate zones, a short identifier fallback is offered, and a stored zone outside the offered set is kept. Daily, Weekly, and Cron keep their stored rule and IANA zone, and each frequency line names the zone only when it differs from the host's system zone. A one-shot After/At seeds the date and time from the record's stored zone, or this device's zone when the record stores none, so the seeded clock names the stored instant; changing that zone keeps the entered clock values and changes the instant. A kind the card seeds on selection uses the committed occurrence's clock and the choice's zone, which is the record's stored zone or this device's zone, a seeded weekly rule starts from that occurrence's weekday in that zone, and a seeded cron rule states that occurrence's clock as a daily expression. Every accepts at least 60 whole seconds, independent of time zones. One Save submits the complete expected record with the staged change and preserves the id, original Session, latest receipt, and saved history; [Schedule](../../schedule/schedule/README.md#use-this-package) defines future-target selection, reanchoring, and unchanged-rule no-ops. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index cd027ea2ea..88575c4405 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +页头按标题的自然宽度排布;标题和“新建”无法并排容纳时,将按钮移到下一行。操作文案保持单行。 + 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 “规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron)的菜单,每周规则增加“星期”行,固定速率选项只修改它所属单位的间隔数量,一次性规则用独立的“日期”和“时间”两行编辑目标,自定义(cron)用一行“Cron 表达式”编辑按所选时区解释的 5 字段表达式。卡片在浏览器中解析该表达式,并在该行下方显示描述它的本地化语句;Host 会拒绝的表达式改为显示本地化校验信息,并在解析通过前阻止保存。已存储的每周规则若星期集合恰好为周一至周五,则选中“周一至周五”选项。“日期”行是只读触发器,以 `YYYY/MM/DD` 显示暂存的日期并展开带本地化星期表头、月份切换和今天标记的月历;草稿与提交文本保持 ISO 形态;“时间”行是只读触发器,显示统一的 24 小时时钟,并展开时、分、秒三列可滚动列表,当前值高亮并在打开时滚动到可见。在月历或时钟上点选即按与其他行相同的分阶段保存写入草稿;方向键在打开的列或网格内移动,Enter 选定当前值,Escape 关闭面板并把焦点还给所在行,被禁用的行无法打开面板。时钟行显示到秒,未改动的行在提交时保留已存储的毫秒值,编辑时间时按整秒精度提交。“时区”使用高度受限的可搜索菜单。顶部无标题区域保留浏览器本地最近选过的 5 个准确时区;首次使用按真实系统时区和 UTC 初始化,而不根据界面语言猜测所在地。系统时区带有标记,其余清单按当前 UTC 偏移、再按规范 IANA 标识排序。可见文案把 `UTC±HH:MM` 放在本地化时区名称之前;IANA 标识仅在内部使用,但可用于搜索。ICU 显示成相同偏移和本地化名称的多个时区只占一行;所有被合并的标识仍可用于搜索,任务已保存的标识也会继续作为选中值,不会被改写。时区清单来自 `Intl.supportedValuesOf('timeZone')`,名称来自 `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`:两者都是浏览器内置的 ICU/CLDR 数据,不是抓取或产品自维护的翻译表。因此没有需要刷新的爬虫或生成时区资源;第三方语言包只需提供常规的 `time.locale` 值,即可自动获得对应的 ICU 名称,仅需翻译选择器周边的 UI 文案。运行时无法枚举时改用简短的标识候选列表,已存储但不在其中的时区会保留。每天、每周和 cron 保留各自已存储的规则与时区,其频率行仅在时区与宿主系统时区不同时标出时区。一次性 After/At 以记录已存储的时区初始化日期和时间;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一瞬时;更改时区会保留输入的钟表值并改变时点。因选择种类而重新初始化的种类使用已提交发生时点的钟表值和该选项的时区,该时区取记录已存储的时区或本设备时区:每周的星期取自该发生时点在该时区下的星期,cron 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 60 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。 diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index 25b573fcd4..ba3ee2a030 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -45,9 +45,10 @@ padding: 0 clamp(24px, 4vw, 48px) 48px; } -/* The page name and the creation actions on one line. */ +/* Page title and creation actions. */ .pageHeading { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; @@ -68,8 +69,9 @@ their titles share one size. The theme carries no size token for this step, which is why every first-level page states the same literal. */ .pageHeading h1 { - flex: 1; + flex: 0 1 auto; min-width: 0; + max-width: 100%; margin: 0; font-size: 20px; line-height: 28px; @@ -82,6 +84,7 @@ flex: none; align-items: center; gap: 16px; + white-space: nowrap; } /* The Plugins toolbar's Add-plugin step: a 32px capsule with the 13px label. */ diff --git a/tsconfig.host.json b/tsconfig.host.json index c5193b97e4..d8369e2a65 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -71,6 +71,7 @@ "apps/web/tests/plugin-config.e2e.ts", "apps/web/tests/input-plugin-extensions.e2e.ts", "apps/web/tests/plugin-manager.e2e.ts", + "apps/web/tests/schedule-header.e2e.ts", "apps/web/tests/worktree-opt-in.e2e.ts", "apps/web/tests/session-inspector.e2e.ts", "apps/web/tests/shared-inspector.e2e.ts", From 5a52b980e59f86a8f4233e093f51132a5655d667 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 12 Oct 2026 22:33:05 +0800 Subject: [PATCH 4/5] feat(skills): keep the mobile input keyboard closed by default Add the eighth rule to the responsive client UI standard: on mobile, do not raise the input keyboard automatically unless it is explicitly intended. --- .agents/skills/dsh-responsive-ui/SKILL.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.agents/skills/dsh-responsive-ui/SKILL.md b/.agents/skills/dsh-responsive-ui/SKILL.md index 75e3879806..a9ee2f3a37 100644 --- a/.agents/skills/dsh-responsive-ui/SKILL.md +++ b/.agents/skills/dsh-responsive-ui/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-responsive-ui -description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover and tooltips, and focus during width changes in DeepSeek Harness. +description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness. --- # Responsive client UI @@ -48,3 +48,7 @@ Use a viewport width of 390 CSS px as the primary acceptance minimum. There must 1. Do not show tooltips that only provide auxiliary information such as action names or keyboard shortcuts on mobile. For example, omit the “Copy” tooltip on a copy button; tapping still performs the original action directly. 2. Tooltips that provide supplementary explanations must support tap-to-show on mobile. For example, the information tooltip beside the Plugins page description. + +## 8. Mobile keyboard + +On mobile, do not raise the input keyboard automatically unless it is explicitly intended. From 9e62c1c2294880cb093c8354401b1333b754c413 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 12 Oct 2026 23:15:20 +0800 Subject: [PATCH 5/5] fix(web): restore the plugin header subtitle row to its flex layout The responsive change had moved `.pageIntro` off flex, which put the information icon 1.06px off the subtitle's vertical centre and failed the existing geometry assertion in `plugin-manager.e2e.ts`. Restore the flex row and the button's `flex: none`, which keeps the 4px gap and the centring. The header still wraps its action group onto another line, which is what this change is for. Pin the wrap threshold in both header scenarios. Each records whether the action group sits beside or below the heading, the plugin scenario asserts the wide and narrow sides explicitly, and the plugin scenario launches its own scaffold so an install task left by an earlier test cannot move the threshold between runs. --- .../header-responsive.expected.md | 28 ++-- .../expected/schedule-header.expected.md | 28 ++-- apps/web/tests/plugin-manager.e2e.ts | 156 ++++++++++-------- apps/web/tests/schedule-header.e2e.ts | 20 ++- .../src/client/PluginManagerPage.module.css | 5 +- 5 files changed, 133 insertions(+), 104 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 f328fbfa83..956ddbda34 100644 --- a/apps/web/tests/expected/plugin-manager/header-responsive.expected.md +++ b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md @@ -1,18 +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 +en 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +en 768px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 520px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 440px: actions below 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 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 the heading, single-line; contained; information follows subtitle; focus retained +zh 520px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +zh 440px: actions below 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 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained Add menu dismisses on Escape and returns focus in both locales. diff --git a/apps/web/tests/expected/schedule-header.expected.md b/apps/web/tests/expected/schedule-header.expected.md index 7ac291a79d..96bff76bee 100644 --- a/apps/web/tests/expected/schedule-header.expected.md +++ b/apps/web/tests/expected/schedule-header.expected.md @@ -1,17 +1,17 @@ # Automation tasks header -en 1680px: title and New single-line; no overlap or overflow; New retains focus -en 800px: title and New single-line; no overlap or overflow; New retains focus -en 520px: title and New single-line; no overlap or overflow; New retains focus -en 440px: title and New single-line; no overlap or overflow; New retains focus -en 390px: title and New single-line; no overlap or overflow; New retains focus -en 430px: title and New single-line; no overlap or overflow; New retains focus -en 1024px: title and New single-line; no overlap or overflow; New retains focus +en 1680px: title and New single-line, New beside the title; contained; New retains focus +en 800px: title and New single-line, New beside the title; contained; New retains focus +en 520px: title and New single-line, New beside the title; contained; New retains focus +en 440px: title and New single-line, New beside the title; contained; New retains focus +en 390px: title and New single-line, New beside the title; contained; New retains focus +en 430px: title and New single-line, New beside the title; contained; New retains focus +en 1024px: title and New single-line, New beside the title; contained; New retains focus en 509px with sidebar expanded: title single-line; New moves below the title -zh 1680px: title and New single-line; no overlap or overflow; New retains focus -zh 800px: title and New single-line; no overlap or overflow; New retains focus -zh 520px: title and New single-line; no overlap or overflow; New retains focus -zh 440px: title and New single-line; no overlap or overflow; New retains focus -zh 390px: title and New single-line; no overlap or overflow; New retains focus -zh 430px: title and New single-line; no overlap or overflow; New retains focus -zh 1024px: title and New single-line; no overlap or overflow; New retains focus +zh 1680px: title and New single-line, New beside the title; contained; New retains focus +zh 800px: title and New single-line, New beside the title; contained; New retains focus +zh 520px: title and New single-line, New beside the title; contained; New retains focus +zh 440px: title and New single-line, New beside the title; contained; New retains focus +zh 390px: title and New single-line, New beside the title; contained; New retains focus +zh 430px: title and New single-line, New beside the title; contained; New retains focus +zh 1024px: title and New single-line, New beside the title; contained; New retains focus diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 20a45f3abe..15937b97ff 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -98,80 +98,98 @@ describe('web e2e: plugin manager', () => { 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) + // A dedicated scaffold keeps the measured header deterministic: an install + // task left by an earlier test swaps the toolbar's add control for the + // view-installation button, which moves the wrap threshold. + const headerScaffold = await launchWebScaffold() + try { + // The widths where the header must have wrapped or not are pinned; the + // remaining widths assert only that the action group is placed on one side + // of the heading, and the golden records which. + const PINNED_PLACEMENT: Record = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' } + 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(headerScaffold.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, + placement: actionRect.left >= headingRect.right - 1 + ? 'beside' as const + : actionRect.top >= headingRect.bottom - 1 ? 'below' as const : 'overlapping' as const, + 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), } - 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`) + const pinned = PINNED_PLACEMENT[width] + await expect.poll(measure).toMatchObject({ + singleLine: true, contained: true, infoFollowsText: true, focused: true, + }) + const placed = (await measure()).placement + if (pinned === undefined) expect(['beside', 'below']).toContain(placed) + else expect(placed).toBe(pinned) + facts.push(`${language} ${width}px: actions ${placed} the heading, single-line; contained; 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 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, + ) + } finally { + await headerScaffold.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 () => { diff --git a/apps/web/tests/schedule-header.e2e.ts b/apps/web/tests/schedule-header.e2e.ts index 54435bd98c..fc79fd571c 100644 --- a/apps/web/tests/schedule-header.e2e.ts +++ b/apps/web/tests/schedule-header.e2e.ts @@ -31,6 +31,8 @@ it('keeps the automation title intact before moving New to another line', async await create.focus() for (const width of [1680, 800, 520, 440, 390, 430, 1024]) { await page.setViewportSize({ width, height: 1000 }) + // The title keeps its natural width at every viewport width, so New + // stays beside it here; the squeeze case below is what moves it down. await expect.poll(() => header.evaluate((element) => { const title = element.querySelector('h1')! const button = element.querySelector('button')! @@ -43,29 +45,37 @@ it('keeps the automation title intact before moving New to another line', async return { titleSingleLine: titleLines.length === 1, buttonSingleLine: button.scrollHeight <= button.clientHeight, - separated: buttonRect.left >= titleRect.right - 1 || buttonRect.top >= titleRect.bottom - 1, + placement: buttonRect.left >= titleRect.right - 1 + ? 'beside' as const + : buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const, contained: titleRect.left >= headerRect.left - 1 && titleRect.right <= headerRect.right + 1 && buttonRect.left >= headerRect.left - 1 && buttonRect.right <= headerRect.right + 1, focused: document.activeElement === button, } })).toEqual({ - titleSingleLine: true, buttonSingleLine: true, separated: true, contained: true, focused: true, + titleSingleLine: true, buttonSingleLine: true, placement: 'beside', contained: true, focused: true, }) - facts.push(`${language} ${width}px: title and New single-line; no overlap or overflow; New retains focus`) + facts.push(`${language} ${width}px: title and New single-line, New beside the title; contained; New retains focus`) } if (language === 'en') { + // 509px collapses the left sidebar to its rail; expanding it leaves the + // page column narrow enough (measured 172px) to force the wrap. await page.setViewportSize({ width: 509, height: 1000 }) await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click() await expect.poll(() => header.evaluate((element) => { const title = element.querySelector('h1')! const button = element.querySelector('button')! + const titleRect = title.getBoundingClientRect() + const buttonRect = button.getBoundingClientRect() const range = document.createRange() range.selectNodeContents(title) return { titleSingleLine: range.getClientRects().length === 1, - actionBelow: button.getBoundingClientRect().top >= title.getBoundingClientRect().bottom, + placement: buttonRect.left >= titleRect.right - 1 + ? 'beside' as const + : buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const, } - })).toEqual({ titleSingleLine: true, actionBelow: true }) + })).toEqual({ titleSingleLine: true, placement: 'below' }) facts.push('en 509px with sidebar expanded: title single-line; New moves below the title') } expect(consoleWatch.pageErrors).toEqual([]) 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 77ce98c504..dda60f3c91 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -53,6 +53,9 @@ } .pageIntro { + display: flex; + align-items: center; + gap: 4px; margin: 4px 0 0; font-size: 13px; line-height: 20px; @@ -63,9 +66,7 @@ flex: none; width: 20px; height: 20px; - margin-inline-start: 4px; padding: 0; - vertical-align: middle; color: var(--dsw-alias-label-caption); }