From ac20d7f6bc7aabafed439f9e7fa5447e9b937028 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 25 Sep 2026 21:22:51 +0800 Subject: [PATCH 01/35] fix(ui): isolate plugin examples and visual polish --- .../cancelled.expected.md | 2 +- .../highlight.expected.md | 30 +++++++++++++ .../another-way.expected.md | 6 +-- .../plugin-install-github/mirror.expected.md | 2 +- apps/web/tests/plugin-install-cancel.e2e.ts | 44 ++++++++++++++++++- .../src/client/chat/TurnTriggerNodeView.tsx | 4 +- .../conversation-nodes/README.i18n.yaml | 4 +- .../src/client/conversation-nodes/README.md | 2 +- .../client/conversation-nodes/README.zh.md | 2 +- .../tests/turn-trigger.client.spec.tsx | 10 +++++ .../src/client/PluginManagerPage.module.css | 4 +- .../ui-plugin-manager/src/client/locales.ts | 8 ++-- .../tests/components.client.spec.tsx | 13 ++++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/plugin-artwork.tsx | 23 +++++++--- .../tests/plugin-artwork.client.spec.tsx | 14 +++++- 18 files changed, 144 insertions(+), 32 deletions(-) create mode 100644 apps/web/tests/expected/plugin-install-cancel/highlight.expected.md diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md index 14d7992611..b61895ca6c 100644 --- a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md @@ -3,7 +3,7 @@ - button "关闭" - paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。 - textbox "包名或地址": - - /placeholder: 例如 @deepseek-ai/dsh-subagent-codex + - /placeholder: 例如 dsh-plugin-whale-pet - text: slow-package - button "插件安装引导和示例" - button "安装源 默认安装源" diff --git a/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md b/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md new file mode 100644 index 0000000000..b56769d22f --- /dev/null +++ b/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md @@ -0,0 +1,30 @@ +- listitem: + - button "查看 slow-package": slow-package + - switch "启用 slow-package" [checked] + +[ + { + "colorScheme": "light", + "reducedMotion": "no-preference", + "boxShadow": "oklab(0.589542 -0.0217781 -0.177995 / 0.4) 0px 0px 0px 2px", + "animated": true + }, + { + "colorScheme": "light", + "reducedMotion": "reduce", + "boxShadow": "color(srgb 0.254902 0.462745 0.901961 / 0.4) 0px 0px 0px 2px", + "animated": false + }, + { + "colorScheme": "dark", + "reducedMotion": "no-preference", + "boxShadow": "oklab(0.739592 -0.02038 -0.132631 / 0.4) 0px 0px 0px 2px", + "animated": true + }, + { + "colorScheme": "dark", + "reducedMotion": "reduce", + "boxShadow": "color(srgb 0.478431 0.666667 1 / 0.4) 0px 0px 0px 2px", + "animated": false + } +] diff --git a/apps/web/tests/expected/plugin-install-github/another-way.expected.md b/apps/web/tests/expected/plugin-install-github/another-way.expected.md index b36f699bb5..0932737ec2 100644 --- a/apps/web/tests/expected/plugin-install-github/another-way.expected.md +++ b/apps/web/tests/expected/plugin-install-github/another-way.expected.md @@ -2,14 +2,14 @@ - heading "添加插件" [level=2] - button "关闭" - textbox "插件包名": - - /placeholder: 例如 @deepseek-ai/dsh-subagent-codex + - /placeholder: 例如 dsh-plugin-whale-pet - button "收起引导" [expanded] - button "安装源 中国大陆镜像源" - list: - listitem: - text: 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例: - - code: "@deepseek-ai/dsh-subagent-codex" - - button "填入示例 @deepseek-ai/dsh-subagent-codex": 填入示例 + - code: dsh-plugin-whale-pet + - button "填入示例 dsh-plugin-whale-pet": 填入示例 - listitem: - text: GitHub 仓库地址 插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。 示例: - code: https://github.com/author/dsh-plugin diff --git a/apps/web/tests/expected/plugin-install-github/mirror.expected.md b/apps/web/tests/expected/plugin-install-github/mirror.expected.md index b649252d7d..d7e9793ddb 100644 --- a/apps/web/tests/expected/plugin-install-github/mirror.expected.md +++ b/apps/web/tests/expected/plugin-install-github/mirror.expected.md @@ -2,7 +2,7 @@ - heading "添加插件" [level=2] - button "关闭" - textbox "插件包名": - - /placeholder: 例如 @deepseek-ai/dsh-subagent-codex + - /placeholder: 例如 dsh-plugin-whale-pet - button "插件安装引导和示例" - button "安装源 中国大陆镜像源" - button "安装" [disabled] diff --git a/apps/web/tests/plugin-install-cancel.e2e.ts b/apps/web/tests/plugin-install-cancel.e2e.ts index 9e2294051e..db282c28c5 100644 --- a/apps/web/tests/plugin-install-cancel.e2e.ts +++ b/apps/web/tests/plugin-install-cancel.e2e.ts @@ -9,7 +9,7 @@ import { expect, it } from 'vitest' import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts' import { ZH_BROWSER_LOCALE } from './support.ts' -it('cancels installation through the UI, restores files, and offers the spec again', async () => { +it('cancels installation, retries and highlights the enabled plugin at 40% alpha, and recovers unknown results', async () => { const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-cancel-')) const overlay = join(scratch, 'cordis.patch.yml') await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`) @@ -39,6 +39,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga `) const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const tripwire = watchConsole(page) + await page.clock.install() await page.goto(scaffold.authenticatedUrl) await page.waitForSelector('[class*="frame"]') if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') @@ -135,7 +136,46 @@ it('cancels installation through the UI, restores files, and offers the spec aga await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click() await dialog.getByText('Retry completed', { exact: true }).waitFor() expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'slow-package': '1.0.0' } }) - await dialog.getByRole('button', { name: '关闭', exact: true }).click() + // Freeze the real highlight's expiry timer and CSS first frame independently. + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)) + const pausedHighlight = await page.addStyleTag({ content: '[data-plugin-highlight] { animation-play-state: paused !important; }' }) + try { + await dialog.getByRole('button', { name: '立即启用', exact: true }).click() + await dialog.waitFor({ state: 'hidden' }) + const card = panel.locator('[data-plugin-package="slow-package"][data-plugin-highlight]') + await card.waitFor({ state: 'visible' }) + expect(await panel.locator('[data-plugin-highlight]').count()).toBe(1) + expect(await card.getAttribute('data-plugin-status')).toBe('running') + const styles = [] + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + for (const reducedMotion of ['no-preference', 'reduce'] as const) { + await page.emulateMedia({ reducedMotion }) + const style = await card.evaluate((element) => { + const computed = getComputedStyle(element) + return { boxShadow: computed.boxShadow, animationName: computed.animationName, animationDuration: computed.animationDuration } + }) + expect(style.boxShadow).toMatch(/(?:\/|,)\s*0\.4\)/) + expect(style.boxShadow).toContain('0px 0px 0px 2px') + if (reducedMotion === 'reduce') { + expect(style.animationName).toBe('none') + } else { + expect(style.animationName).toContain('dsh-plugin-highlight') + expect(style.animationDuration).toBe('2.4s') + } + styles.push({ colorScheme, reducedMotion, boxShadow: style.boxShadow, animated: style.animationName !== 'none' }) + } + } + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/highlight.expected.md', import.meta.url)), + `${await captureStableAria(page, '[data-plugin-package="slow-package"]', scaffold.workspaceCwd)}\n\n${JSON.stringify(styles, null, 2)}`, webSnapshotMode()) + await page.clock.runFor(2400) + await card.waitFor({ state: 'detached' }) + } finally { + await pausedHighlight.evaluate((element) => { element.parentNode?.removeChild(element) }) + await page.emulateMedia({ colorScheme: null, reducedMotion: null }) + await page.clock.resume() + } await panel.getByRole('button', { name: '添加插件', exact: true }).click() await dialog.getByRole('textbox').fill('recovered-package') await page.route('**/api/pluginManager/installBundle', async (route) => { diff --git a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx index 7df6e718d0..afa2106ca9 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx @@ -1,7 +1,7 @@ /** An independent, expandable notice explaining a non-human Turn trigger. */ import { useId, useState, type ComponentType } from 'react' import { - IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular, + IconAgentPresetOutlineRegular, IconClockOutlineRegular, IconBranchOutlineRegular, IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular, type IconProps, @@ -20,7 +20,7 @@ const TRIGGER_ICONS: Record> = { subagent: IconAgentPresetOutlineRegular, github: IconBranchOutlineRegular, webhook: IconGlobeOutlineRegular, - schedule: IconAlarmClockOutlineRegular, + schedule: IconClockOutlineRegular, job: IconQueueOutlineRegular, plugin: IconCordisPluginOutlineRegular, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 3017e3eaed..52d08c5590 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -24,8 +24,8 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 8db4991626210708 - zh: e18c8aa3cb434980 + en: 5e6e6968cd896dd1 + zh: 879da107fc243b08 /chat-conversation-node-rules/display-modes: en: 42342a06ef189075 zh: 1a554b50125fcdda diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 12c63b5497..2032bca0df 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -78,7 +78,7 @@ The Turn control follows all its opening inputs, including human steering and no | Non-human message in an idle `next-step` claim | Turn-trigger notice only in Step 1, with a loaded Turn start, a claim after that start, no `next-turn` claim in this Turn, and no human in the same `next-step` batch. | | Other non-human input | Ordinary Context, retained in the Node Store but omitted from Chat. Unclaimed, canceled, or requeued messages do not establish a waking claim. | -Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution. +Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Schedule notices use the same plain clock icon as the Automation tasks entry. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution. | Turn condition | Current behavior | |---|---| diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 341c2826a1..26f87adeda 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -78,7 +78,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 空闲时从 `next-step` 领取的非人工消息 | 仅在 Step 1、已加载轮次起点、领取发生于起点之后、本轮没有 `next-turn` 领取、同一 `next-step` 批次没有人工消息时,作为轮次触发通知。 | | 其他非人工输入 | 普通 Context,保留在 Node Store,但不显示在 Chat 中。未领取、已取消或重新入队的消息不构成唤醒领取。 | -触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。 +触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。定时任务通知与「自动化任务」入口使用同一个普通时钟图标。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。 | 轮次条件 | 当前行为 | |---|---| diff --git a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx index 851111fd14..0fba10e47f 100644 --- a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNode } from '../src/client/contract/chat-nodes.ts' import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx' import { turnTriggerDetails } from '../src/client/chat/turn-trigger.ts' @@ -45,6 +46,15 @@ describe('Turn trigger notices', () => { expect(view.container.querySelector('time')?.dateTime).toBe('2023-11-14T22:13:20.000Z') }) + it('uses the same plain clock as the automation tasks entry', () => { + const node = trigger({ kind: 'schedule' }) + const view = render() + const reference = render() + expect(view.getByRole('button').querySelector('svg')?.outerHTML) + .toBe(reference.container.querySelector('svg')?.outerHTML) + expect(node.data.source).toEqual({ kind: 'schedule' }) + }) + it('opens the recorded notice body and closes it independently of the Turn', () => { const view = render() const button = view.getByRole('button') 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 9fafda562b..082ca33113 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -146,7 +146,7 @@ @keyframes dsh-plugin-highlight { 0%, 55% { - box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); } 100% { @@ -157,7 +157,7 @@ @media (prefers-reduced-motion: reduce) { .card[data-plugin-highlight] { animation: none; - box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); } } diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 3c4fb304f0..4238ee82c1 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -48,11 +48,11 @@ export const zh = { installTitle: '添加插件', installDescription: '输入插件的包名、GitHub 仓库地址或本地目录路径。', installSpecLabel: '包名或地址', - installSpecPlaceholder: '例如 @deepseek-ai/dsh-subagent-codex', + installSpecPlaceholder: '例如 dsh-plugin-whale-pet', installGuideToggle: '插件安装引导和示例', installGuideHide: '收起引导', installGuideIdTitle: '包名', - installGuideIdExample: '@deepseek-ai/dsh-subagent-codex', + installGuideIdExample: 'dsh-plugin-whale-pet', installGuideIdHint: '插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。', installGuideGitTitle: 'GitHub 仓库地址', installGuideGitExample: 'https://github.com/author/dsh-plugin', @@ -241,11 +241,11 @@ export const en = { installTitle: 'Add plugin', installDescription: 'Enter the plugin\'s package name, GitHub repository address, or local directory path.', installSpecLabel: 'Package name or address', - installSpecPlaceholder: 'for example @deepseek-ai/dsh-subagent-codex', + installSpecPlaceholder: 'for example dsh-plugin-whale-pet', installGuideToggle: 'Install guide and examples', installGuideHide: 'Hide the guide', installGuideIdTitle: 'Package name', - installGuideIdExample: '@deepseek-ai/dsh-subagent-codex', + installGuideIdExample: 'dsh-plugin-whale-pet', installGuideIdHint: 'The plugin package name is the npm package name (like dsh-xxx or @author/plugin): the part after dsh plugin add or pnpm add in a community plugin\'s README install command.', installGuideGitTitle: 'GitHub repository address', installGuideGitExample: 'https://github.com/author/dsh-plugin', 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 09303b9e7a..fc2c8cbfbe 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -735,6 +735,19 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name) }) + it.each([ + { locale: en, placeholder: 'for example dsh-plugin-whale-pet' }, + { locale: zh, placeholder: '例如 dsh-plugin-whale-pet' }, + ])('uses the package example in $placeholder and the install guide', ({ locale, placeholder }) => { + const { actions, setLanguage } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) + setLanguage(locale) + expect(screen.getByPlaceholderText(placeholder)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: locale.installGuideToggle })) + expect(screen.getByText('dsh-plugin-whale-pet')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: locale.installGuideFillAria.replace('{example}', 'dsh-plugin-whale-pet') })) + expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith('dsh-plugin-whale-pet') + }) + it('opens a guide under the field and drops an example into it', () => { const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) expect(screen.queryByText(en.installGuideIdHint)).toBeNull() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6203412c5d..0c472394e3 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: ddec1f6f9b018747 zh: 9555f132daf8901c /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 54ced5d959bd06c6 - zh: 5a7fc2504f92a788 + en: 4e1f3e827d55571e + zh: 6ccdd5dbae5c886d /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: 83052e14c3fb34cf zh: 0adb09885723296a diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c9f0cb4a5a..2359e442b5 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -66,7 +66,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. | -| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork; `Terminal` supplies the light-blue prompt in plugin cards and sidebar guide entries. `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | +| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork; `Terminal` supplies the muted cyan-to-blue prompt in plugin cards and sidebar guide entries. `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `GuideArtworkBrowser`/`Files` | Fixed-palette 36×36 browser and folder artwork for sidebar guide entries. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. | | `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The lazy line-token highlighter shared by code preview and diff review. The filename grammar selection is re-exported from `@deepseek-ai/dsh-util-code-language`, the single extension table also behind the read card's persisted short-id `lang` hint. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index de6adf1a2d..58c1742727 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -66,7 +66,7 @@ kind: "package-library" | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 | -| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 固定配色的 36×36 插件插画;`Terminal` 为插件卡片和侧边栏开始页入口提供浅蓝色提示符。`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | +| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 固定配色的 36×36 插件插画;`Terminal` 为插件卡片和侧边栏开始页入口提供柔和的青蓝渐变提示符。`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `GuideArtworkBrowser`/`Files` | 固定配色的 36×36 浏览器与文件夹插画,用于侧栏引导入口。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 | | `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的惰性逐行 token 高亮。文件名 grammar 选择再导出自 `@deepseek-ai/dsh-util-code-language`,即 read 卡片持久化短 id `lang` 提示背后的同一张扩展名表。 | diff --git a/packages/client/ui-primitives/src/plugin-artwork.tsx b/packages/client/ui-primitives/src/plugin-artwork.tsx index 374d99e138..a0df4ca4a8 100644 --- a/packages/client/ui-primitives/src/plugin-artwork.tsx +++ b/packages/client/ui-primitives/src/plugin-artwork.tsx @@ -12,13 +12,22 @@ import type { IconProps } from './icons/props.ts' */ const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}` -/** Light-blue terminal artwork shared by plugin cards and sidebar guide entries. */ -export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => ( - - - - -) +/** Muted cyan-to-blue terminal artwork shared by plugin cards and sidebar guides. */ +export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => { + const uid = useArtworkId() + return ( + + + + + + + + + + + ) +} /** Agent-loop plugin artwork (four leaves circling a center). */ export const PluginArtworkLoop = ({ size = 36, className }: IconProps) => { diff --git a/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx b/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx index e34d8555d2..fc25fa7507 100644 --- a/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx +++ b/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx @@ -36,6 +36,16 @@ describe('plugin artwork', () => { expect(container.querySelector('foreignObject div')).not.toBeNull() }) + it('uses a muted cyan-to-blue gradient for both terminal strokes', () => { + const { container } = render() + const gradient = container.querySelector('linearGradient')! + const colors = [...container.querySelectorAll('stop')].map(stop => stop.getAttribute('stop-color')) + expect(colors).toEqual(['#4DD9D4', '#6085EB']) + expect(gradient.getAttribute('gradientUnits')).toBe('userSpaceOnUse') + const strokes = [...container.querySelectorAll('path')].map(path => path.getAttribute('stroke')) + expect(strokes).toEqual([`url(#${gradient.id})`, `url(#${gradient.id})`]) + }) + it.each(Object.entries(artworks))('%s renders at 36 by default and repeats without document-global ids', (_name, Artwork) => { const { container } = render( <> @@ -46,8 +56,8 @@ describe('plugin artwork', () => { for (const svg of container.querySelectorAll('svg')) expect(svg.getAttribute('width')).toBe('36') const ids = [...container.querySelectorAll('[id]')].map(node => node.id) expect(new Set(ids).size).toBe(ids.length) - for (const reference of container.querySelectorAll('[fill^="url("], [clip-path^="url("]')) { - const target = (reference.getAttribute('fill') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1) + for (const reference of container.querySelectorAll('[fill^="url("], [stroke^="url("], [clip-path^="url("]')) { + const target = (reference.getAttribute('fill') ?? reference.getAttribute('stroke') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1) expect(ids).toContain(target) } }) From 16c30ed6ba2fad3318c5074bcf61280cb66b961a Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sat, 26 Sep 2026 00:50:42 +0800 Subject: [PATCH 02/35] fix(plugins): polish installation interactions and loading visuals --- .../plugin-config/official.expected.md | 2 +- .../invalid-registry.expected.md | 12 ++ .../plugin-manager/live-enabled.expected.md | 2 +- .../plugin-manager/loading.expected.md | 11 ++ .../plugin-manager/manager.expected.md | 2 +- .../schedule-after/conversation.expected.md | 2 + apps/web/tests/plugin-install-cancel.e2e.ts | 20 +++ apps/web/tests/plugin-manager.e2e.ts | 133 +++++++++++++++++- apps/web/tests/schedule-after.e2e.ts | 5 +- .../conversation-nodes/README.i18n.yaml | 4 +- .../src/client/conversation-nodes/README.md | 2 +- .../client/conversation-nodes/README.zh.md | 2 +- packages/client/ui-chat/src/client/locale.ts | 4 +- .../tests/turn-trigger.client.spec.tsx | 12 +- .../client/ui-plugin-manager/README.i18n.yaml | 8 +- packages/client/ui-plugin-manager/README.md | 4 +- .../client/ui-plugin-manager/README.zh.md | 4 +- .../src/client/PluginManagerPage.module.css | 75 +++++++--- .../src/client/PluginManagerPage.tsx | 81 ++++++++--- .../ui-plugin-manager/src/client/locales.ts | 4 +- .../src/client/manager-store.ts | 2 +- .../tests/components.client.spec.tsx | 79 ++++++++++- .../tests/manager-store.client.spec.ts | 2 +- packages/experimental/auto-review/icon.svg | 10 +- .../experimental/schedule-bundle/icon.svg | 16 +-- .../voice-input-bundle/locale/zh.json | 2 +- 26 files changed, 418 insertions(+), 82 deletions(-) create mode 100644 apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md create mode 100644 apps/web/tests/expected/plugin-manager/loading.expected.md diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 247d6ac308..eabd9f8198 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -19,7 +19,7 @@ - switch "启用 定时与时间上下文" - listitem: - button "查看 语音输入": 语音输入 - - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 + - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 - switch "启用 语音输入" - listitem: - button "查看 终端": 终端 diff --git a/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md b/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md new file mode 100644 index 0000000000..28e142d8ab --- /dev/null +++ b/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md @@ -0,0 +1,12 @@ +- radio "自定义地址" [checked] +- text: 自定义地址 +- textbox "自定义地址" [invalid]: + - /placeholder: https://npm.example.com/ + - text: invalid-registry +- alert: 请输入以 http:// 或 https:// 开头的地址 +- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。 + +{ + "outline": "none", + "boxShadow": "none" +} diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 4bd8ed064e..c39ec6b5f9 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -19,7 +19,7 @@ - switch "启用 定时与时间上下文" - listitem: - button "查看 语音输入": 语音输入 - - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 + - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 - switch "启用 语音输入" - listitem: - button "查看 终端": 终端 diff --git a/apps/web/tests/expected/plugin-manager/loading.expected.md b/apps/web/tests/expected/plugin-manager/loading.expected.md new file mode 100644 index 0000000000..467153a406 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/loading.expected.md @@ -0,0 +1,11 @@ +- heading "插件" [level=1] +- paragraph: 添加和管理插件 +- button "刷新" [disabled] +- button "添加插件" [disabled] +- status "正在读取插件…" + +1680px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px +1000px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px +Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders +Loaded: skeleton removed; header actions enabled +Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index b188be8787..342e22528f 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -19,7 +19,7 @@ - switch "启用 定时与时间上下文" - listitem: - button "查看 语音输入": 语音输入 - - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 + - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 - switch "启用 语音输入" - listitem: - button "查看 终端": 终端 diff --git a/apps/web/tests/expected/schedule-after/conversation.expected.md b/apps/web/tests/expected/schedule-after/conversation.expected.md index 995cfb9044..e91ea95ad2 100644 --- a/apps/web/tests/expected/schedule-after/conversation.expected.md +++ b/apps/web/tests/expected/schedule-after/conversation.expected.md @@ -1 +1,3 @@ +- text: Automation task + - paragraph: "Reminder: Check the deployment log." diff --git a/apps/web/tests/plugin-install-cancel.e2e.ts b/apps/web/tests/plugin-install-cancel.e2e.ts index db282c28c5..4b08f194b5 100644 --- a/apps/web/tests/plugin-install-cancel.e2e.ts +++ b/apps/web/tests/plugin-install-cancel.e2e.ts @@ -49,6 +49,26 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha // The dialog is named after its current screen, so it is found by role alone. const dialog = page.getByRole('dialog') await dialog.getByRole('textbox').fill('slow-package') + await dialog.getByRole('button', { name: /^安装源/ }).click() + const registryMenu = page.locator('[data-install-registry]') + const customAddress = registryMenu.getByRole('textbox', { name: '自定义地址', exact: true }) + await customAddress.fill('invalid-registry') + await page.keyboard.press('Escape') + await registryMenu.waitFor({ state: 'hidden' }) + await dialog.getByRole('button', { name: '安装', exact: true }).click() + await customAddress.waitFor({ state: 'visible' }) + await expect.poll(() => customAddress.evaluate(element => document.activeElement === element)).toBe(true) + expect(await customAddress.getAttribute('aria-invalid')).toBe('true') + const invalidStyle = await customAddress.evaluate((element) => { + const style = getComputedStyle(element) + return { outline: style.outlineStyle, boxShadow: style.boxShadow } + }) + expect(invalidStyle).toEqual({ outline: 'none', boxShadow: 'none' }) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/invalid-registry.expected.md', import.meta.url)), + `${await captureStableAria(page, '[data-install-registry] > [data-checked="true"]', scaffold.workspaceCwd)}\n\n${JSON.stringify(invalidStyle, null, 2)}`, webSnapshotMode()) + await registryMenu.getByRole('radio').first().check() + await page.keyboard.press('Escape') + await registryMenu.waitFor({ state: 'hidden' }) // Hold request delivery and the real Host's cancellation reply independently. // Cancellation reaches the Host before the install it names. const cancellationArrived = Promise.withResolvers() diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index cfa6a9a763..3069002de9 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -2,14 +2,15 @@ // managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero // model calls: everything is client state, seeded Session state, profile files, and the settings // document, so there is no fixture and a stray stream would fail loud on the open llm seam. -import { readFile } from 'node:fs/promises' +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { FiberState } from '@deepseek-ai/cordis' import { OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { join } from 'node:path' import { SCAFFOLD_DEFAULTS_BUNDLE, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -93,6 +94,130 @@ describe('web e2e: plugin manager', () => { return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '') } + it('aligns the first-read skeleton with the loaded plugin cards', async () => { + const facts: string[] = [] + let aria = '' + const shots = MODE === 'refresh' ? await mkdtemp(join(tmpdir(), 'dsh-plugin-loading-')) : undefined + for (const width of [1680, 1000]) { + const context = await browser.newContext({ viewport: { width, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const release = Promise.withResolvers() + const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager) + const reads: ReturnType[] = [] + const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles').mockImplementation(() => { + const read = release.promise.then(() => listBundles()) + reads.push(read) + return read + }) + try { + const probe = await context.newPage() + const consoleWatch = watchConsole(probe) + await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await probe.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + await expect.poll(() => spy.mock.calls.length, { timeout: 10_000 }).toBeGreaterThan(0) + const panel = probe.locator('[data-plugin-panel]') + const skeleton = panel.locator('[data-plugin-loading]') + await skeleton.waitFor() + await probe.evaluate(() => document.fonts.ready) + expect(await skeleton.locator(':scope > ul > li').count()).toBe(4) + expect(await skeleton.getAttribute('role')).toBe('status') + expect(await skeleton.getAttribute('aria-label')).toBe('正在读取插件…') + expect(await panel.getAttribute('aria-busy')).toBe('true') + const actions = panel.locator(':scope > header button') + expect(await actions.count()).toBe(2) + for (const action of await actions.all()) expect(await action.isDisabled()).toBe(true) + const loadingAria = await captureStableAria(probe, '[data-plugin-panel]', scaffold.workspaceCwd) + if (aria === '') aria = loadingAria + else expect(loadingAria).toBe(aria) + + const measure = (group: Locator) => group.evaluate((element) => { + const rect = (node: Element | null | undefined) => { + if (node === null || node === undefined) throw new Error('Missing plugin layout element') + const { x, y, width, height } = node.getBoundingClientRect() + return { x, y, width, height } + } + return { + pageHeader: rect(element.closest('[data-plugin-panel]')?.querySelector(':scope > header')), + groupHeader: rect(element.firstElementChild), + rows: Array.from(element.querySelectorAll(':scope > ul > li')).slice(0, 4).map((row) => { + const head = row.firstElementChild + const main = head?.children[1] + return { + row: rect(row), head: rect(head), icon: rect(head?.children[0]), main: rect(main), + titleRow: rect(main?.children[0]), title: rect(main?.children[0]?.firstElementChild), + description: rect(main?.children[1]), actions: rect(head?.children[2]), + } + }), + } + }) + const loading = await measure(skeleton) + const blankActions = await skeleton.locator(':scope > ul > li > div > div:last-child').evaluateAll(nodes => nodes.map(node => ({ + children: node.childElementCount, + background: getComputedStyle(node).backgroundColor, + width: node.getBoundingClientRect().width, + height: node.getBoundingClientRect().height, + }))) + expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 36, height: 20 }))) + if (shots !== undefined) { + for (const colorScheme of ['light', 'dark'] as const) { + await probe.emulateMedia({ colorScheme }) + const path = join(shots, `loading-${width}-${colorScheme}.png`) + await panel.screenshot({ path, animations: 'disabled' }) + console.log(`Plugin loading screenshot: ${path}`) + } + } + const motion = await skeleton.evaluate(element => element.getAnimations({ subtree: true }).map(animation => ({ + duration: animation.effect?.getTiming().duration, + opacityOnly: animation.effect instanceof KeyframeEffect + && animation.effect.getKeyframes().every(frame => frame.opacity !== undefined && frame.transform === undefined), + }))) + expect(motion.length).toBe(13) + expect(motion.every(animation => animation.duration === 2000 && animation.opacityOnly)).toBe(true) + await probe.emulateMedia({ reducedMotion: 'reduce' }) + await expect.poll(() => skeleton.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0) + await probe.emulateMedia({ reducedMotion: 'no-preference', colorScheme: null }) + + release.resolve(undefined) + await skeleton.waitFor({ state: 'detached' }) + expect(await panel.getAttribute('aria-busy')).toBe('false') + for (const action of await actions.all()) expect(await action.isDisabled()).toBe(false) + const official = panel.locator('[data-plugin-group="official"]') + await official.locator('[data-plugin-package]').first().waitFor() + const loaded = await measure(official) + expect(loaded.rows).toHaveLength(4) + const compare = (name: string, a: typeof loading.pageHeader, b: typeof loaded.pageHeader, axes: readonly (keyof typeof a)[] = ['x', 'y', 'width', 'height']) => { + for (const axis of axes) { + expect(Math.abs(a[axis] - b[axis]), `${width}px ${name}.${axis}: loading=${a[axis]}, loaded=${b[axis]}`).toBeLessThanOrEqual(0.1) + } + } + compare('pageHeader', loading.pageHeader, loaded.pageHeader) + compare('groupHeader', loading.groupHeader, loaded.groupHeader) + for (const [index, row] of loading.rows.entries()) { + const real = loaded.rows[index]! + for (const part of ['row', 'head', 'icon', 'main', 'titleRow', 'actions'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part]) + // Painted text bars are deliberately shorter than real copy; their line origins and heights align. + for (const part of ['title', 'description'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part], ['x', 'y', 'height']) + } + facts.push(`${width}px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px`) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + release.resolve(undefined) + spy.mockRestore() + try { + await context.close() + } finally { + await Promise.allSettled(reads) + } + } + } + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'loading.expected.md'), [ + aria, '', ...facts, + 'Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders', + 'Loaded: skeleton removed; header actions enabled', + 'Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses', + ].join('\n'), MODE) + }) + it('starts with an unavailable selected bundle and lets the user clear its error', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle')) const panel = await openPluginsPanel() @@ -383,7 +508,7 @@ describe('web e2e: plugin manager', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { 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', + 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', ]) }) }) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index c0f131f3d3..805dd51c07 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -462,9 +462,12 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { await row.waitFor({ timeout: 15_000 }) expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step') expect(await row.textContent()).toContain(AFTER_REPLY) + const triggerTitle = '[data-turn-trigger] > button > span:nth-child(2)' + await page.locator(triggerTitle).waitFor() + expect(await page.locator(triggerTitle).textContent()).toBe('Automation task') await compareOrRefreshGolden( AFTER_EXPECTED, - await captureStableAria(page, selector, scaffold.workspaceCwd), + `${await captureStableAria(page, triggerTitle, scaffold.workspaceCwd)}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`, MODE, ) await expectNoReminderEntry(page) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 52d08c5590..b1d330fd75 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -24,8 +24,8 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 5e6e6968cd896dd1 - zh: 879da107fc243b08 + en: 70e98703051fbfe1 + zh: cbeeb922dabb2712 /chat-conversation-node-rules/display-modes: en: 42342a06ef189075 zh: 1a554b50125fcdda diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 2032bca0df..6b1d58ca1c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -78,7 +78,7 @@ The Turn control follows all its opening inputs, including human steering and no | Non-human message in an idle `next-step` claim | Turn-trigger notice only in Step 1, with a loaded Turn start, a claim after that start, no `next-turn` claim in this Turn, and no human in the same `next-step` batch. | | Other non-human input | Ordinary Context, retained in the Node Store but omitted from Chat. Unclaimed, canceled, or requeued messages do not establish a waking claim. | -Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Schedule notices use the same plain clock icon as the Automation tasks entry. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution. +Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Schedule notices are labeled Automation task and use the same plain clock icon as the Automation tasks entry. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution. | Turn condition | Current behavior | |---|---| diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 26f87adeda..91f59f7b91 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -78,7 +78,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 空闲时从 `next-step` 领取的非人工消息 | 仅在 Step 1、已加载轮次起点、领取发生于起点之后、本轮没有 `next-turn` 领取、同一 `next-step` 批次没有人工消息时,作为轮次触发通知。 | | 其他非人工输入 | 普通 Context,保留在 Node Store,但不显示在 Chat 中。未领取、已取消或重新入队的消息不构成唤醒领取。 | -触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。定时任务通知与「自动化任务」入口使用同一个普通时钟图标。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。 +触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。定时任务通知显示为「自动化任务」,与「自动化任务」入口使用同一个普通时钟图标。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。 | 轮次条件 | 当前行为 | |---|---| diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 53a4999b11..4e19bf91fd 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -57,7 +57,7 @@ export const zh = { 'message.trigger.subagent': '子任务状态更新', 'message.trigger.github': '收到 GitHub 事件', 'message.trigger.webhook': '收到外部事件', - 'message.trigger.schedule': '定时任务', + 'message.trigger.schedule': '自动化任务', 'message.trigger.job': '后台任务状态更新', 'message.trigger.plugin': '插件状态更新', 'message.trigger.explanation': '这条通知触发了本轮回复。', @@ -250,7 +250,7 @@ export const en = { 'message.trigger.subagent': 'Subtask status updated', 'message.trigger.github': 'GitHub event received', 'message.trigger.webhook': 'External event received', - 'message.trigger.schedule': 'Scheduled task', + 'message.trigger.schedule': 'Automation task', 'message.trigger.job': 'Background task updated', 'message.trigger.plugin': 'Plugin status updated', 'message.trigger.explanation': 'This notification triggered this response.', diff --git a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx index 0fba10e47f..3b0ac5596c 100644 --- a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx @@ -7,7 +7,7 @@ import type { ChatNode } from '../src/client/contract/chat-nodes.ts' import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx' import { turnTriggerDetails } from '../src/client/chat/turn-trigger.ts' import { contextForm, contextProducer } from '../src/client/conversation-nodes/event-projection.ts' -import { en } from '../src/client/locale.ts' +import { en, zh } from '../src/client/locale.ts' afterEach(cleanup) @@ -25,7 +25,7 @@ function trigger(source: unknown): ChatNode<'turn-trigger'> { describe('Turn trigger notices', () => { it.each([ - [{ kind: 'schedule' }, 'schedule', 'Scheduled task'], + [{ kind: 'schedule' }, 'schedule', 'Automation task'], [{ kind: 'tool-jobs' }, 'job', 'Background task updated'], [{ kind: 'cordis-host-runner' }, 'plugin', 'Plugin status updated'], [{ kind: 'goal' }, 'goal', 'Continuing goal'], @@ -46,9 +46,13 @@ describe('Turn trigger notices', () => { expect(view.container.querySelector('time')?.dateTime).toBe('2023-11-14T22:13:20.000Z') }) - it('uses the same plain clock as the automation tasks entry', () => { + it.each([ + { locale: en, title: 'Automation task' }, + { locale: zh, title: '自动化任务' }, + ])('uses the automation task label $title and shared clock', ({ locale, title }) => { const node = trigger({ kind: 'schedule' }) - const view = render() + const view = render() + expect(view.getByRole('button').textContent).toContain(title) const reference = render() expect(view.getByRole('button').querySelector('svg')?.outerHTML) .toBe(reference.container.querySelector('svg')?.outerHTML) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 893f4e8405..9eaa2361f2 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: ca2748d14de42df7 - zh: 51225f56acb1bada + en: 263cf8b4c89b9550 + zh: cebfb0cc84953e44 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: 130b84453fcb0f8c - zh: f03976813657b35b + en: 4816e6ab0ee26abe + zh: b16bb7cdfacd6c74 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: abc90531efa889d5 zh: 0562570f85e69c35 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 09580f5cbe..c51da00e2f 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -29,13 +29,15 @@ Select **Plugins** in the sidebar. The page reads the inventory and the bundles 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` at that plugin address; 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 image declared by their own `package.json.icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. +The first list read shows a short Official-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states. + ### Installing a bundle On first use, when pnpm uses the unconfigured official npm registry and npmmirror is offered, the Host probes both registries in parallel. The first successful HTTPS ping response selects the initial source. The dialog preserves remembered choices, manual selections, explicit manager configuration, and custom or unknown pnpm registries. An install clicked during initial probing waits for that bounded operation; a late result cannot overwrite a manual choice or reopen a closed dialog. The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved. -**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque shared menu surface, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry menu, and focuses the address with one error-colored border. They float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 93f764df98..09e717a1fd 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -29,13 +29,15 @@ kind: "package-reference" 已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示各自 `package.json.icon` 声明的图片;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 +首次读取列表时,页面显示一条较短的「官方」标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 + ### 安装一个组合包 首次使用时,如果未显式配置安装源、pnpm 使用官方 npm 源,且列表提供 npmmirror,Host 会并发探测这两个源,选择最先成功响应 HTTPS ping 的源。已记住的选择、用户手选、管理器显式配置,以及自定义或未知的 pnpm 源均保留。在初次探测期间点击安装会等待这次有时限的操作;迟到结果不会覆盖手选或重新打开已关闭的对话框。 Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。 -**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明的共享菜单面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源菜单并聚焦地址框,仅显示错误色边框。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。 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 082ca33113..37ff54cc9f 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -293,6 +293,46 @@ overflow: hidden; } +/* Match Session search's pulse without coupling the two feature packages. */ +.skeletonFill { + background: var(--dsw-alias-bg-skeleton); + animation: dsh-plugin-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; +} + +/* Keep each real text line's height; center the shorter painted bar within it. */ +.skeletonText { + display: flex; + align-items: center; + height: 1lh; +} + +.skeletonBar { + width: 100%; + height: 12px; + border-radius: var(--dsw-radius-xs); +} + +.skeletonHeading { width: 48px; } +.skeletonTitle { width: min(144px, 60%); } +.skeletonDescription { width: min(280px, 85%); } +.skeletonIcon { border-color: transparent; } + +/* Keep text-column metrics while leaving the action area blank. */ +.skeletonActions { + width: 36px; + height: 20px; +} + +@keyframes dsh-plugin-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .skeletonFill { animation: none; } +} + .cardEnd { position: relative; z-index: 1; @@ -462,13 +502,16 @@ color: var(--dsw-alias-label-primary); } -.installField input[type='text']:focus { +.installField input[type='text']:focus, +.registryCustomField:focus { border-color: var(--dsw-alias-state-business-primary); box-shadow: inset 0 0 0 0.5px var(--dsw-alias-state-business-primary); } -.installField input[aria-invalid='true'] { +.installField input[type='text'][aria-invalid='true'], +.registryCustomField[aria-invalid='true'] { border-color: var(--dsw-alias-state-error-primary); + box-shadow: none; } /* The guide under the field: opened by a text button, it says what an ID is and shows the three forms with examples. */ @@ -610,10 +653,10 @@ display: inline-flex; align-items: center; gap: 6px; - height: 26px; + height: 28px; padding: 0 8px 0 10px; border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: var(--dsw-radius-sm); + border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-2); font: inherit; font-size: 12.5px; @@ -655,12 +698,12 @@ box-sizing: border-box; width: min(440px, calc(100vw - 24px)); margin: 0; - padding: 8px 10px 12px; + padding: 8px; border: 0; - border-radius: var(--dsw-radius-lg); - background: var(--dsw-alias-bg-layer-2); - /* The neutral stroke is the elevation's hairline, not a border beside it. */ - --dsw-elevation-stroke-color: var(--dsw-alias-border-l3); + /* Keep an opaque surface behind the registry form controls. */ + --dsw-menu-surface-fill: var(--dsw-alias-bg-layer-2); + --dsw-menu-backdrop-filter: none; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); min-width: 0; } @@ -670,14 +713,13 @@ flex-direction: column; gap: 4px; padding: 8px 10px; - border: 0.5px solid transparent; - border-radius: var(--dsw-radius-lg); + border: 0; + border-radius: var(--dsw-radius-md); cursor: pointer; } -.registryOption[data-checked='true'] { - border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 40%, transparent); - background: var(--dsw-alias-bg-layer-2); +.registryOption:hover { + background: var(--dsw-alias-interactive-bg-hover); } /* An offered registry is one row: the radio beside its title and the line under it. */ @@ -720,6 +762,7 @@ label.registryOption { } .registryCustomField { + outline: none; height: 32px; margin-left: 26px; padding: 0 10px; @@ -731,10 +774,6 @@ label.registryOption { color: var(--dsw-alias-label-primary); } -.registryCustomField[aria-invalid='true'] { - border-color: var(--dsw-alias-state-error-primary); -} - /* The typed address's field, refusal, and line sit under its title, past the radio and its gap. */ .registryOption > .inputError, .registryOption > .registryHint { diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 183ed40fae..18b694db82 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -16,7 +16,7 @@ import { Button, IconCheckCircleFillRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineMedium, IconChevronRightOutlineRegular, IconCloseOutlineMedium, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, - IconWarningOutlineRegular, Input, Modal, + IconWarningOutlineRegular, Input, MenuSurface, Modal, PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, @@ -323,6 +323,39 @@ function CardHead({ title, t, onOpen, icon, tags, description, end }: { ) } +/** First-read placeholders share the Official group's card and text-line layout. */ +function ListSkeleton({ label }: { readonly label: string }): ReactNode { + return ( +
+ + +
+ ) +} + /** The top every page shares: the crumb that leads back, then the icon with the page's actions at its right. */ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { readonly crumbLabel: string @@ -681,10 +714,10 @@ function registryList(registries: readonly Registry[], t: Translate, resolved: s return registries.map(registry => registryText(registry, t, resolved).name).join(t('registryListSeparator')) } -/** An option's label: the registry's name with the host it names, unless the host is the name. */ -function registryOption(registry: Registry, t: Translate, resolved: string | null): string { +/** An option's name with its host in tertiary text, unless the host is the name. */ +function registryOption(registry: Registry, t: Translate, resolved: string | null): ReactNode { const { name, host } = registryText(registry, t, resolved) - return name === host ? name : t('registryWithHost', { name, host }) + return name === host ? name : <>{name}{' '}{host} } /** @@ -757,15 +790,21 @@ function InstallDialog({ const registryId = useId() const registryErrorId = useId() const [guideOpen, setGuideOpen] = useState(false) + const [customRegistryDraft, setCustomRegistryDraft] = useState('') const { phase } = install // The registry options float over the dialog from their toggle, so unfolding them never adds to its height; // the store folds them when a run starts, so they show at the spec only. const registryToggleRef = useRef(null) - const registryPanelRef = useRef(null) + const registryPanelRef = useRef(null) + const registryCustomRef = useRef(null) const registryShown = install.registryOpen && phase === 'idle' const registryPosition = useAnchoredPosition({ open: registryShown, anchorRef: registryToggleRef, panelRef: registryPanelRef, align: 'end', gap: 6, margin: 12, }) + const registryReady = registryShown && registryPosition !== null + useEffect(() => { + if (registryReady && install.registryError) registryCustomRef.current?.focus() + }, [registryReady, install.registryError]) // The hook only ever asks to close. useDismissOnOutsidePointer(registryToggleRef, registryShown, onToggleRegistry, registryPanelRef) useEffect(() => { @@ -918,8 +957,9 @@ function InstallDialog({ : null} {registryShown ? createPortal( -
- { onChooseRegistry({ kind: 'offered', registry }) }} /> - {registryOption(registry, t, resolved)} + { + if (choice.kind === 'custom') setCustomRegistryDraft(choice.url) + onChooseRegistry({ kind: 'offered', registry }) + }} /> + {registryOption(registry, t, resolved)} ) })} -
+
{ + // Keep label activation from moving focus back to the radio. + if (!(event.target instanceof HTMLInputElement)) event.preventDefault() + registryCustomRef.current?.focus() + }}> { if (choice.kind !== 'custom') onChooseRegistry({ kind: 'custom', url: customRegistryDraft }) }} onChange={(event) => { onChooseRegistry({ kind: 'custom', url: event.currentTarget.value }) }} onKeyDown={(event) => { if (event.key === 'Enter' && !empty) onRun() }} /> @@ -962,7 +1011,7 @@ function InstallDialog({ : null} {t('registryCustomHint')}
-
, + , document.body, ) : null} @@ -1243,11 +1292,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ) : null} - {showsCards && state.status === 'loading' ? ( -

- {t('loading')} -

- ) : null} + {showsCards && state.status === 'loading' ? : null} {showsCards && state.status === 'unavailable' ? (

{t('unavailable')} diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 4238ee82c1..550391f769 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -72,7 +72,7 @@ export const zh = { registryWithHost: '{name}({host})', registryCustom: '自定义地址', registryCustomPlaceholder: 'https://npm.example.com/', - registryCustomHint: '公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里', + registryCustomHint: '填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。', registryCustomInvalid: '请输入以 http:// 或 https:// 开头的地址', registryListSeparator: '、', installRun: '安装', @@ -265,7 +265,7 @@ export const en = { registryWithHost: '{name} ({host})', registryCustom: 'Custom address', registryCustomPlaceholder: 'https://npm.example.com/', - registryCustomHint: 'A company or private npm registry; for one that needs a login, keep the credentials in ~/.npmrc on this machine', + registryCustomHint: 'Enter an internal or private npm registry address starting with http:// or https://. If it requires a login, store the credentials in ~/.npmrc on this machine.', registryCustomInvalid: 'Enter an address starting with http:// or https://', registryListSeparator: ', ', installRun: 'Install', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index ab1c3aa038..59a449cfab 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -787,7 +787,7 @@ export class PluginManagerController { const choice = install.registry const typed = choice.kind === 'custom' ? choice.url.trim() : undefined if (typed !== undefined && !REGISTRY_URL.test(typed)) { - this.patchInstall({ phase: 'idle', registryError: true }) + this.patchInstall({ phase: 'idle', registryError: true, registryOpen: true }) return } this.abortInspect() 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 fc2c8cbfbe..fe2cb8db84 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -54,9 +54,9 @@ const OFFICIAL = 'https://registry.npmjs.org/' const REGISTRIES = { registry: null, fallbackRegistries: [MIRROR], resolved: OFFICIAL } /** pnpm's own registry as the options label it: by npm's own name once read, the neutral default name until then. */ -const OFFICIAL_OPTION = en.registryWithHost.replace('{name}', en.registryOfficial).replace('{host}', 'registry.npmjs.org') -const UNREAD_OPTION = en.registryWithHost.replace('{name}', en.registryDefault).replace('{host}', 'registry.npmjs.org') -const MIRROR_OPTION = en.registryWithHost.replace('{name}', en.registryNpmmirror).replace('{host}', 'registry.npmmirror.com') +const OFFICIAL_OPTION = `${en.registryOfficial} registry.npmjs.org` +const UNREAD_OPTION = `${en.registryDefault} registry.npmjs.org` +const MIRROR_OPTION = `${en.registryNpmmirror} registry.npmmirror.com` const IDLE_INSTALL: InstallState = { open: false, spec: '', registries: null, registry: { kind: 'offered', registry: null }, registryOpen: false, registryError: false, attempts: null, @@ -203,9 +203,14 @@ describe('PluginManagerPage', () => { it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => { const { actions, set } = renderTab({ status: 'loading' }) expect(actions.ensure).toHaveBeenCalledTimes(1) - expect(screen.getByText(en.loading).querySelector('[data-state="ongoing"]')).not.toBeNull() + const loading = screen.getByRole('status', { name: en.loading }) + expect(loading.querySelectorAll('li')).toHaveLength(4) + expect(loading.querySelector('ul')?.getAttribute('aria-hidden')).toBe('true') + expect(loading.querySelector('button, input, [data-state="ongoing"]')).toBeNull() + expect(document.querySelector('[data-plugin-panel]')?.getAttribute('aria-busy')).toBe('true') expect(screen.getByRole('button', { name: en.addPlugin })).toHaveProperty('disabled', true) set({ status: 'unavailable' }) + expect(screen.queryByRole('status', { name: en.loading })).toBeNull() expect(screen.getByRole('status').querySelector('[data-state="idle"]')).not.toBeNull() set({ status: 'error' }) expect(screen.getByRole('alert').querySelector('[data-state="error"]')).not.toBeNull() @@ -219,6 +224,22 @@ describe('PluginManagerPage', () => { expect(actions.openInstall).toHaveBeenCalledTimes(1) }) + it('shows skeletons only while loading and keeps ready cards during refresh and switching', () => { + const { actions, set } = renderTab({ status: 'idle' }) + expect(screen.queryByRole('status', { name: en.loading })).toBeNull() + set({ status: 'loading' }) + expect(screen.getByRole('status', { name: en.loading })).toBeTruthy() + set({ status: 'ready', packages: [pkg()] }) + expect(screen.queryByRole('status', { name: en.loading })).toBeNull() + expect(document.querySelector('[data-plugin-panel]')?.getAttribute('aria-busy')).toBe('false') + fireEvent.click(screen.getByRole('button', { name: en.refresh })) + expect(actions.refresh).toHaveBeenCalledTimes(1) + set({ busy: ['dsh-better-sidebar'] }) + expect(screen.queryByRole('status', { name: en.loading })).toBeNull() + expect(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })).toBeTruthy() + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true) + }) + it('keeps the read failure and its retry visible while a detail page is open', () => { const { actions, set } = renderTab({ packages: [pkg()] }) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) @@ -1210,6 +1231,56 @@ describe('PluginManagerPage', () => { } }) + it('selects the custom registry from its whole area and focuses the address without clearing it', () => { + const open = { ...IDLE_INSTALL, open: true, registryOpen: true, registries: REGISTRIES } + const { actions, set } = renderTab({ install: open }) + const field = screen.getByRole('textbox', { name: en.registryCustom }) + const label = screen.getByRole('radio', { name: en.registryCustom }).parentElement! + const area = label.parentElement! + expect(field).toHaveProperty('disabled', false) + for (const target of [area, label, screen.getByText(en.registryCustomHint)]) { + field.blur() + set({ install: open }) + actions.chooseRegistry.mockClear() + fireEvent.click(target) + expect(actions.chooseRegistry).toHaveBeenCalledExactlyOnceWith({ kind: 'custom', url: '' }) + expect(document.activeElement).toBe(field) + } + set({ install: { ...open, registry: { kind: 'custom', url: 'https://npm.corp/' } } }) + field.blur() + actions.chooseRegistry.mockClear() + field.focus() + fireEvent.click(field) + fireEvent.click(area) + expect(document.activeElement).toBe(field) + expect(field).toHaveProperty('value', 'https://npm.corp/') + expect(actions.chooseRegistry).not.toHaveBeenCalled() + field.blur() + fireEvent.click(screen.getByRole('radio', { name: OFFICIAL_OPTION })) + set({ install: open }) + expect(field).toHaveProperty('value', 'https://npm.corp/') + fireEvent.click(screen.getByRole('radio', { name: MIRROR_OPTION })) + set({ install: { ...open, registry: { kind: 'offered', registry: MIRROR } } }) + fireEvent.click(area) + expect(actions.chooseRegistry).toHaveBeenLastCalledWith({ kind: 'custom', url: 'https://npm.corp/' }) + expect(document.activeElement).toBe(field) + }) + + it('focuses an invalid custom address when submission opens its registry menu', () => { + const open = { ...IDLE_INSTALL, open: true, spec: 'dsh-new', registry: { kind: 'custom' as const, url: 'invalid' } } + const { actions, set } = renderTab({ install: open }) + fireEvent.click(screen.getByRole('button', { name: en.installRun, exact: true })) + expect(actions.runInstall).toHaveBeenCalledOnce() + set({ install: { ...open, registryOpen: true, registryError: true } }) + const field = screen.getByRole('textbox', { name: en.registryCustom }) + expect(document.activeElement).toBe(field) + expect(field.getAttribute('aria-invalid')).toBe('true') + expect(screen.getByRole('alert').textContent).toBe(en.registryCustomInvalid) + fireEvent.keyDown(field, { key: 'Escape' }) + expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce() + expect(actions.closeInstall).not.toHaveBeenCalled() + }) + it('offers the registries under the spec, folded by default, and picks or types one', () => { const open = { ...IDLE_INSTALL, open: true, registries: REGISTRIES } const { actions, set } = renderTab({ install: open }) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 0cd5683e0e..6d852dfb18 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -1159,7 +1159,7 @@ describe('PluginManagerController', () => { face.editInstallSpec('dsh-new') face.chooseRegistry({ kind: 'custom', url: ' npm.corp.example ' }) face.runInstall() - expect(state().install).toMatchObject({ phase: 'idle', registryError: true }) + expect(state().install).toMatchObject({ phase: 'idle', registryError: true, registryOpen: true }) expect(plugins.inspect).not.toHaveBeenCalled() // Typing again clears the refusal; a URL is asked as typed, trimmed. face.chooseRegistry({ kind: 'custom', url: ' https://npm.corp.example ' }) diff --git a/packages/experimental/auto-review/icon.svg b/packages/experimental/auto-review/icon.svg index 352561fe59..e278e508fe 100644 --- a/packages/experimental/auto-review/icon.svg +++ b/packages/experimental/auto-review/icon.svg @@ -1,10 +1,12 @@ - - + + + + - - + + diff --git a/packages/experimental/schedule-bundle/icon.svg b/packages/experimental/schedule-bundle/icon.svg index 4299a86395..9b778ac2c9 100644 --- a/packages/experimental/schedule-bundle/icon.svg +++ b/packages/experimental/schedule-bundle/icon.svg @@ -1,11 +1,9 @@ - - - - - - - - - + + + + + + + diff --git a/packages/experimental/voice-input-bundle/locale/zh.json b/packages/experimental/voice-input-bundle/locale/zh.json index 43b2e83408..3ad24e67b3 100644 --- a/packages/experimental/voice-input-bundle/locale/zh.json +++ b/packages/experimental/voice-input-bundle/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { "title": "语音输入", - "description": "在本机使用 SenseVoice 转写录音;首次使用需安装依赖" + "description": "在本机使用 SenseVoice 转写录音,首次使用需安装依赖。" } } From ace2e5b3052c60ee320a691ae0b58a98d1ebf83a Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sat, 26 Sep 2026 01:11:16 +0800 Subject: [PATCH 03/35] test(plugins): use supported role query options --- .../client/ui-plugin-manager/tests/components.client.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fe2cb8db84..70cfb1b4d6 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -1269,7 +1269,7 @@ describe('PluginManagerPage', () => { it('focuses an invalid custom address when submission opens its registry menu', () => { const open = { ...IDLE_INSTALL, open: true, spec: 'dsh-new', registry: { kind: 'custom' as const, url: 'invalid' } } const { actions, set } = renderTab({ install: open }) - fireEvent.click(screen.getByRole('button', { name: en.installRun, exact: true })) + fireEvent.click(screen.getByRole('button', { name: en.installRun })) expect(actions.runInstall).toHaveBeenCalledOnce() set({ install: { ...open, registryOpen: true, registryError: true } }) const field = screen.getByRole('textbox', { name: en.registryCustom }) From dc8508dc3ba27e1392d5b6c732ca1c5100051c8e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:17:01 +0800 Subject: [PATCH 04/35] test(web): pin conversation clocks to the fixture day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser lane pins the browser to Asia/Shanghai, and formatMessageClock prefixes clock.md / clock.ymd to the clock once a message's local calendar day differs from the renderer's. Four scenarios left both clocks on the wall clock while their message times are stamped during the run — a live composer send by the Host, a `createdAt: 0` seed anchored from Date.now() — so the job that began six minutes before local midnight rendered `9/25 23:58` where their goldens record `{{clock}}` (run 36157253018, job 108144715986: steering, markdown-images, message-actions, and reference-composer failed together, then passed in the next run). Read WEB_FIXTURE_TIME on both clocks instead. Live-stamped steering suites install pinHostClock(), a Date.now spy that advances from the anchor with real elapsed time; pinBrowserClock() re-applies one frozen instant from the anchor on an interval, because holding a single instant removes elapsed time from the 150 ms process-title hold and the composer's 10 ms composition guard, which then never elapse. Seeded scenarios anchor their seed beside the pinned browser clock, which keeps the relative-age buckets their goldens assert. The steering mid capture waits for the settled question title its golden records, and the whole-queue scenario waits for the running turn before its queue gestures, whose decision resolves against an agent that reports busy. Goldens, the Asia/Shanghai browser timezone, and product rendering are unchanged. --- ...b-conversation-clock-fixture-day.i18n.yaml | 22 ++++++++ ...9-26-web-conversation-clock-fixture-day.md | 47 ++++++++++++++++ ...6-web-conversation-clock-fixture-day.zh.md | 47 ++++++++++++++++ apps/web/tests/markdown-images.e2e.ts | 8 ++- apps/web/tests/message-actions.e2e.ts | 11 +++- apps/web/tests/reference-composer.e2e.ts | 7 ++- apps/web/tests/steering.e2e.ts | 44 ++++++++++++--- apps/web/tests/support.ts | 53 ++++++++++++++++++- 8 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md create mode 100644 .agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.zh.md diff --git a/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.i18n.yaml b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.i18n.yaml new file mode 100644 index 0000000000..8f91e16be8 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-26-web-conversation-clock-fixture-day.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md +/agent-note-web-conversation-clocks-on-the-fixture-day: + en: 13b5d9d0bb3626af + zh: 0bd01e1848284a35 +/agent-note-web-conversation-clocks-on-the-fixture-day/problem: + en: 6f334488a6d3a28d + zh: b33aed9b10d0cd84 +/agent-note-web-conversation-clocks-on-the-fixture-day/decision: + en: ba875c82795d58e1 + zh: 13a7df6f8c4e0aa6 +/agent-note-web-conversation-clocks-on-the-fixture-day/alternatives-considered: + en: 9c0a2afb1473c1d1 + zh: 36c9e72a70072f5e +/agent-note-web-conversation-clocks-on-the-fixture-day/verification: + en: 5031ca866fed24dd + zh: c11f71af70c1f5ec +/agent-note-web-conversation-clocks-on-the-fixture-day/consequences: + en: c5cdbbd3b2889df7 + zh: 8458ef92443cf1a6 diff --git a/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md new file mode 100644 index 0000000000..8b572d43f3 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md @@ -0,0 +1,47 @@ +# Agent Note: Web conversation clocks on the fixture day + +Status: implemented + +English | [中文](2026-09-26-web-conversation-clock-fixture-day.zh.md) + +## Problem + +Run 36157253018 (job 108144715986, `node 24 / snapshots and artifacts`) failed four browser scenarios in one pass — [steering](../../../../apps/web/tests/steering.e2e.ts), [markdown-images](../../../../apps/web/tests/markdown-images.e2e.ts), [message-actions](../../../../apps/web/tests/message-actions.e2e.ts), and [reference-composer](../../../../apps/web/tests/reference-composer.e2e.ts) — with one aria difference: a message rendered `… and stop. 9/25 23:58` where its golden records `… and stop. {{clock}}`. + +[`newEnglishPage`](../../../../apps/web/tests/support.ts) pins the browser to `Asia/Shanghai`, and [`formatMessageClock`](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) prefixes `clock.md` / `clock.ymd` to the clock as soon as the message's local calendar day or year differs from the renderer's. These scenarios left both clocks on the wall clock, and their message times are stamped during the run: a live composer send by the Host, a seed whose fixture header carries `createdAt: 0` anchored at `Date.now() - 60_000` by `seedSession`. The failing job started at 15:54Z, six minutes before local midnight, so rows stamped before it were captured after it and rendered a date prefix; the next run of the same scenario passed minutes after midnight. + +The lane already carried the remedy — [`WEB_FIXTURE_TIME`](../../../../apps/web/tests/support.ts), documented as the same-day anchor for seeded event times and the `Asia/Shanghai` browser clock, and used by seven other scenarios — but these four scenarios never adopted it. + +## Decision + +The four scenarios read the fixture day on both clocks. + +Live-stamped scenarios pin the Host clock with `pinHostClock()`, a `Date.now` spy advancing from the anchor with real elapsed time, and the browser clock with [`pinBrowserClock(page)`](../../../../apps/web/tests/support.ts). Seeded scenarios anchor the seed through `seedSession`'s `createdAt` argument and pin the browser clock the same way. + +The seed anchor is not only the rendered clock: the sidebar's relative ages are computed against the browser clock, so a pinned browser with a live-anchored seed reads `8mo` where the [message-actions](../../../../apps/web/tests/message-actions.e2e.ts) fork golden asserts `now` and `1min`. + +`pinBrowserClock` re-applies one frozen instant from the anchor on an interval instead of leaving it at a single value, because product code reads two `Date.now()` values to decide how long a rendered value is held: the step process keeps its previous title for `PROCESS_TITLE_MINIMUM_MS` = 150 ms ([ChatGroupSeat](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx)) and the composer keymap keeps a composition guard 10 ms past `compositionend` ([keymap](../../../../packages/client/ui-conversation/src/client/input/editor/keymap.ts)). Both compute the remaining hold as `Date.now() - previousReading`, which a permanently frozen clock never advances, so the hold never elapses and the golden's settled text stays unrendered. CI run 36179748541 (job 108218939163) caught exactly that on the first revision that froze the clock: `Preparing questions` where the steering mid golden records `Waiting for your action · Ready to continue?`. + +The steering mid capture waits for that settled question title before it captures, and the [whole-queue scenario](../../../../apps/web/tests/steering.e2e.ts) calls the queue gesture only after the running turn's Stop control appears, so Enter's queue decision always resolves against an agent that already reports busy. + +## Alternatives considered + +**Collapse the rendered date prefix in the aria normalizer.** Collapsing `9/25 23:58` to `{{clock}}` in `normalizeAria` fixes the four scenarios with a two-line change, and it was measured: it passed the four files and then failed `cordis-tool-round`, `stats-paged-history`, and `navigation-panes` in one `DSH_WEB_SNAPSHOT_WORKERS=16` lane run, because their goldens deliberately record the dated form (`9/1 {{clock}}` and the year form in the Timing pane), and `skill-tool-row` already tokenizes that form as `{{date}} {{clock}}`. The two renderings state different facts — this row is from another day, this row is from today — so collapsing them deletes assertions instead of stabilizing them. + +**Leave the browser clock frozen.** Pinning one instant for the whole scenario is simpler and passed the four files, but it removes elapsed time from every two-reading hold in the page, which is how the `Preparing questions` capture above happened. Re-applying the anchor keeps the calendar pinned without changing how long a held value is held. + +**Pin the clocks for the whole lane.** Pinning inside `launchWebScaffold` and `newEnglishPage` would cover every scenario at once, including the ones that fail this way in future. It loses on blast radius: 83 goldens carry a clock and session-tree goldens assert literal relative ages, so a lane-wide now moves every scenario away from its own session and fixture times simultaneously, and each would still need its own anchor to read the buckets it was recorded with. + +**Keep scenarios clear of local midnight.** Scheduling or refusing runs near the boundary leaves the dependency in place and fails the run instead of the code under test. The [CI readiness and completion decision](2026-09-08-ci-readiness-and-completion.md) already rejects budgets and retries as substitutes for a controlled observation. + +## Verification + +Two negative controls pin one clock and leave the other on the wall clock, reproducing the recorded signature exactly: `steering` with the browser clock pinned and the Host clock live fails its two golden tests with `… and stop. 9/26 {{clock}}`, and `message-actions` with the browser clock pinned and a live-anchored seed fails with `9/26 {{clock}}` on every clock line plus `now` where the fork golden asserts `1min`. Pinning both clocks passes both files, and their goldens are unchanged. + +The queue scenario's start race is measured, not inferred: with the clocks pinned and no busy observation it failed 10 times in 61 runs of `vitest run … -t "queues two messages"`, all at the same `2 queued messages` wait; `origin/master` without the pins failed 0 of 41; with the observation added, 20 of 20 passed. + +CI run 36179748541 (job 108218939163) shows the calendar fix holding under the lane's own topology: the prompt line matches `{{clock}}` where run 36157253018 rendered `9/25 {{clock}}`. + +## Consequences + +The four scenarios render the same aria line whenever they run, and their goldens keep asserting a clock without a date prefix. The calendar fact is owned per scenario: a scenario that types messages, or seeds a fixture whose header carries `createdAt: 0`, must read the fixture day on both clocks, or it inherits the midnight exposure. Product clock rendering, the `Asia/Shanghai` browser timezone, every committed golden, and the assertions of the scenarios not listed here are unchanged. diff --git a/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.zh.md b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.zh.md new file mode 100644 index 0000000000..4631d36b8a --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Web conversation clocks on the fixture day + +Status: implemented + +[English](2026-09-26-web-conversation-clock-fixture-day.md) | 中文 + +## Problem + +运行 36157253018(作业 108144715986,`node 24 / snapshots and artifacts`)在一次执行中让四个浏览器场景失败——[steering](../../../../apps/web/tests/steering.e2e.ts)、[markdown-images](../../../../apps/web/tests/markdown-images.e2e.ts)、[message-actions](../../../../apps/web/tests/message-actions.e2e.ts) 与 [reference-composer](../../../../apps/web/tests/reference-composer.e2e.ts)——且是同一处 aria 差异:消息渲染为 `… and stop. 9/25 23:58`,而 golden 记录为 `… and stop. {{clock}}`。 + +[`newEnglishPage`](../../../../apps/web/tests/support.ts) 将浏览器时区固定为 `Asia/Shanghai`,而 [`formatMessageClock`](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 一旦消息的本地日历日或年份与渲染时不同,就会在时钟前拼接 `clock.md` / `clock.ymd`。这些场景把两个时钟都留在墙上时钟上,而它们的消息时间是在运行期盖章的:实时输入由 Host 盖章,fixture 头部为 `createdAt: 0` 的种子由 `seedSession` 以 `Date.now() - 60_000` 为锚点。失败的作业于 15:54Z 启动,距本地午夜还有六分钟,于是午夜前盖章的行在午夜后捕获并渲染出日期前缀;同一场景的下一次运行在午夜后数分钟通过。 + +通道本已具备对策——[`WEB_FIXTURE_TIME`](../../../../apps/web/tests/support.ts),其文档写明它是种子事件时间与 `Asia/Shanghai` 浏览器时钟的同日锚点,另有七个场景在使用它——但这四个场景从未采用。 + +## Decision + +这四个场景让两个时钟都读取 fixture 日。 + +实时盖章的场景用 `pinHostClock()` 固定 Host 时钟(一个从锚点起按真实流逝时间推进的 `Date.now` spy),并用 [`pinBrowserClock(page)`](../../../../apps/web/tests/support.ts) 固定浏览器时钟。种子场景通过 `seedSession` 的 `createdAt` 参数锚定种子,并用同样方式固定浏览器时钟。 + +种子锚点不只影响渲染出的时钟:侧边栏的相对时间也以浏览器时钟为基准计算,因此固定浏览器却用实时锚点的种子会读出 `8mo`,而 [message-actions](../../../../apps/web/tests/message-actions.e2e.ts) 的 fork golden 断言的是 `now` 与 `1min`。 + +`pinBrowserClock` 按固定间隔从锚点重新施加同一个冻结时刻,而不是让时钟停在单一数值上,因为产品代码要靠读取两个 `Date.now()` 值来决定一个已渲染值要保持多久:步骤进程会把上一个标题保持 `PROCESS_TITLE_MINIMUM_MS` = 150 ms([ChatGroupSeat](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx)),输入框 keymap 会在 `compositionend` 后再保持 10 ms 的合成守卫([keymap](../../../../packages/client/ui-conversation/src/client/input/editor/keymap.ts))。两者都把剩余保持时间算作 `Date.now() - previousReading`,而永久冻结的时钟永远不会让它推进,于是保持期不会结束,golden 里那行稳定文案始终不出现。CI 运行 36179748541(作业 108218939163)在第一个冻结时钟的修订上正是抓到了这一点:steering 的 mid golden 记录的是 `Waiting for your action · Ready to continue?`,实际捕获到 `Preparing questions`。 + +steering 的 mid 捕获在截图前等待该稳定标题出现;[整队场景](../../../../apps/web/tests/steering.e2e.ts) 则只在运行中回合的停止控件出现后才做排队手势,因此 Enter 的排队判定总是相对一个已上报忙碌的 agent 解析。 + +## Alternatives considered + +**在 aria normalizer 中折叠渲染出的日期前缀。** 在 `normalizeAria` 中把 `9/25 23:58` 折叠为 `{{clock}}` 只需两行即可修好这四个场景,且已实测:它让四个文件通过,随后在一次 `DSH_WEB_SNAPSHOT_WORKERS=16` 的通道运行中让 `cordis-tool-round`、`stats-paged-history` 与 `navigation-panes` 失败,因为这些 golden 有意记录带日期的形式(`9/1 {{clock}}`,以及 Timing 面板中的年份形式),而 `skill-tool-row` 早已把该形式 token 化为 `{{date}} {{clock}}`。两种渲染陈述的是不同事实——此行来自另一天,此行来自今天——因此折叠它们是在删除断言,而不是在稳定断言。 + +**让浏览器时钟保持冻结。** 整个场景固定单一时刻更简单,也能让四个文件通过,但它抽掉了页面中所有“读两次”保持逻辑的时间流逝,上面的 `Preparing questions` 捕获就是这样发生的。重新施加锚点既保持日历固定,又不改变已渲染值的保持时长。 + +**为整条通道固定时钟。** 在 `launchWebScaffold` 与 `newEnglishPage` 内固定时钟可以一次覆盖所有场景,包括将来以同样方式失败的场景。它因影响面而落选:83 个 golden 带有时钟,且会话树 golden 断言字面相对时间,因此通道级的 now 会同时把所有场景移离各自的会话与 fixture 时间,而每个场景仍需自己的锚点才能读出录制时的分桶。 + +**让场景避开本地午夜。** 在边界附近调度或拒绝运行会把依赖留在原处,并让运行而非被测代码失败。[CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 已经拒绝用预算或重试替代受控观察。 + +## Verification + +两个负向对照各固定一个时钟、把另一个留在墙上时钟,精确复现了记录到的特征:`steering` 固定浏览器时钟而 Host 时钟实时时,两个 golden 用例以 `… and stop. 9/26 {{clock}}` 失败;`message-actions` 固定浏览器时钟但仍用实时锚点的种子时,每行时钟都以 `9/26 {{clock}}` 失败,且本应断言 `1min` 的 fork golden 读出 `now`。两个时钟都固定后两个文件均通过,且它们的 golden 未改动。 + +排队场景的启动竞态是实测而非推断:固定时钟且不等待忙碌状态时,`vitest run … -t "queues two messages"` 在 61 次中失败 10 次,全部停在同一个 `2 queued messages` 等待上;未加固定时钟的 `origin/master` 在 41 次中失败 0 次;加上该观察后 20 次全部通过。 + +CI 运行 36179748541(作业 108218939163)显示日历修复在通道自身的拓扑下成立:提示行匹配 `{{clock}}`,而运行 36157253018 渲染的是 `9/25 {{clock}}`。 + +## Consequences + +这四个场景无论何时运行都渲染同一行 aria,其 golden 继续断言不带日期前缀的时钟。日历事实现在按场景归属:自行输入消息、或种子 fixture 头部为 `createdAt: 0` 的场景必须让两个时钟都读取 fixture 日,否则会继承午夜暴露。产品时钟渲染、`Asia/Shanghai` 浏览器时区、所有已提交的 golden,以及本说明未列出的场景的断言均不变。 diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 147a97b098..d0c7af8a78 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -23,7 +23,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-images', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-images/ui.expected.md', import.meta.url)) @@ -166,6 +166,7 @@ describe('web e2e: Markdown image rendering', () => { let browser: Browser let page: Page let tripwire: ReturnType + let unpinBrowserClock: (() => void) | undefined const mediaResponses = new Map() beforeAll(async () => { @@ -186,9 +187,11 @@ describe('web e2e: Markdown image rendering', () => { const outsidePath = join(scaffold.persistenceRoot, 'outside.png') await writeFile(outsidePath, PNG) await writeFile(join(scaffold.workspaceCwd, 'active.html'), '

File preview

') - await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID) + await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID, undefined, + { createdAt: WEB_FIXTURE_TIME - 60_000 }) browser = await chromium.launch() page = await newEnglishPage(browser) + unpinBrowserClock = await pinBrowserClock(page) tripwire = watchConsole(page) page.on('response', (response) => { const url = new URL(response.url()) @@ -201,6 +204,7 @@ describe('web e2e: Markdown image rendering', () => { }, 120_000) afterAll(async () => { + unpinBrowserClock?.() await browser?.close() await scaffold?.close() if (imageOrigin !== undefined) await stopServer(imageOrigin.server) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 8c6e7ea615..dc1df8058a 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -14,7 +14,7 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { openSettings, newEnglishPage, saveFailureShot } from './support.ts' +import { openSettings, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actions', import.meta.url)) // Borrowed read-only: this scenario needs any settled user+assistant pair, not @@ -189,6 +189,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { let browser: Browser let page: Page let tripwire: ReturnType + let unpinBrowserClock: (() => void) | undefined beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -201,15 +202,21 @@ describe('web e2e: message IconActions and clocks on settled history', () => { expect(parseSeedFixture(raw).events.flatMap(event => event.type === 'request/header' ? [event.data.reason] : []), 'adapted seed must carry an unchanged resume header').toEqual(['initial', 'resume']) - await seedSession(scaffold, raw, SEED_ID) + // The fixture carries no times of its own, so the seed anchors them at + // `Date.now() - 60_000` unless the scenario names the shared fixture day. + // 90 s keeps the earliest rows in the `1min` bucket and the later ones, + // carried by their stream spans, inside `now` — what the fork golden records. + await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME - 90_000 }) browser = await chromium.launch() page = await newEnglishPage(browser) + unpinBrowserClock = await pinBrowserClock(page) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) afterAll(async () => { + unpinBrowserClock?.() await browser?.close() await scaffold?.close() }) diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index d4518210b4..6381deb606 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -29,7 +29,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/reference-composer', import.meta.url)) const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') @@ -142,14 +142,16 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through let browser: Browser let page: Page let tripwire: ReturnType + let unpinBrowserClock: (() => void) | undefined beforeAll(async () => { scaffold = await launchWebScaffold({}) - const targetCreatedAt = Date.now() - 60_000 + const targetCreatedAt = WEB_FIXTURE_TIME - 60_000 await seedSession(scaffold, sourceSessionFixture(), SOURCE_SESSION_ID, undefined, { createdAt: targetCreatedAt - 1 }) await seedSession(scaffold, targetSessionFixture(), TARGET_SESSION_ID, undefined, { createdAt: targetCreatedAt }) browser = await chromium.launch() page = await newEnglishPage(browser) + unpinBrowserClock = await pinBrowserClock(page) tripwire = watchConsole(page) // Fixture files land before the workspace connects so the Host's file // index never races their creation (the connect helper mkdirs the same @@ -169,6 +171,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through }, 120_000) afterAll(async () => { + unpinBrowserClock?.() await browser?.close() await scaffold?.close() }) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 8b83176777..226987dc13 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, type MockInstance } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { expandAssistantStream } from '@deepseek-ai/dsh-llm' @@ -17,7 +17,7 @@ import { compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { openSettings, connectFreshWorkspace, newEnglishPage, pinBrowserClock, pinHostClock, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/steering', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') @@ -72,15 +72,22 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { let browser: Browser let page: Page let tripwire: ReturnType + let hostClock: MockInstance | undefined + let unpinBrowserClock: (() => void) | undefined const sessionEvents: SessionEvent[] = [] beforeAll(async () => { + // This scenario types its own messages, so the Host stamps their times: both + // clocks must read the fixture day or a run across Asia/Shanghai midnight + // renders the same message with a date prefix. + hostClock = pinHostClock() scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: true }) scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) + unpinBrowserClock = await pinBrowserClock(page) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -89,8 +96,15 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { }, 120_000) afterAll(async () => { - await browser?.close() - await scaffold?.close() + try { + await browser?.close() + await scaffold?.close() + } finally { + // A failing close (unconsumed fixture, mismatched snapshot) must not + // leave the pinned clocks running for the suites after this one. + unpinBrowserClock?.() + hostClock?.mockRestore() + } }) it('strictly steers one queued row; the interjection is logged, rendered, and obeyed', async () => { @@ -135,6 +149,11 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { expect(await page.getByText(STEER, { exact: true }).count()).toBe(1) expect(await pendingSteering.count()).toBe(1) expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0) + // The question step's process title reaches its settled text a minimum + // display interval after the takeover; capture that text, not the + // transient one the golden does not record. + await page.getByRole('button', { name: 'Waiting for your action · Ready to continue?' }) + .waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE) } @@ -381,9 +400,12 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { let browser: Browser let page: Page let tripwire: ReturnType + let hostClock: MockInstance | undefined + let unpinBrowserClock: (() => void) | undefined const sessionEvents: SessionEvent[] = [] beforeAll(async () => { + hostClock = pinHostClock() // The scenario boots a fresh session against the override-only fixture; // the replay.override.json sidecar replaces the derived script, so the // (deliberately absent) session.jsonl is never read. @@ -399,6 +421,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) + unpinBrowserClock = await pinBrowserClock(page) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -409,8 +432,13 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { afterAll(async () => { releaseReplay.resolve(undefined) disposeReplayBarrier?.() - await browser?.close() - await scaffold?.close() + try { + await browser?.close() + await scaffold?.close() + } finally { + unpinBrowserClock?.() + hostClock?.mockRestore() + } }) it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => { @@ -424,6 +452,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(PROMPT) await input.press('Enter') + // The turn must report busy before the next two sends: Enter's queue gesture + // resolved against a not-yet-running agent admits the message into the turn + // instead of publishing a Queue row. + await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 }) await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(STEER_ONE) await input.press('Enter') diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 0dc69acbbf..1d58d48083 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -5,7 +5,7 @@ import { createRequire } from 'node:module' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' -import { expect } from 'vitest' +import { expect, vi, type MockInstance } from 'vitest' /** The built page under test; `pnpm run test:web` rebuilds it before running. */ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url)) @@ -41,6 +41,57 @@ export const ZH_BROWSER_LOCALE = 'zh-CN' /** Same-day anchor for seeded event times and the Asia/Shanghai browser clock. */ export const WEB_FIXTURE_TIME = Date.parse('2026-01-15T12:00:00+08:00') +/** How often a pinned browser clock advances, in milliseconds. */ +const PINNED_CLOCK_STEP_MS = 250 + +/** + * Pin the Host clock to the shared fixture day for one scenario suite. + * + * A rendered message clock gains a `clock.md` / `clock.ymd` date prefix as soon + * as the message's local day differs from the renderer's, so a scenario whose + * message times are stamped by the Host wall clock — a live composer send, or a + * seed anchored from `Date.now()` — renders a different aria line once the run + * spans `Asia/Shanghai` midnight. Reading the fixture day on both clocks makes + * that calendar fact part of the scenario rather than of the run. + * + * The pinned clock advances with real elapsed time, so turn deadlines, session + * ordering, and durations keep running. + * @returns the installed `Date.now` spy, restored by the caller in teardown. + */ +export function pinHostClock(): MockInstance { + const startedAt = performance.now() + return vi.spyOn(Date, 'now').mockImplementation(() => + WEB_FIXTURE_TIME + Math.floor(performance.now() - startedAt)) +} + +/** + * Pin a scenario page's clock to the shared fixture day, advancing with real + * time. Pair it with {@link pinHostClock} for a scenario that stamps its own + * message times, and with `seedSession`'s `createdAt` for a seeded one. + * + * The frozen instant is re-applied from the anchor on an interval rather than + * left at one value, because product logic reads two `Date.now()` values to + * decide a minimum display hold or a deadline window; a permanently frozen + * clock stops supplying elapsed time, so a title that owes its golden settled + * text keeps rendering its previous one. Both clocks therefore advance at the + * real rate and stay within one step of each other. + * @param page - page whose clock is pinned. + * @returns the disposer that stops re-pinning; call it in teardown. + */ +export async function pinBrowserClock(page: Page): Promise<() => void> { + const startedAt = Date.now() + const step = async (): Promise => { + try { + await page.clock.setFixedTime(WEB_FIXTURE_TIME + (Date.now() - startedAt)) + } catch { + // The page closed under the interval; teardown owns the last word. + } + } + await step() + const timer = setInterval(() => { void step() }, PINNED_CLOCK_STEP_MS) + return () => { clearInterval(timer) } +} + /** * Open the standard browser-test page advertising English before client boot. * This keeps role locators and goldens deterministic while leaving the Host From eff2c6463f636f909ab34ac858536bbd66989a49 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:17:02 +0800 Subject: [PATCH 05/35] test(web): await the saved provider settings before reopening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the provider editor confirms its write, but the browser directory refresh lands after it, and the reopened editor copies the previous input selection into a new draft — checkbox polling cannot correct a draft that was already built. Run 36159502081 (job 108152262196) read a stale `true` from that draft at models-settings.e2e.ts:395. Wait for the saved-provider notice, which the refresh publishes, before the third reopen. The adjacent reopen in the same test file waits on the same notice. --- apps/web/tests/models-settings.e2e.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 4a206e7299..a82a816230 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -388,6 +388,10 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByRole('button', { name: '保存', exact: true }).click() await types.waitFor({ state: 'detached' }) await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text'] }) + // The saved notice confirms the browser directory refresh after the editor closes. + // Reopening before it copies the previous selection into a new draft, which + // checkbox polling cannot correct. + await dialog.getByText('已保存 openai。', { exact: true }).waitFor({ timeout: 10_000 }) await edit.click() await dialog.getByText('自定义设置').click() await dialog.getByRole('button', { name: '模型选项 1' }).click() From 5815da5a53fc85d24f2f85545d6f56eb94709361 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 26 Sep 2026 11:33:16 +0800 Subject: [PATCH 06/35] test(web): poll the spreadsheet selection after each sidebar resize --- apps/web/tests/document-preview.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index cc5c1fa763..7812b5b6b6 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -275,7 +275,7 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width) await expectExcelLayout(excel) expect(await meetingCanvas.evaluate(node => node.isConnected)).toBe(true) - expect(await selection.innerText()).toBe('A1') + await expect.poll(() => selection.innerText()).toBe('A1') await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0) } const gridOffset = await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft) From e967e3cbba3dcfc32f05461192cc66f5414235cc Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sat, 26 Sep 2026 22:40:27 +0800 Subject: [PATCH 07/35] fix(ui): shrink auto-review artwork by one pixel --- packages/experimental/auto-review/icon.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experimental/auto-review/icon.svg b/packages/experimental/auto-review/icon.svg index e278e508fe..47d6f7aef3 100644 --- a/packages/experimental/auto-review/icon.svg +++ b/packages/experimental/auto-review/icon.svg @@ -1,5 +1,5 @@ - + From 5d72bfbe49c5ce001805e448c1ee1e5d2941f824 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sun, 27 Sep 2026 00:51:57 +0800 Subject: [PATCH 08/35] fix(plugins): refine refresh feedback and plugin artwork --- .../plugin-manager/refresh.expected.md | 24 +++ apps/web/tests/plugin-manager.e2e.ts | 130 ++++++++++++- .../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.tsx | 25 ++- .../src/client/PluginRefreshToast.tsx | 31 +++ .../ui-plugin-manager/src/client/index.ts | 11 +- .../ui-plugin-manager/src/client/locales.ts | 2 + .../src/client/manager-store.ts | 32 +++- .../src/client/presentation.ts | 1 + .../tests/browser-plugin.client.spec.tsx | 51 ++++- .../tests/components.client.spec.tsx | 171 ++++++++++++++++- .../tests/manager-store.client.spec.ts | 181 +++++++++++++++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- packages/client/ui-primitives/src/Tooltip.tsx | 21 +- .../ui-primitives/src/plugin-artwork.tsx | 27 +-- .../tests/plugin-artwork.client.spec.tsx | 16 +- .../tests/tooltip.client.spec.tsx | 79 ++++++++ packages/experimental/auto-review/icon.svg | 4 +- .../experimental/schedule-bundle/icon.svg | 17 +- .../src/client/slot-catalog.ts | 1 + 24 files changed, 781 insertions(+), 63 deletions(-) create mode 100644 apps/web/tests/expected/plugin-manager/refresh.expected.md create mode 100644 packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md new file mode 100644 index 0000000000..24ac557b62 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md @@ -0,0 +1,24 @@ +Hover at 499ms: 0 tooltips +Hover at 500ms: +- tooltip "刷新" +Focus at 499ms: 0 tooltips +Focus at 500ms: +- tooltip "刷新" +success: pending +- button "刷新" [disabled] +{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +success: settled +- button "刷新" +{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +failure: pending +- button "刷新" [disabled] +{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +failure: settled +- button "刷新" +{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} +retry: pending +- button "刷新" [disabled] +{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +retry: settled +- button "刷新" +{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index d856618d90..e7c0395e18 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -218,6 +218,134 @@ describe('web e2e: plugin manager', () => { ].join('\n'), MODE) }) + it('delays refresh hints and keeps cards through manual refresh, failure, and retry', async () => { + const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager) + const reads: ReturnType[] = [] + try { + const probe = await context.newPage() + const consoleWatch = watchConsole(probe) + onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-refresh')) + await probe.clock.install() + await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const panel = probe.locator('[data-plugin-panel]') + const refresh = panel.getByRole('button', { name: '刷新', exact: true }) + const add = panel.getByRole('button', { name: '添加插件', exact: true }) + await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor() + await expect.poll(() => refresh.isEnabled()).toBe(true) + await probe.evaluate(() => document.fonts.ready) + const cards = panel.locator('[data-plugin-package], [data-plugin-item]') + const cardText = await cards.allTextContents() + expect(cardText.length).toBeGreaterThan(0) + const tooltip = probe.getByRole('tooltip').filter({ hasText: '刷新' }) + const trace: string[] = [] + const recordRefresh = async (phase: string) => { + trace.push(phase, await refresh.ariaSnapshot(), JSON.stringify({ + busy: await panel.getAttribute('aria-busy'), + spinners: await refresh.locator('[data-state="ongoing"]').count(), + cards: await cards.count(), + skeletons: await panel.locator('[data-plugin-loading]').count(), + alerts: await panel.getByRole('alert').allTextContents(), + retry: await panel.getByRole('button', { name: '重试', exact: true }).count(), + toasts: await probe.locator('body > [role="alert"]').allTextContents(), + })) + } + const bounds = await refresh.boundingBox() + if (bounds === null) throw new Error('Refresh button has no visible bounds') + await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000)) + try { + await probe.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2) + await probe.clock.runFor(499) + expect(await tooltip.count()).toBe(0) + trace.push(`Hover at 499ms: ${await tooltip.count()} tooltips`) + await probe.clock.runFor(1) + await tooltip.waitFor() + trace.push('Hover at 500ms:', await tooltip.ariaSnapshot()) + await probe.mouse.move(0, 999) + await tooltip.waitFor({ state: 'detached' }) + + await add.focus() + await probe.keyboard.press('Shift+Tab') + expect(await refresh.evaluate(element => element === document.activeElement)).toBe(true) + await probe.clock.runFor(499) + expect(await tooltip.count()).toBe(0) + trace.push(`Focus at 499ms: ${await tooltip.count()} tooltips`) + await probe.clock.runFor(1) + await tooltip.waitFor() + trace.push('Focus at 500ms:', await tooltip.ariaSnapshot()) + await probe.keyboard.press('Tab') + await tooltip.waitFor({ state: 'detached' }) + } finally { + await probe.clock.resume() + } + + await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000)) + const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles') + try { + for (const outcome of ['success', 'failure', 'retry'] as const) { + const release = Promise.withResolvers() + const called = spy.mock.calls.length + spy.mockImplementationOnce(() => { + const read = release.promise.then(() => { + if (outcome === 'failure') throw new Error('Fixture plugin refresh failed') + return listBundles() + }) + reads.push(read) + return read + }) + try { + await refresh.click() + await expect.poll(() => spy.mock.calls.length).toBeGreaterThan(called) + expect(await refresh.isDisabled()).toBe(true) + expect(await refresh.getAttribute('aria-busy')).toBe('true') + expect(await panel.getAttribute('aria-busy')).toBe('true') + const spinner = refresh.locator('[data-state="ongoing"]') + await spinner.waitFor() + expect(await spinner.evaluate(element => element.getAnimations({ subtree: true }).length)).toBeGreaterThan(0) + expect(await cards.allTextContents()).toEqual(cardText) + expect(await panel.locator('[data-plugin-loading]').count()).toBe(0) + expect(await tooltip.count()).toBe(0) + expect(await panel.getByRole('alert').count()).toBe(0) + expect(await probe.locator('body > [role="alert"]').count()).toBe(0) + await recordRefresh(`${outcome}: pending`) + + release.resolve(undefined) + await expect.poll(() => refresh.isEnabled()).toBe(true) + expect(await refresh.getAttribute('aria-busy')).toBe('false') + expect(await panel.getAttribute('aria-busy')).toBe('false') + expect(await spinner.count()).toBe(0) + expect(await cards.allTextContents()).toEqual(cardText) + expect(await panel.locator('[data-plugin-loading]').count()).toBe(0) + expect(await panel.getByRole('alert').count()).toBe(0) + expect(await panel.getByRole('button', { name: '重试', exact: true }).count()).toBe(0) + const toasts = probe.locator('body > [role="alert"]') + if (outcome === 'failure') { + await expect.poll(() => toasts.allTextContents()).toEqual(['刷新失败,请重试']) + } else { + expect(await toasts.count()).toBe(0) + } + await recordRefresh(`${outcome}: settled`) + } finally { + release.resolve(undefined) + } + } + } finally { + spy.mockRestore() + await probe.clock.resume() + } + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'refresh.expected.md'), trace.join('\n'), MODE) + expect(consoleWatch.pageErrors).toEqual([]) + expect(consoleWatch.warnings).toEqual([]) + } finally { + try { + await context.close() + } finally { + await Promise.allSettled(reads) + } + } + }) + it('starts with an unavailable selected bundle and lets the user clear its error', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle')) const panel = await openPluginsPanel() @@ -541,7 +669,7 @@ describe('web e2e: plugin manager', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { 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', + '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', ]) }) }) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 9eaa2361f2..b31911ea69 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: 263cf8b4c89b9550 - zh: cebfb0cc84953e44 + en: 37ba2a927f27ef7e + zh: 562fb22a02f0acd4 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: 4816e6ab0ee26abe zh: b16bb7cdfacd6c74 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index c51da00e2f..661ee09ae1 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -31,6 +31,8 @@ Installed bundles and their plugin rows display their own title and description The first list read shows a short Official-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states. +The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. During a manual refresh, the button shows a spinner and stays disabled until all pending reads finish; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select **Refresh** again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner. + ### Installing a bundle On first use, when pnpm uses the unconfigured official npm registry and npmmirror is offered, the Host probes both registries in parallel. The first successful HTTPS ping response selects the initial source. The dialog preserves remembered choices, manual selections, explicit manager configuration, and custom or unknown pnpm registries. An install clicked during initial probing waits for that bounded operation; a late result cannot overwrite a manual choice or reopen a closed dialog. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 09e717a1fd..3729c9a963 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 首次读取列表时,页面显示一条较短的「官方」标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 +**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取结束;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择**刷新**即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。 + ### 安装一个组合包 首次使用时,如果未显式配置安装源、pnpm 使用官方 npm 源,且列表提供 npmmirror,Host 会并发探测这两个源,选择最先成功响应 HTTPS ping 的源。已记住的选择、用户手选、管理器显式配置,以及自定义或未知的 pnpm 源均保留。在初次探测期间点击安装会等待这次有时限的操作;迟到结果不会覆盖手选或重新打开已关闭的对话框。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 18b694db82..19503fdcdd 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -18,7 +18,7 @@ import { IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, IconWarningOutlineRegular, Input, MenuSurface, Modal, PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, - StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer, + StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' @@ -1217,7 +1217,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const timer = setTimeout(clearHighlight, HIGHLIGHT_MS) return () => { clearTimeout(timer) } }, [highlight, clearHighlight]) - const noticeLine = state.notice === null ? null : noticeText(state.notice, t) + const noticeLine = state.notice === null || state.notice.kind === 'refresh-failed' ? null : noticeText(state.notice, t) // The page manages what the person installed, what the installation ships for them to switch on, and a // selected name the Host cannot read; the installation's other bundles are inspected in the Settings @@ -1227,6 +1227,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const mine = listed.filter(pkg => pkg.installed || !pkg.optional) const official = listed.filter(pkg => pkg.optional && !pkg.installed) const loaded = state.status === 'ready' || state.status === 'error' + const refreshing = state.refreshStatus === 'refreshing' const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined const openItem = view.kind === 'item' ? ledger.items.find(item => item.id === view.id) : undefined const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined @@ -1273,7 +1274,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ) return ( -
+
{showsCards ? (
@@ -1282,9 +1283,13 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {

{t('intro')}

- + + + @@ -1309,11 +1314,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { onDone={props.dismissNotice} /> )} - {!showsCards && state.status === 'error' + {!showsCards && state.status === 'error' && !refreshing ? (

- {t('error')} + {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}

@@ -1362,11 +1367,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { {renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))} {/* A failed package read trails the groups it left incomplete: right under Official on a first-load failure, and after the kept cards when a refresh fails over stale data. */} - {state.status === 'error' + {state.status === 'error' && !refreshing ? (

- {t('error')} + {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}

diff --git a/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx b/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx new file mode 100644 index 0000000000..b83bcce545 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx @@ -0,0 +1,31 @@ +/** Refresh failure feedback hosted outside the Plugins panel's lifetime. */ +import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { PluginManagerFace } from './manager-store.ts' +import { noticeText } from './presentation.ts' + +/** The controller's shared notice source and dismissal action. */ +export type PluginRefreshToastFace = Pick & { + hooks: Pick +} + +/** Render inputs bound from the shared controller and plugin dictionary. */ +export type PluginRefreshToastProps = InjectFace & PropsLocale<'pluginManager'> + +/** + * Display refresh failures even after navigation leaves the Plugins panel. + * @param props - shared notice hook, dismissal action, and locale seat. + * @returns the refresh failure toast, or null for other notices. + */ +export function PluginRefreshToast({ usePluginManager, dismissNotice, t }: PluginRefreshToastProps) { + const notice = usePluginManager(state => state.notice) + if (notice?.kind !== 'refresh-failed') return null + return ( + } + onDone={dismissNotice} + /> + ) +} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 0bceeabe9d..40c6f8a4b5 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // through the owning package's client-safe types subpath). import type {} from '@deepseek-ai/dsh-plugin-manager/types' import { PluginManagerPage } from './PluginManagerPage.tsx' +import { PluginRefreshToast, type PluginRefreshToastFace } from './PluginRefreshToast.tsx' import { PluginsPanelIcon } from './PluginsPanelIcon.tsx' import { configLedgerSource } from './config-ledger.ts' import { PluginManagerController } from './manager-store.ts' @@ -97,6 +98,14 @@ export function apply(ctx: ClientContext): void { // the page's own; a plugin's configuration arrives through the slots the // page declares here, so the page never names a configurable plugin. const configLedger = configLedgerSource(ctx) + const face = controller.inject(configLedger, text => ctx.locale.resolveText(text)) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, + inject: (): PluginRefreshToastFace => ({ + hooks: { pluginManager: face.hooks.pluginManager }, + dismissNotice: face.dismissNotice, + }), + }, PluginRefreshToast)) ctx.slots.inject('main', function* () { const handle = createNavigationStore(), instance = handle.create() const store: typeof handle = { ...handle, create: () => instance } @@ -105,7 +114,7 @@ export function apply(ctx: ClientContext): void { key: PANEL_ID, locale: NS, store, - inject: () => controller.inject(configLedger, text => ctx.locale.resolveText(text)), + inject: () => face, children: { 'plugins.item': { kind: 'list', scope: 'root' }, 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 550391f769..2a45761da2 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -10,6 +10,7 @@ export const zh = { unavailable: '本部署没有可管理的 profile,无法安装或启停插件。', retry: '重试', refresh: '刷新', + refreshError: '刷新失败,请重试', empty: '还没有安装任何插件。', addPlugin: '添加插件', restartNotice: '更改将在下次启动生效', @@ -203,6 +204,7 @@ export const en = { unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.', retry: 'Retry', refresh: 'Refresh', + refreshError: 'Refresh failed. Please try again.', empty: 'No plugins are installed yet.', addPlugin: 'Add plugin', restartNotice: 'The change takes effect at the next start', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 59a449cfab..d07c1d9f42 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -41,6 +41,7 @@ export type ManagerNotice = | { readonly kind: 'restart'; readonly packageName: string; readonly seq: number } | { readonly kind: 'overridden'; readonly packageName: string; readonly seq: number } | { readonly kind: 'cancelled'; readonly seq: number } + | { readonly kind: 'refresh-failed'; readonly seq: number } | { readonly kind: 'install'; readonly outcome: 'done' | 'failed' | 'unconfirmed' | 'applying' | 'unknown'; readonly seq: number } | { readonly kind: 'failed' @@ -269,6 +270,8 @@ export interface ConfirmState { export interface PluginManagerState { /** `unavailable` when the Host runs without a managed profile; `error` keeps the last packages. */ readonly status: 'idle' | 'loading' | 'ready' | 'error' | 'unavailable' + /** Manual refresh feedback; background reads do not show the toolbar spinner. */ + readonly refreshStatus: 'idle' | 'refreshing' | 'failed' readonly packages: readonly PackageView[] /** Package names and row keys with an action crossing the wire. */ readonly busy: readonly string[] @@ -492,6 +495,8 @@ export class PluginManagerController { private rerun = false private generation = 0 private disposed = false + /** A successful managed-profile read remains usable even when it returned no bundles. */ + private hasCachedInventory = false private pendingConfirm: (() => Promise) | undefined /** Cancels the check the dialog has in flight. */ private inspectAbort: AbortController | undefined @@ -510,7 +515,7 @@ export class PluginManagerController { private readonly ctx: ClientContext, ) { this.store = createSnapshotStore({ - status: 'idle', packages: [], busy: [], notice: null, + status: 'idle', refreshStatus: 'idle', packages: [], busy: [], notice: null, install: IDLE_INSTALL, confirm: null, highlight: null, }) } @@ -543,7 +548,7 @@ export class PluginManagerController { hooks: { pluginManager: this.store, configLedger, configurations: this.ctx.configForms.describe() }, configForm: id => this.ctx.configForms.get(id), ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() }, - refresh: () => { void this.load() }, + refresh: () => { void this.refresh() }, openInstall: () => { const install = this.getSnapshot().install if (install.requestId === undefined) { @@ -710,6 +715,26 @@ export class PluginManagerController { return run } + /** Keep manual refresh feedback until its coalesced reads settle, without clearing cached cards. */ + private async refresh(): Promise { + if (this.disposed || this.getSnapshot().refreshStatus === 'refreshing') return + this.patch({ + refreshStatus: 'refreshing', + ...this.getSnapshot().notice?.kind === 'refresh-failed' ? { notice: null } : {}, + }) + try { + await this.load() + } catch (_error) { + // A rejected transport request leaves the cached cards available for retry. + this.patch({ status: 'error' }) + } finally { + const failed = this.getSnapshot().status === 'error' + this.patch(failed && this.hasCachedInventory + ? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } } + : { refreshStatus: failed ? 'failed' : 'idle' }) + } + } + private async read(): Promise { try { do { @@ -725,6 +750,7 @@ export class PluginManagerController { continue } if (inventory.value.managementAvailable !== true) { + this.hasCachedInventory = false this.patch({ status: 'unavailable', packages: [] }) continue } @@ -737,8 +763,10 @@ export class PluginManagerController { this.patch({ status: 'error' }) continue } + this.hasCachedInventory = true this.patch({ status: 'ready', + refreshStatus: this.getSnapshot().refreshStatus === 'refreshing' ? 'refreshing' : 'idle', packages: sortPackages(bundles.value.map(bundle => packageView(bundle, plugins.value))), }) } while (this.shouldRerun()) diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index 292d604386..727d893729 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -143,6 +143,7 @@ export function noticeText(notice: ManagerNotice, t: Translate): string { case 'restart': return t('restartNotice') case 'overridden': return t('overriddenNotice', { name: notice.packageName }) case 'cancelled': return t('installCancelled') + case 'refresh-failed': return t('refreshError') case 'install': return t(({ done: 'installBackgroundDone', failed: 'installBackgroundFailed', unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying', unknown: 'installBackgroundUnknown', diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index f9cff227dc..155a15c69b 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -12,6 +12,7 @@ import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-t import * as settings from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' +import { PluginRefreshToast, type PluginRefreshToastFace } from '../src/client/PluginRefreshToast.tsx' import { PluginsPanelIcon } from '../src/client/PluginsPanelIcon.tsx' import type { PluginManagerFace } from '../src/client/manager-store.ts' @@ -54,6 +55,7 @@ function declare(slots: SlotRegistry): () => void { name: 'root', children: { 'main': { kind: 'keyed', scope: 'root' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, 'sidebar.panellist': { kind: 'list', scope: 'root' }, }, } as never, () => null) @@ -80,6 +82,41 @@ describe('ui-plugin-manager browser plugin', () => { expect(navigation.getSnapshot()).toEqual({ view: { kind: 'package', name: 'dsh-navigation-test' } }) }) + it('shares refresh failures with the overlay after navigation and releases both registrations across reloads', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('shell.overlay')).toHaveLength(0) + const removeRoot = declare(b.slots) + const mainInjected: object = b.slots.entries('main')[0]!.inject!() + const face = mainInjected as PluginManagerFace + const overlayInjected: object = b.slots.entries('shell.overlay')[0]!.inject!() + const overlay = overlayInjected as PluginRefreshToastFace + expect(overlay.hooks.pluginManager).toBe(face.hooks.pluginManager) + face.ensure() + await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) + b.selectPanel(PANEL_ID) + b.list.mockRejectedValueOnce(new Error('offline')) + face.refresh() + b.selectPanel(null) + await vi.waitFor(() => { expect(overlay.hooks.pluginManager.getSnapshot().notice?.kind).toBe('refresh-failed') }) + overlay.dismissNotice() + expect(face.hooks.pluginManager.getSnapshot().notice).toBeNull() + removeRoot() + expect(b.slots.entries('shell.overlay')).toHaveLength(0) + expect(b.slots.entries('main')).toHaveLength(0) + declare(b.slots) + const remountedInjected: object = b.slots.entries('shell.overlay')[0]!.inject!() + expect((remountedInjected as PluginRefreshToastFace).hooks.pluginManager).toBe(face.hooks.pluginManager) + await fiber.dispose() + expect(b.slots.entries('shell.overlay')).toHaveLength(0) + expect(b.slots.entries('main')).toHaveLength(0) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const reloadedInjected: object = b.slots.entries('shell.overlay')[0]!.inject!() + expect((reloadedInjected as PluginRefreshToastFace).hooks.pluginManager).not.toBe(face.hooks.pluginManager) + expect((reloadedInjected as PluginRefreshToastFace).hooks.pluginManager.getSnapshot().notice).toBeNull() + }) + it('declares only the services the page and its Remote methods use', () => { expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']) }) @@ -116,7 +153,18 @@ describe('ui-plugin-manager browser plugin', () => { for (const name of ['plugins.detail.actions', 'plugins.detail.badge', 'plugins.detail.section'] as const) { expect(b.slots.spec(name)).toMatchObject({ kind: 'list', scope: 'root' }) } - const face = (entry.inject as unknown as () => PluginManagerFace)() + const injected: object = entry.inject!() + const face = injected as PluginManagerFace + const overlay = b.slots.entries('shell.overlay')[0]! + expect(overlay.component).toBe(PluginRefreshToast) + expect(overlay.options).toMatchObject({ id: 'plugin-manager.refresh-toast' }) + expect(overlay.locale).toBe(NS) + const overlayInjected: object = overlay.inject!() + const overlayFace = overlayInjected as PluginRefreshToastFace + expect(overlayFace.hooks.pluginManager).toBe(face.hooks.pluginManager) + expect(overlayFace.dismissNotice).toBe(face.dismissNotice) + expect(Object.keys(overlayFace).sort()).toEqual(['dismissNotice', 'hooks']) + expect(Object.keys(overlayFace.hooks)).toEqual(['pluginManager']) const text = { en: 'Local tools', zh: '本地工具' } expect(face.resolveText(text)).toBe('本地工具') b.locale.setLocale('en') @@ -146,6 +194,7 @@ describe('ui-plugin-manager browser plugin', () => { await fiber.dispose() expect(b.ctx.get('pluginNavigation')).toBeUndefined() expect(b.slots.entries('main')).toHaveLength(0) + expect(b.slots.entries('shell.overlay')).toHaveLength(0) expect(b.slots.entries('sidebar.panellist')).toHaveLength(0) b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) await Promise.resolve() 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 70cfb1b4d6..7e94a9734e 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -11,6 +11,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { StrictMode, type ReactNode } from 'react' import { createNavigationStore } from '../src/client/navigation-store.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' +import { PluginRefreshToast } from '../src/client/PluginRefreshToast.tsx' import type { PluginManagerPageProps } from '../src/client/index.ts' import type { ConfigLedger } from '../src/client/config-ledger.ts' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' @@ -66,6 +67,7 @@ const IDLE_INSTALL: InstallState = { const READY: PluginManagerState = { status: 'ready', + refreshStatus: 'idle', packages: [], busy: [], notice: null, @@ -163,9 +165,18 @@ function renderTab( return body(owner.view, owner, 'form' in owner ? owner.form as ConfigPageForm | undefined : undefined) }, } - const { rerender, unmount } = render() + let showPage = true + let currentT = t + const contents = () => ( + <> + {showPage ? : null} + + + ) + const { rerender, unmount } = render(contents()) return { navigation, + hidePage: () => { showPage = false; rerender(contents()) }, props, unmount, store, @@ -173,7 +184,8 @@ function renderTab( set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }, setLanguage: (dict: typeof en) => { locale.setLocale(dict === zh ? 'zh' : 'en') - rerender() + currentT = translate(dict) + rerender(contents()) }, } } @@ -240,6 +252,161 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true) }) + it('shows refresh progress on the disabled button while preserving cards, then restores it without a toast', () => { + const { actions, set } = renderTab({ packages: [pkg()] }) + const refresh = screen.getByRole('button', { name: en.refresh }) + const detailName = en.openDetail.replace('{name}', 'dsh-better-sidebar') + const card = screen.getByRole('button', { name: detailName }) + expect(refresh).toHaveProperty('disabled', false) + expect(refresh.getAttribute('title')).toBeNull() + fireEvent.click(refresh) + expect(actions.refresh).toHaveBeenCalledOnce() + set({ refreshStatus: 'refreshing' }) + expect(refresh).toHaveProperty('disabled', true) + expect(refresh.getAttribute('aria-busy')).toBe('true') + expect(refresh.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(screen.getByRole('button', { name: detailName })).toBe(card) + expect(screen.queryByRole('status', { name: en.loading })).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(refresh) + expect(actions.refresh).toHaveBeenCalledOnce() + set({ refreshStatus: 'idle' }) + expect(refresh).toHaveProperty('disabled', false) + expect(refresh.getAttribute('aria-busy')).toBe('false') + expect(refresh.querySelector('[data-state="ongoing"]')).toBeNull() + expect(screen.getByRole('button', { name: detailName })).toBe(card) + expect(screen.queryByRole('alert')).toBeNull() + expect(actions.dismissNotice).not.toHaveBeenCalled() + }) + + it.each(['list', 'detail'] as const)('keeps cached %s content with one localized refresh toast and no inline retry', (view) => { + const { set, setLanguage } = renderTab({ packages: [pkg()] }) + if (view === 'detail') fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + const content = document.querySelector(view === 'detail' ? '[data-plugin-detail]' : '[data-plugin-package]') + for (const dict of [en, zh]) { + setLanguage(dict) + set({ status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: 1 } }) + expect(screen.getAllByRole('alert')).toHaveLength(1) + const toast = screen.getByRole('alert') + expect(toast.textContent).toBe(dict.refreshError) + expect(toast.parentElement).toBe(document.body) + expect(document.querySelector('[data-plugin-panel]')?.contains(toast)).toBe(false) + expect(screen.queryByRole('button', { name: dict.retry })).toBeNull() + expect(content?.isConnected).toBe(true) + set({ refreshStatus: 'refreshing', notice: null }) + expect(screen.queryByRole('alert')).toBeNull() + expect(content?.isConnected).toBe(true) + set({ refreshStatus: 'idle' }) + expect(screen.queryByRole('alert')).toBeNull() + } + }) + + it.each(['list', 'configuration detail'] as const)('keeps an uncached refresh failure inline in %s and hides it during retry', (view) => { + const { actions, set, setLanguage } = renderTab( + { status: 'error', refreshStatus: 'failed' }, + { items: [{ id: 'bash', label: 'Shell' }] }, + ) + if (view === 'configuration detail') fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Shell') })) + for (const dict of [en, zh]) { + setLanguage(dict) + set({ status: 'error', refreshStatus: 'failed' }) + const alert = screen.getByRole('alert') + expect(alert.textContent).toBe(dict.refreshError) + expect(document.querySelector('[data-plugin-panel]')?.contains(alert)).toBe(true) + fireEvent.click(screen.getByRole('button', { name: dict.retry })) + set({ refreshStatus: 'refreshing' }) + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByRole('button', { name: dict.retry })).toBeNull() + } + expect(actions.refresh).toHaveBeenCalledTimes(2) + }) + + it.each(['before', 'after'] as const)('keeps the refresh toast when navigation leaves %s the failure arrives', (navigation) => { + vi.useFakeTimers() + try { + const { actions, set, hidePage } = renderTab({ packages: [pkg()] }) + set({ refreshStatus: 'refreshing' }) + if (navigation === 'before') hidePage() + set({ refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: 1 } }) + const toast = screen.getByRole('alert') + expect(toast.textContent).toBe(en.refreshError) + if (navigation === 'after') hidePage() + expect(document.querySelector('[data-plugin-panel]')).toBeNull() + expect(screen.getByRole('alert')).toBe(toast) + expect(toast.style.getPropertyValue('--dsh-toast-hold')).toBe('3000ms') + act(() => { vi.advanceTimersByTime(3_999) }) + expect(actions.dismissNotice).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(actions.dismissNotice).toHaveBeenCalledOnce() + set({ notice: null }) + expect(screen.queryByRole('alert')).toBeNull() + } finally { + cleanup() + vi.useRealTimers() + } + }) + + it('gives a repeated refresh failure its own toast lifetime without extending it for locale changes', () => { + vi.useFakeTimers() + try { + const { actions, set, setLanguage } = renderTab({ notice: { kind: 'refresh-failed', seq: 1 } }) + act(() => { vi.advanceTimersByTime(2_000) }) + set({ notice: { kind: 'refresh-failed', seq: 2 } }) + act(() => { vi.advanceTimersByTime(2_000) }) + expect(actions.dismissNotice).not.toHaveBeenCalled() + setLanguage(zh) + expect(screen.getByRole('alert').textContent).toBe(zh.refreshError) + act(() => { vi.advanceTimersByTime(2_000) }) + expect(actions.dismissNotice).toHaveBeenCalledOnce() + } finally { + cleanup() + vi.useRealTimers() + } + }) + + it.each(['hover', 'keyboard'] as const)('shows a portaled refresh tooltip after 500ms of %s and cancels early departure', (trigger) => { + vi.useFakeTimers() + try { + const { unmount } = renderTab() + const refresh = screen.getByRole('button', { name: en.refresh }) + const enter = () => { + if (trigger === 'hover') fireEvent.mouseEnter(refresh) + else { + fireEvent.keyDown(document, { key: 'Tab' }) + fireEvent.focus(refresh) + } + } + const leave = () => { + if (trigger === 'hover') fireEvent.mouseLeave(refresh) + else fireEvent.blur(refresh) + } + expect(refresh.getAttribute('title')).toBeNull() + enter() + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() + leave() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() + enter() + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip.textContent).toBe(en.refresh) + expect(tooltip.parentElement).toBe(document.body) + fireEvent.click(refresh) + expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() + leave() + enter() + unmount() + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() + } finally { + cleanup() + vi.useRealTimers() + } + }) + it('keeps the read failure and its retry visible while a detail page is open', () => { const { actions, set } = renderTab({ packages: [pkg()] }) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 6d852dfb18..b665a551c3 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -74,7 +74,9 @@ const NO_CONFIG: HostObservable = { function bench(overrides: Partial>> = {}) { const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) } const plugins = { - listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE]))), + listBundles: vi.fn<() => Promise> | ReturnType>>( + () => Promise.resolve(ok([BUNDLE])), + ), listPlugins: vi.fn(() => Promise.resolve(ok(PLUGINS))), inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))), registries: vi.fn(() => Promise.resolve(ok(REGISTRIES))), @@ -224,6 +226,183 @@ describe('PluginManagerController', () => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + it('keeps cached packages while a manual refresh reads and ignores repeated refreshes without a success notice', async () => { + const { plugins, inventory, face, state, controller } = bench() + await controller.load() + const packages = state().packages + const gate = deferred>>() + plugins.listBundles.mockReturnValueOnce(gate.promise) + face.refresh() + expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null }) + expect(state().packages).toBe(packages) + await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + face.refresh() + face.refresh() + gate.resolve(ok([{ ...BUNDLE, version: '0.17.0' }])) + await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + expect(state().packages[0]?.version).toBe('0.17.0') + expect(state().status).toBe('ready') + expect(state().notice).toBeNull() + expect(inventory.list).toHaveBeenCalledTimes(2) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + expect(plugins.listPlugins).toHaveBeenCalledTimes(2) + }) + + it.each(['refused', 'rejected'] as const)('keeps cached packages ready after a %s refresh and clears its toast when retry starts', async (failure) => { + const { inventory, plugins, face, state, controller } = bench() + await controller.load() + const packages = state().packages + if (failure === 'refused') inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline')) + else inventory.list.mockRejectedValueOnce(new Error('transport down')) + face.refresh() + await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + expect(state()).toMatchObject({ status: 'ready', notice: { kind: 'refresh-failed', seq: 1 } }) + expect(state().packages).toBe(packages) + const gate = deferred>>() + plugins.listBundles.mockReturnValueOnce(gate.promise) + face.refresh() + expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null }) + expect(state().packages).toBe(packages) + await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + gate.resolve(ok([BUNDLE])) + await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + expect(state()).toMatchObject({ status: 'ready', notice: null }) + inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline again')) + face.refresh() + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'refresh-failed', seq: 2 }) }) + face.dismissNotice() + expect(state().notice).toBeNull() + }) + + it.each(['refused', 'rejected'] as const)('keeps a %s first refresh failure inline without a toast', async (failure) => { + const inventory = failure === 'refused' + ? vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')) + : vi.fn().mockRejectedValueOnce(new Error('transport down')) + const { face, state } = bench({ inventory }) + face.refresh() + await vi.waitFor(() => { expect(state().refreshStatus).toBe('failed') }) + expect(state()).toMatchObject({ status: 'error', packages: [], notice: null }) + }) + + it('treats an empty successful inventory as cached for refresh failures', async () => { + const { inventory, face, state, controller } = bench({ listBundles: vi.fn().mockResolvedValue(ok([])) }) + await controller.load() + expect(state().packages).toEqual([]) + inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline')) + face.refresh() + await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + expect(state()).toMatchObject({ status: 'ready', packages: [], notice: { kind: 'refresh-failed' } }) + }) + + it('forgets cached inventory when the managed profile becomes unavailable', async () => { + const { inventory, face, state, controller } = bench() + await controller.load() + inventory.list.mockResolvedValueOnce(ok({ entries: [], managementAvailable: false })) + await controller.load() + expect(state()).toMatchObject({ status: 'unavailable', packages: [] }) + inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline')) + face.refresh() + await vi.waitFor(() => { expect(state().refreshStatus).toBe('failed') }) + expect(state()).toMatchObject({ status: 'error', packages: [], notice: null }) + }) + + it('preserves unrelated notices when a manual refresh starts and succeeds', async () => { + const { face, state, controller } = bench({ + setBundleEnabled: vi.fn().mockResolvedValue(ok({ ...APPLIED, application: 'restart-required' })), + }) + await controller.load() + face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { expect(state().notice?.kind).toBe('restart') }) + const notice = state().notice + face.refresh() + expect(state().notice).toBe(notice) + await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + expect(state().notice).toBe(notice) + }) + + it('keeps background reads quiet and clears an uncached refresh failure when they succeed', async () => { + const { plugins, face, state, controller } = bench({ + inventory: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')) + .mockResolvedValue(ok({ entries: [], managementAvailable: true })), + }) + face.refresh() + await vi.waitFor(() => { expect(state().refreshStatus).toBe('failed') }) + const gate = deferred>>() + plugins.listBundles.mockReturnValueOnce(gate.promise) + const loading = controller.load() + await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledOnce() }) + expect(state().refreshStatus).toBe('failed') + gate.resolve(ok([BUNDLE])) + await loading + expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'idle', notice: null }) + }) + + it.each(['background', 'manual'] as const)('holds the refresh indicator through coalesced reads when %s starts first', async (first) => { + const { plugins, face, state, controller } = bench() + await controller.load() + const initial = deferred>>() + const rerun = deferred>>() + plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise) + const background = first === 'background' ? controller.load() : undefined + if (first === 'manual') face.refresh() + await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + expect(state().refreshStatus).toBe(first === 'background' ? 'idle' : 'refreshing') + const completion = background ?? controller.load() + if (first === 'background') face.refresh() + initial.resolve(ok([{ ...BUNDLE, version: '0.17.0' }])) + await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(3) }) + expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null }) + expect(state().packages[0]?.version).toBe('0.17.0') + face.refresh() + rerun.resolve(ok([{ ...BUNDLE, version: '0.18.0' }])) + await completion + await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + expect(state().packages[0]?.version).toBe('0.18.0') + expect(state().notice).toBeNull() + expect(plugins.listBundles).toHaveBeenCalledTimes(3) + }) + + it.each([false, true])('reports only the final coalesced refresh result (failure: %s)', async (failure) => { + const { plugins, face, state, controller } = bench() + await controller.load() + const initial = deferred> | ReturnType>() + const rerun = deferred> | ReturnType>() + plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise) + face.refresh() + await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + const loading = controller.load() + initial.resolve(failure ? ok([BUNDLE]) : refused('gateway/internal', 'offline')) + await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(3) }) + expect(state().refreshStatus).toBe('refreshing') + expect(state().notice).toBeNull() + rerun.resolve(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE])) + await loading + await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + expect(state().status).toBe('ready') + expect(state().notice).toEqual(failure ? { kind: 'refresh-failed', seq: 1 } : null) + }) + + it.each([false, true])('does not publish a manual refresh that settles after disposal (failure: %s)', async (failure) => { + const { plugins, face, state, controller } = bench() + await controller.load() + const gate = deferred> | ReturnType>() + plugins.listBundles.mockReturnValueOnce(gate.promise) + face.refresh() + await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + const completion = controller.load() + const before = state() + const listener = vi.fn() + const unsubscribe = face.hooks.pluginManager.subscribe(listener) + onTestFinished(unsubscribe) + controller.dispose() + gate.resolve(failure ? refused('gateway/internal', 'offline') : ok([{ ...BUNDLE, version: '0.17.0' }])) + await completion + face.refresh() + expect(state()).toBe(before) + expect(listener).not.toHaveBeenCalled() + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + }) + it('reports a Host without a managed profile as unavailable and keeps the last packages across a failed read', async () => { const { inventory, plugins, face, state, controller } = bench() await controller.load() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 0c472394e3..9df146dd15 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: ddec1f6f9b018747 zh: 9555f132daf8901c /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 4e1f3e827d55571e - zh: 6ccdd5dbae5c886d + en: 27c3b3715866df73 + zh: 65c07b00516149ad /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: 83052e14c3fb34cf zh: 0adb09885723296a diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 2359e442b5..0da0f9f934 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -55,7 +55,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | | `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | -| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | +| `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | @@ -66,7 +66,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. | -| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork; `Terminal` supplies the muted cyan-to-blue prompt in plugin cards and sidebar guide entries. `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | +| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork; `Terminal` supplies the light-blue prompt in plugin cards and sidebar guide entries. `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `GuideArtworkBrowser`/`Files` | Fixed-palette 36×36 browser and folder artwork for sidebar guide entries. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. | | `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The lazy line-token highlighter shared by code preview and diff review. The filename grammar selection is re-exported from `@deepseek-ai/dsh-util-code-language`, the single extension table also behind the read card's persisted short-id `lang` hint. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 58c1742727..f22fa7b540 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -55,7 +55,7 @@ kind: "package-library" | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | | `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | -| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | +| `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | @@ -66,7 +66,7 @@ kind: "package-library" | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 | -| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 固定配色的 36×36 插件插画;`Terminal` 为插件卡片和侧边栏开始页入口提供柔和的青蓝渐变提示符。`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | +| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 固定配色的 36×36 插件插画;`Terminal` 为插件卡片和侧边栏开始页入口提供浅蓝色提示符。`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `GuideArtworkBrowser`/`Files` | 固定配色的 36×36 浏览器与文件夹插画,用于侧栏引导入口。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 | | `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的惰性逐行 token 高亮。文件名 grammar 选择再导出自 `@deepseek-ai/dsh-util-code-language`,即 read 卡片持久化短 id `lang` 提示背后的同一张扩展名表。 | diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 3d0783c422..b997279f54 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -40,7 +40,9 @@ type TooltipLabel = string | (() => string) * bubble would overlap); default 'center'. Ignored for side 'right'. * @param props.portal - render the bubble under document.body, so an ancestor's clipping or its * stacking context (which confines the bubble's z-index to that context) cannot hide it. - * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. + * @param props.delayMs - hover delay in milliseconds (default 0). + * @param props.focusDelayMs - keyboard focus delay in milliseconds (default 0); blur, click, + * mouse leave, disabling, and unmount cancel a pending show. * @param props.gap - anchor-to-bubble distance in pixels for 'bottom'/'top' bubbles (default 8); * ignored for side 'right'. * @param props.disabled - suppress the bubble while true; the anchor renders identically so @@ -53,7 +55,7 @@ type TooltipLabel = string | (() => string) * anchor dismisses the bubble until the next trigger, and focus arriving after a pointer * interaction (a closing menu refocusing its trigger) never raises it. */ -export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) { +export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -157,16 +159,16 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', }) announce(true) } - const showAfterHoverDelay = () => { + const showAfterDelay = (delay: number) => { cancelShow() - if (delayMs <= 0) { + if (delay <= 0) { show() return } showTimer.current = setTimeout(() => { showTimer.current = null show() - }, delayMs) + }, delay) } const withdraw = () => { setPos(null) @@ -198,13 +200,18 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', {cloneElement(children, { ref: mergedRef, - onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() }, + onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterDelay(delayMs) }, onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); withdraw() }, // Activating the anchor dismisses the bubble: the action often changes // what the anchor now does (pin → unpin), and the click leaves the // anchor focused, which would otherwise pin the relabelled bubble up. onClick: (e) => { children.props.onClick?.(e); triggers.current.focus = false; cancelShow(); withdraw() }, - onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality()) return; triggers.current.focus = true; cancelShow(); show() }, + onFocus: (e) => { + children.props.onFocus?.(e) + if (pointerModality()) return + triggers.current.focus = true + showAfterDelay(focusDelayMs) + }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {portal ? (content !== false && createPortal(content, document.body)) : content} diff --git a/packages/client/ui-primitives/src/plugin-artwork.tsx b/packages/client/ui-primitives/src/plugin-artwork.tsx index a0df4ca4a8..8fdbadb711 100644 --- a/packages/client/ui-primitives/src/plugin-artwork.tsx +++ b/packages/client/ui-primitives/src/plugin-artwork.tsx @@ -12,22 +12,13 @@ import type { IconProps } from './icons/props.ts' */ const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}` -/** Muted cyan-to-blue terminal artwork shared by plugin cards and sidebar guides. */ -export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => { - const uid = useArtworkId() - return ( - - - - - - - - - - - ) -} +/** Light-blue terminal artwork shared by plugin cards and sidebar guide entries. */ +export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => ( + + + + +) /** Agent-loop plugin artwork (four leaves circling a center). */ export const PluginArtworkLoop = ({ size = 36, className }: IconProps) => { @@ -79,13 +70,13 @@ export const PluginArtworkSearch = ({ size = 36, className }: IconProps) => { const uid = useArtworkId() return ( - +
{ expect(container.querySelector('foreignObject div')).not.toBeNull() }) - it('uses a muted cyan-to-blue gradient for both terminal strokes', () => { + it('pairs the search green with the loop upper-right blue-violet in its ring and handle', () => { + const { container } = render() + const ring = container.querySelector('foreignObject div') + expect(ring?.style.background).toBe('conic-gradient(from 90deg, rgb(65, 225, 172) 0deg, rgb(101, 142, 255) 62.0619deg, rgb(65, 225, 172) 360deg)') + expect(container.querySelector('path[stroke]')?.getAttribute('stroke')).toBe('#658EFF') + }) + + it('uses light blue for both terminal strokes', () => { const { container } = render() - const gradient = container.querySelector('linearGradient')! - const colors = [...container.querySelectorAll('stop')].map(stop => stop.getAttribute('stop-color')) - expect(colors).toEqual(['#4DD9D4', '#6085EB']) - expect(gradient.getAttribute('gradientUnits')).toBe('userSpaceOnUse') const strokes = [...container.querySelectorAll('path')].map(path => path.getAttribute('stroke')) - expect(strokes).toEqual([`url(#${gradient.id})`, `url(#${gradient.id})`]) + expect(strokes).toEqual(['#679EFE', '#679EFE']) + expect(container.querySelector('linearGradient')).toBeNull() }) it.each(Object.entries(artworks))('%s renders at 36 by default and repeats without document-global ids', (_name, Artwork) => { diff --git a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx index 3d109a91bf..e681afae06 100644 --- a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx @@ -120,6 +120,85 @@ describe('Tooltip', () => { } }) + describe('keyboard focus delay', () => { + beforeEach(() => { + vi.useFakeTimers() + fireEvent.keyDown(document.body, { key: 'Tab' }) + }) + afterEach(() => { + cleanup() + vi.useRealTimers() + fireEvent.keyDown(document.body, { key: 'Tab' }) + }) + + it('shows at 500ms, but not at 499ms', () => { + const label = vi.fn(() => 'Refresh') + render() + fireEvent.focus(screen.getByText('anchor')) + expect(screen.queryByRole('tooltip')).toBeNull() + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + expect(label).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('tooltip').textContent).toBe('Refresh') + }) + + it.each(['blur', 'click', 'mouseLeave', 'disabled', 'unmount'] as const)( + 'cancels a pending focus bubble on %s', (cancel) => { + const view = render() + const anchor = screen.getByText('anchor') + const initialTimers = vi.getTimerCount() + fireEvent.focus(anchor) + expect(vi.getTimerCount()).toBe(initialTimers + 1) + act(() => { vi.advanceTimersByTime(499) }) + if (cancel === 'disabled') { + view.rerender() + } else if (cancel === 'unmount') { + view.unmount() + } else { + fireEvent[cancel](anchor) + } + expect(vi.getTimerCount()).toBe(initialTimers) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + if (cancel === 'disabled') { + view.rerender() + expect(screen.queryByRole('tooltip')).toBeNull() + } + }, + ) + + it('keeps default focus immediate and cancels a pending hover delay', () => { + render() + const anchor = screen.getByText('anchor') + const initialTimers = vi.getTimerCount() + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Refresh') + expect(vi.getTimerCount()).toBe(initialTimers) + }) + + it('does not schedule pointer-driven focus and resumes delay after a key press', () => { + render() + const anchor = screen.getByText('anchor') + const initialTimers = vi.getTimerCount() + fireEvent.pointerDown(document.body) + fireEvent.focus(anchor) + expect(vi.getTimerCount()).toBe(initialTimers) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.blur(anchor) + fireEvent.keyDown(document.body, { key: 'Tab' }) + fireEvent.focus(anchor) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('tooltip').textContent).toBe('Refresh') + }) + }) + it('shows the bubble to the right on hover and hides it on leave', () => { render( diff --git a/packages/experimental/auto-review/icon.svg b/packages/experimental/auto-review/icon.svg index 47d6f7aef3..dcee81cdf9 100644 --- a/packages/experimental/auto-review/icon.svg +++ b/packages/experimental/auto-review/icon.svg @@ -5,8 +5,8 @@ - - + + diff --git a/packages/experimental/schedule-bundle/icon.svg b/packages/experimental/schedule-bundle/icon.svg index 9b778ac2c9..e300011998 100644 --- a/packages/experimental/schedule-bundle/icon.svg +++ b/packages/experimental/schedule-bundle/icon.svg @@ -1,9 +1,18 @@ - + + - - - + + + + + + + + + + + diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 6659ef98b7..ef586cd268 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2801,6 +2801,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', occupants: [ 'client-ui-chat QuotaNoticeHost id \'chat.quota-notice\'', + 'client-ui-plugin-manager PluginRefreshToast id \'plugin-manager.refresh-toast\'', 'client-ui-schedule ScheduleDeleteToast id \'schedule.delete-toast\'', 'client-ui-settings-account DesktopOnboardingEntry id \'desktop-onboarding\'', 'client-ui-settings-account AccountPlatformHost id \'account.platform-page\'', From f057cc37c478fe60786e3fc829273e357ff7c1b3 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sun, 27 Sep 2026 01:44:00 +0800 Subject: [PATCH 09/35] fix(plugins): guard IME submission and refine automation icon --- .../plugin-manager/ime-enter.expected.md | 62 +++++++++++ apps/web/tests/plugin-manager.e2e.ts | 101 ++++++++++++++++++ .../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.tsx | 31 +++++- .../tests/components.client.spec.tsx | 96 ++++++++++++++++- .../experimental/schedule-bundle/icon.svg | 10 +- 8 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 apps/web/tests/expected/plugin-manager/ime-enter.expected.md diff --git a/apps/web/tests/expected/plugin-manager/ime-enter.expected.md b/apps/web/tests/expected/plugin-manager/ime-enter.expected.md new file mode 100644 index 0000000000..732a5c2d20 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/ime-enter.expected.md @@ -0,0 +1,62 @@ +Synthetic browser KeyboardEvents, not an OS input-method test. +package / isComposing=true: inspect=0, install=0; editable; value retained +- textbox "包名或地址": + - /placeholder: 例如 dsh-plugin-whale-pet + - text: ime-confirm-package +package / Safari isComposing=false, keyCode=229: inspect=0, install=0; editable; value retained +- textbox "包名或地址": + - /placeholder: 例如 dsh-plugin-whale-pet + - text: ime-confirm-package +package / compositionstart + unmarked Enter: inspect=0, install=0; editable; value retained +- textbox "包名或地址": + - /placeholder: 例如 dsh-plugin-whale-pet + - text: ime-confirm-package +package / compositionend + immediate unmarked Enter: inspect=0, install=0; editable; value retained +- textbox "包名或地址": + - /placeholder: 例如 dsh-plugin-whale-pet + - text: ime-confirm-package +package / compositionend + 9ms unmarked Enter: inspect=0, install=0; editable; value retained +- textbox "包名或地址": + - /placeholder: 例如 dsh-plugin-whale-pet + - text: ime-confirm-package +package / plain Enter: inspect=1, install=1 +- dialog "插件安装失败": + - button "返回编辑": 编辑 + - button "关闭" + - alert: 插件安装失败 + - paragraph: "IME fixture: no package was installed" + - paragraph: ime-confirm-package + - paragraph: 版本 1.0.0 + - button "查看安装详情" + - button "重试" +custom registry / isComposing=true: inspect=0, install=0; editable; value retained +- textbox "自定义地址": + - /placeholder: https://npm.example.com/ + - text: https://registry.example.test/ +custom registry / Safari isComposing=false, keyCode=229: inspect=0, install=0; editable; value retained +- textbox "自定义地址": + - /placeholder: https://npm.example.com/ + - text: https://registry.example.test/ +custom registry / compositionstart + unmarked Enter: inspect=0, install=0; editable; value retained +- textbox "自定义地址": + - /placeholder: https://npm.example.com/ + - text: https://registry.example.test/ +custom registry / compositionend + immediate unmarked Enter: inspect=0, install=0; editable; value retained +- textbox "自定义地址": + - /placeholder: https://npm.example.com/ + - text: https://registry.example.test/ +custom registry / compositionend + 9ms unmarked Enter: inspect=0, install=0; editable; value retained +- textbox "自定义地址": + - /placeholder: https://npm.example.com/ + - text: https://registry.example.test/ +custom registry / plain Enter: inspect=1, install=1 +- dialog "插件安装失败": + - button "返回编辑": 编辑 + - button "关闭" + - alert: 插件安装失败 + - paragraph: "IME fixture: no package was installed" + - paragraph: ime-confirm-registry + - paragraph: 版本 1.0.0 + - button "查看安装详情" + - button "重试" +Profile manifest unchanged; controlled Host result performed no package installation. diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index e7c0395e18..b7ac3a48f2 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -598,6 +598,107 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('keeps IME confirmation Enter in install fields and submits only a plain Enter', async () => { + const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const manager = scaffold.ctx.pluginManager + const manifest = await homeFile('profiles', 'scaffold', 'package.json') + const registrySpy = vi.spyOn(manager, 'registries').mockResolvedValue({ + registry: null, fallbackRegistries: [], resolved: 'https://registry.npmjs.org/', + }) + const inspectSpy = vi.spyOn(manager, 'inspect').mockImplementation(async (spec, options) => ({ + status: 'accepted', kind: 'registry', name: spec, version: '1.0.0', bundle: true, registry: options?.registry ?? null, + })) + const installSpy = vi.spyOn(manager, 'installBundle').mockImplementation(async spec => ({ + changed: false, application: 'failed', stage: 'install', target: spec, + error: { code: 'operation-error', diagnostic: 'IME fixture: no package was installed' }, + })) + try { + const probe = await context.newPage() + const consoleWatch = watchConsole(probe) + onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-ime-enter')) + await probe.clock.install() + await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const panel = probe.locator('[data-plugin-panel]') + await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 }) + const trace: string[] = ['Synthetic browser KeyboardEvents, not an OS input-method test.'] + for (const target of ['package', 'custom registry'] as const) { + inspectSpy.mockClear() + installSpy.mockClear() + await panel.getByRole('button', { name: '添加插件', exact: true }).click() + const dialog = probe.getByRole('dialog', { name: '添加插件', exact: true }) + await dialog.waitFor({ timeout: 10_000 }) + const spec = target === 'package' ? 'ime-confirm-package' : 'ime-confirm-registry' + const specField = dialog.getByRole('textbox', { name: '包名或地址', exact: true }) + await specField.fill(spec) + let field = specField + const customRegistry = 'https://registry.example.test/' + if (target === 'custom registry') { + await dialog.getByRole('button', { name: /^安装源/ }).click() + field = probe.locator('[data-install-registry]').getByRole('textbox', { name: '自定义地址', exact: true }) + await field.fill(customRegistry) + } + const value = target === 'package' ? spec : customRegistry + const assertUnsubmitted = async (label: string) => { + expect(await field.inputValue()).toBe(value) + expect(await field.isEditable()).toBe(true) + expect(await dialog.getByRole('button', { name: '安装', exact: true }).isEnabled()).toBe(true) + expect(inspectSpy).not.toHaveBeenCalled() + expect(installSpy).not.toHaveBeenCalled() + trace.push(`${target} / ${label}: inspect=0, install=0; editable; value retained`, await field.ariaSnapshot()) + } + await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000)) + try { + for (const event of [ + { label: 'isComposing=true', isComposing: true, keyCode: 13 }, + { label: 'Safari isComposing=false, keyCode=229', isComposing: false, keyCode: 229 }, + ]) { + await field.dispatchEvent('keydown', { + key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, + isComposing: event.isComposing, keyCode: event.keyCode, + }) + await assertUnsubmitted(event.label) + } + const unmarkedEnter = { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, isComposing: false, keyCode: 13 } + await field.dispatchEvent('compositionstart') + await field.dispatchEvent('keydown', unmarkedEnter) + await assertUnsubmitted('compositionstart + unmarked Enter') + await field.dispatchEvent('compositionend') + await field.dispatchEvent('keydown', unmarkedEnter) + await assertUnsubmitted('compositionend + immediate unmarked Enter') + await probe.clock.runFor(9) + await field.dispatchEvent('keydown', unmarkedEnter) + await assertUnsubmitted('compositionend + 9ms unmarked Enter') + await probe.clock.runFor(2) + } finally { + await probe.clock.resume() + } + await field.press('Enter') + await expect.poll(() => inspectSpy.mock.calls.length, { timeout: 10_000 }).toBe(1) + await expect.poll(() => installSpy.mock.calls.length, { timeout: 10_000 }).toBe(1) + expect(inspectSpy.mock.calls[0]?.[0]).toBe(spec) + expect(installSpy.mock.calls[0]?.[0]).toBe(spec) + expect(installSpy.mock.calls[0]?.[1]).toMatchObject({ enabled: false, registry: target === 'package' ? null : customRegistry }) + const failed = probe.getByRole('dialog', { name: '插件安装失败', exact: true }) + await failed.waitFor({ timeout: 10_000 }) + trace.push(`${target} / plain Enter: inspect=1, install=1`, await failed.ariaSnapshot()) + await failed.getByRole('button', { name: '关闭', exact: true }).click() + await failed.waitFor({ state: 'hidden', timeout: 10_000 }) + } + expect(await homeFile('profiles', 'scaffold', 'package.json')).toBe(manifest) + expect(consoleWatch.pageErrors).toEqual([]) + trace.push('Profile manifest unchanged; controlled Host result performed no package installation.') + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ime-enter.expected.md'), trace.join('\n'), MODE) + } finally { + try { await context.close() } + finally { + registrySpy.mockRestore() + inspectSpy.mockRestore() + installSpy.mockRestore() + } + } + }, 60_000) + it('checks a spec before installing it and words what the check refused', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install')) const panel = await openPluginsPanel() diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index b31911ea69..b654a8d801 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -18,8 +18,8 @@ en: 37ba2a927f27ef7e zh: 562fb22a02f0acd4 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: 4816e6ab0ee26abe - zh: b16bb7cdfacd6c74 + en: 69cb8163271444fc + zh: c4f345d9344ee93c /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: abc90531efa889d5 zh: 0562570f85e69c35 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 661ee09ae1..5888894154 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -39,7 +39,7 @@ On first use, when pnpm uses the unconfigured official npm registry and npmmirro The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved. -**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque shared menu surface, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry menu, and focuses the address with one error-colored border. They float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque shared menu surface, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry menu, and focuses the address with one error-colored border. They float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 3729c9a963..7a0f53e917 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。 -**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明的共享菜单面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源菜单并聚焦地址框,仅显示错误色边框。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明的共享菜单面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源菜单并聚焦地址框,仅显示错误色边框。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 19503fdcdd..df9a064490 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -759,6 +759,19 @@ function SubjectCard({ subject, t }: { readonly subject: InstallSubject; readonl ) } +/** Track one install field's composition, including Safari's 10ms post-composition Enter window. */ +function useInstallComposition(active: boolean) { + const composition = useRef({ active: false, until: 0 }) + useEffect(() => { composition.current = { active: false, until: 0 } }, [active]) + return { + onCompositionStart: () => { composition.current.active = true }, + onCompositionEnd: () => { composition.current = { active: false, until: Date.now() + 10 } }, + onBlur: () => { composition.current = { active: false, until: 0 } }, + isComposing: (event: KeyboardEvent) => event.isComposing || Reflect.get(event, 'keyCode') === 229 + || composition.current.active || Date.now() < composition.current.until, + } +} + /** * The install dialog: the spec and its check, then the installing, installed, * and failed screens over the same subject card. A failed run that left @@ -798,6 +811,8 @@ function InstallDialog({ const registryPanelRef = useRef(null) const registryCustomRef = useRef(null) const registryShown = install.registryOpen && phase === 'idle' + const specComposition = useInstallComposition(install.open && phase === 'idle') + const registryComposition = useInstallComposition(install.open && registryShown) const registryPosition = useAnchoredPosition({ open: registryShown, anchorRef: registryToggleRef, panelRef: registryPanelRef, align: 'end', gap: 6, margin: 12, }) @@ -888,7 +903,13 @@ function InstallDialog({ aria-invalid={install.inputError !== null} aria-describedby={install.inputError === null ? undefined : errorId} onChange={(event) => { onEditSpec(event.currentTarget.value) }} - onKeyDown={(event) => { if (event.key === 'Enter' && !empty && !checking) onRun() }} + onCompositionStart={specComposition.onCompositionStart} + onCompositionEnd={specComposition.onCompositionEnd} + onBlur={specComposition.onBlur} + onKeyDown={(event) => { + if (specComposition.isComposing(event.nativeEvent)) return + if (event.key === 'Enter' && !empty && !checking) onRun() + }} />
{inputSentence === null @@ -1004,7 +1025,13 @@ function InstallDialog({ aria-describedby={install.registryError ? registryErrorId : undefined} onFocus={() => { if (choice.kind !== 'custom') onChooseRegistry({ kind: 'custom', url: customRegistryDraft }) }} onChange={(event) => { onChooseRegistry({ kind: 'custom', url: event.currentTarget.value }) }} - onKeyDown={(event) => { if (event.key === 'Enter' && !empty) onRun() }} + onCompositionStart={registryComposition.onCompositionStart} + onCompositionEnd={registryComposition.onCompositionEnd} + onBlur={registryComposition.onBlur} + onKeyDown={(event) => { + if (registryComposition.isComposing(event.nativeEvent)) return + if (event.key === 'Enter' && !empty) onRun() + }} /> {install.registryError ? 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 7e94a9734e..153ab26a86 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import type { ConfigPageForm } from '../src/client/slot-contract.ts' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client' @@ -1058,6 +1058,100 @@ describe('PluginManagerPage', () => { expect(within(detail).getByText(en.rowStateIdle)).toBeTruthy() }) + describe.each(['package', 'custom registry'] as const)('%s input IME confirmation', (input) => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { cleanup(); vi.useRealTimers() }) + const inputName = input === 'package' ? en.installSpecLabel : en.registryCustom + const open: InstallState = { + ...IDLE_INSTALL, open: true, spec: 'dsh-new', registryOpen: true, + registry: { kind: 'custom', url: 'https://npm.corp.example/' }, + } + const enter = (field: HTMLElement) => { + // oxlint-disable-next-line typescript/no-deprecated -- Exercise native Enter's legacy keyCode in the IME regression. + const event = new KeyboardEvent('keydown', { key: 'Enter', isComposing: false, keyCode: 13, bubbles: true, cancelable: true }) + fireEvent(field, event) + expect(event.defaultPrevented).toBe(false) + } + + it('guards active composition and the first 10ms after compositionend without native flags', () => { + const { actions } = renderTab({ install: open }) + const field = screen.getByRole('textbox', { name: inputName }) + fireEvent.compositionStart(field) + enter(field) + expect(actions.runInstall).not.toHaveBeenCalled() + fireEvent.compositionEnd(field) + enter(field) + act(() => { vi.advanceTimersByTime(9) }) + enter(field) + expect(actions.runInstall).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + enter(field) + expect(actions.runInstall).toHaveBeenCalledOnce() + }) + + it('keeps composition state independent from the other input', () => { + const { actions } = renderTab({ install: open }) + const field = screen.getByRole('textbox', { name: inputName }) + const other = screen.getByRole('textbox', { name: input === 'package' ? en.registryCustom : en.installSpecLabel }) + fireEvent.compositionStart(field) + enter(other) + expect(actions.runInstall).toHaveBeenCalledOnce() + enter(field) + expect(actions.runInstall).toHaveBeenCalledOnce() + }) + + it.each(['active', 'ended'] as const)('clears %s composition on blur and dialog reopen', (phase) => { + const { actions, set } = renderTab({ install: open }) + const field = screen.getByRole('textbox', { name: inputName }) + fireEvent.compositionStart(field) + if (phase === 'ended') fireEvent.compositionEnd(field) + fireEvent.blur(field) + enter(field) + expect(actions.runInstall).toHaveBeenCalledOnce() + fireEvent.compositionStart(field) + if (phase === 'ended') fireEvent.compositionEnd(field) + set({ install: { ...open, open: false } }) + set({ install: open }) + enter(screen.getByRole('textbox', { name: inputName })) + expect(actions.runInstall).toHaveBeenCalledTimes(2) + }) + + if (input === 'custom registry') { + it('clears composition when the registry menu closes and reopens', () => { + const { actions, set } = renderTab({ install: open }) + fireEvent.compositionStart(screen.getByRole('textbox', { name: inputName })) + set({ install: { ...open, registryOpen: false } }) + set({ install: open }) + enter(screen.getByRole('textbox', { name: inputName })) + expect(actions.runInstall).toHaveBeenCalledOnce() + }) + } + + it.each([ + { signal: 'isComposing', isComposing: true, keyCode: 13 }, + { signal: 'legacy keyCode 229', isComposing: false, keyCode: 229 }, + ])('leaves $signal Enter to the IME and installs on the next ordinary Enter', ({ isComposing, keyCode }) => { + const { actions } = renderTab({ + install: { + ...IDLE_INSTALL, open: true, spec: 'dsh-new', registryOpen: input === 'custom registry', + registry: { kind: 'custom', url: 'https://npm.corp.example/' }, + }, + }) + const field = screen.getByRole('textbox', { name: input === 'package' ? en.installSpecLabel : en.registryCustom }) + fireEvent.compositionStart(field) + if (!isComposing) fireEvent.compositionEnd(field) + // oxlint-disable-next-line typescript/no-deprecated -- Exercise the legacy IME signal when isComposing is false. + const confirm = new KeyboardEvent('keydown', { key: 'Enter', isComposing, keyCode, bubbles: true, cancelable: true }) + fireEvent(field, confirm) + expect(actions.runInstall).not.toHaveBeenCalled() + expect(confirm.defaultPrevented).toBe(false) + if (isComposing) fireEvent.compositionEnd(field) + act(() => { vi.advanceTimersByTime(10) }) + fireEvent.keyDown(field, { key: 'Enter', isComposing: false, keyCode: 13 }) + expect(actions.runInstall).toHaveBeenCalledOnce() + }) + }) + it('takes a spec, checks it, and words what the check refused', () => { const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) expect(screen.getByText(en.installDescription)).toBeTruthy() diff --git a/packages/experimental/schedule-bundle/icon.svg b/packages/experimental/schedule-bundle/icon.svg index e300011998..90e945c5a3 100644 --- a/packages/experimental/schedule-bundle/icon.svg +++ b/packages/experimental/schedule-bundle/icon.svg @@ -1,16 +1,16 @@ - - + + - + - + - + From 9fabb67bca088b6dff6bb6550471aca34a4bc2b5 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sun, 27 Sep 2026 02:28:09 +0800 Subject: [PATCH 10/35] test(plugins): trim redundant refresh/tooltip coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the plugin-manager refresh e2e to a single failure round: the store's success and retry outcomes are covered deterministically by manager-store.client.spec.ts, while the e2e keeps its unique value — real 500ms tooltip timing, a running spinner animation, aria-busy, and the portaled failure toast over kept cards. Regenerate refresh.expected.md. Remove the components spec tooltip-delay case; ui-primitives' tooltip.client.spec.tsx already covers 499/500ms hover/focus delay, early cancellation, and portaling. PluginManagerPage.tsx stays at 100% coverage. --- .../plugin-manager/refresh.expected.md | 12 ------ apps/web/tests/plugin-manager.e2e.ts | 17 +++----- .../tests/components.client.spec.tsx | 43 ------------------- 3 files changed, 7 insertions(+), 65 deletions(-) diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md index 24ac557b62..75dda78845 100644 --- a/apps/web/tests/expected/plugin-manager/refresh.expected.md +++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md @@ -4,21 +4,9 @@ Hover at 500ms: Focus at 499ms: 0 tooltips Focus at 500ms: - tooltip "刷新" -success: pending -- button "刷新" [disabled] -{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} -success: settled -- button "刷新" -{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} failure: pending - button "刷新" [disabled] {"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} failure: settled - button "刷新" {"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} -retry: pending -- button "刷新" [disabled] -{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} -retry: settled -- button "刷新" -{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 7fded39f73..bd36ca4970 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -283,14 +283,15 @@ describe('web e2e: plugin manager', () => { await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000)) const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles') try { - for (const outcome of ['success', 'failure', 'retry'] as const) { + { + // One failure round exercises the full path: kept cards and spinner while pending, + // then the failure toast with no inline error over the stale cards. Success and retry + // outcomes are the store's deterministic concern, covered by manager-store.client.spec.ts. + const outcome = 'failure' as const const release = Promise.withResolvers() const called = spy.mock.calls.length spy.mockImplementationOnce(() => { - const read = release.promise.then(() => { - if (outcome === 'failure') throw new Error('Fixture plugin refresh failed') - return listBundles() - }) + const read = release.promise.then(() => { throw new Error('Fixture plugin refresh failed') }) reads.push(read) return read }) @@ -320,11 +321,7 @@ describe('web e2e: plugin manager', () => { expect(await panel.getByRole('alert').count()).toBe(0) expect(await panel.getByRole('button', { name: '重试', exact: true }).count()).toBe(0) const toasts = probe.locator('body > [role="alert"]') - if (outcome === 'failure') { - await expect.poll(() => toasts.allTextContents()).toEqual(['刷新失败,请重试']) - } else { - expect(await toasts.count()).toBe(0) - } + await expect.poll(() => toasts.allTextContents()).toEqual(['刷新失败,请重试']) await recordRefresh(`${outcome}: settled`) } finally { release.resolve(undefined) 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 153ab26a86..625672a34d 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -364,49 +364,6 @@ describe('PluginManagerPage', () => { } }) - it.each(['hover', 'keyboard'] as const)('shows a portaled refresh tooltip after 500ms of %s and cancels early departure', (trigger) => { - vi.useFakeTimers() - try { - const { unmount } = renderTab() - const refresh = screen.getByRole('button', { name: en.refresh }) - const enter = () => { - if (trigger === 'hover') fireEvent.mouseEnter(refresh) - else { - fireEvent.keyDown(document, { key: 'Tab' }) - fireEvent.focus(refresh) - } - } - const leave = () => { - if (trigger === 'hover') fireEvent.mouseLeave(refresh) - else fireEvent.blur(refresh) - } - expect(refresh.getAttribute('title')).toBeNull() - enter() - act(() => { vi.advanceTimersByTime(499) }) - expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() - leave() - act(() => { vi.advanceTimersByTime(1) }) - expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() - enter() - act(() => { vi.advanceTimersByTime(499) }) - expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() - act(() => { vi.advanceTimersByTime(1) }) - const tooltip = screen.getByRole('tooltip', { hidden: true }) - expect(tooltip.textContent).toBe(en.refresh) - expect(tooltip.parentElement).toBe(document.body) - fireEvent.click(refresh) - expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() - leave() - enter() - unmount() - act(() => { vi.advanceTimersByTime(500) }) - expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() - } finally { - cleanup() - vi.useRealTimers() - } - }) - it('keeps the read failure and its retry visible while a detail page is open', () => { const { actions, set } = renderTab({ packages: [pkg()] }) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) From eb9efc6265af21e519e9ee17c1ccd802aaf771c5 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sun, 27 Sep 2026 09:46:15 +0800 Subject: [PATCH 11/35] fix(plugins): restore registry group, own opaque material, hold refresh spinner The install registry picker used MenuSurface and overrode the theme-owned menu material tokens, which the shared-menu-material gate rejects. Return the picker to a fieldset (its native group role) and paint an opaque bg-layer-2 surface so the form controls stay readable without touching menu tokens. This also restores the group's radios to the accessible tree. Align plugin-install-registry.e2e and its snapshots with the PR's visible text: registry option names drop the parentheses around the host, the custom address field is no longer disabled, and the custom hint spells out http(s) and ~/.npmrc. Hold the manual-refresh spinner to a 400ms minimum so a fast read does not flash it, clearing to idle (or the failure toast) only after the minimum, and dropping a refresh that settles after disposal. Prune the retired no-unknown-casts baseline entry for browser-plugin.client.spec. --- .../fastest-default.expected.md | 12 ++++----- .../official-default.expected.md | 12 ++++----- .../picker.expected.md | 12 ++++----- apps/web/tests/plugin-install-registry.e2e.ts | 4 +-- .../src/client/PluginManagerPage.module.css | 6 ++--- .../src/client/PluginManagerPage.tsx | 9 +++---- .../src/client/manager-store.ts | 17 +++++++++--- .../tests/manager-store.client.spec.ts | 27 +++++++++++++++++++ scripts/no-unknown-casts.baseline.json | 3 --- 9 files changed, 67 insertions(+), 35 deletions(-) diff --git a/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md b/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md index ff8c008df0..91c3574956 100644 --- a/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md @@ -1,10 +1,10 @@ - group "从哪个 npm 源下载插件": - - radio "npm 官方源(registry.npmjs.org)" - - text: npm 官方源(registry.npmjs.org) - - radio "中国大陆镜像源(registry.npmmirror.com)" [checked] - - text: 中国大陆镜像源(registry.npmmirror.com) + - radio "npm 官方源 registry.npmjs.org" + - text: npm 官方源 registry.npmjs.org + - radio "中国大陆镜像源 registry.npmmirror.com" [checked] + - text: 中国大陆镜像源 registry.npmmirror.com - radio "自定义地址" - text: 自定义地址 - - textbox "自定义地址" [disabled]: + - textbox "自定义地址": - /placeholder: https://npm.example.com/ - - text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里 + - text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。 diff --git a/apps/web/tests/expected/plugin-install-registry/official-default.expected.md b/apps/web/tests/expected/plugin-install-registry/official-default.expected.md index 9c219b83e0..944fe05716 100644 --- a/apps/web/tests/expected/plugin-install-registry/official-default.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/official-default.expected.md @@ -1,10 +1,10 @@ - group "从哪个 npm 源下载插件": - - radio "npm 官方源(registry.npmjs.org)" [checked] - - text: npm 官方源(registry.npmjs.org) - - radio "中国大陆镜像源(registry.npmmirror.com)" - - text: 中国大陆镜像源(registry.npmmirror.com) + - radio "npm 官方源 registry.npmjs.org" [checked] + - text: npm 官方源 registry.npmjs.org + - radio "中国大陆镜像源 registry.npmmirror.com" + - text: 中国大陆镜像源 registry.npmmirror.com - radio "自定义地址" - text: 自定义地址 - - textbox "自定义地址" [disabled]: + - textbox "自定义地址": - /placeholder: https://npm.example.com/ - - text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里 + - text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。 diff --git a/apps/web/tests/expected/plugin-install-registry/picker.expected.md b/apps/web/tests/expected/plugin-install-registry/picker.expected.md index ff8c008df0..91c3574956 100644 --- a/apps/web/tests/expected/plugin-install-registry/picker.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/picker.expected.md @@ -1,10 +1,10 @@ - group "从哪个 npm 源下载插件": - - radio "npm 官方源(registry.npmjs.org)" - - text: npm 官方源(registry.npmjs.org) - - radio "中国大陆镜像源(registry.npmmirror.com)" [checked] - - text: 中国大陆镜像源(registry.npmmirror.com) + - radio "npm 官方源 registry.npmjs.org" + - text: npm 官方源 registry.npmjs.org + - radio "中国大陆镜像源 registry.npmmirror.com" [checked] + - text: 中国大陆镜像源 registry.npmmirror.com - radio "自定义地址" - text: 自定义地址 - - textbox "自定义地址" [disabled]: + - textbox "自定义地址": - /placeholder: https://npm.example.com/ - - text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里 + - text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。 diff --git a/apps/web/tests/plugin-install-registry.e2e.ts b/apps/web/tests/plugin-install-registry.e2e.ts index 043dd70764..54a1dbe3a1 100644 --- a/apps/web/tests/plugin-install-registry.e2e.ts +++ b/apps/web/tests/plugin-install-registry.e2e.ts @@ -75,7 +75,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding expect(await page.getByRole('radio').count()).toBe(0) await registryToggle.click() const options = page.getByRole('group', { name: '从哪个 npm 源下载插件', exact: true }) - const mirror = options.getByRole('radio', { name: '中国大陆镜像源(registry.npmmirror.com)', exact: true }) + const mirror = options.getByRole('radio', { name: '中国大陆镜像源 registry.npmmirror.com', exact: true }) await expect.poll(async () => (await readFile(requestLog, 'utf8')).trim().split('\n').filter(Boolean)).toHaveLength(2) const pingUrls = (await readFile(requestLog, 'utf8')).trim().split('\n').sort() expect(pingUrls).toEqual(['https://registry.npmjs.org/-/ping', 'https://registry.npmmirror.com/-/ping'].map(url => JSON.stringify(url))) @@ -84,7 +84,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/fastest-default.expected.md', import.meta.url)), await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode()) } else { - expect(await options.getByRole('radio', { name: 'npm 官方源(registry.npmjs.org)', exact: true }).isChecked()).toBe(true) + expect(await options.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true }).isChecked()).toBe(true) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/official-default.expected.md', import.meta.url)), await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode()) await mirror.click() 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 37ff54cc9f..505ae1e096 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -700,9 +700,9 @@ margin: 0; padding: 8px; border: 0; - /* Keep an opaque surface behind the registry form controls. */ - --dsw-menu-surface-fill: var(--dsw-alias-bg-layer-2); - --dsw-menu-backdrop-filter: none; + /* An opaque layer keeps the registry form controls readable over the dialog. */ + background: var(--dsw-alias-bg-layer-2); + border-radius: var(--dsw-radius-lg); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); min-width: 0; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index df9a064490..2dfbb1afee 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -16,7 +16,7 @@ import { Button, IconCheckCircleFillRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineMedium, IconChevronRightOutlineRegular, IconCloseOutlineMedium, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, - IconWarningOutlineRegular, Input, MenuSurface, Modal, + IconWarningOutlineRegular, Input, Modal, PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, @@ -808,7 +808,7 @@ function InstallDialog({ // The registry options float over the dialog from their toggle, so unfolding them never adds to its height; // the store folds them when a run starts, so they show at the spec only. const registryToggleRef = useRef(null) - const registryPanelRef = useRef(null) + const registryPanelRef = useRef(null) const registryCustomRef = useRef(null) const registryShown = install.registryOpen && phase === 'idle' const specComposition = useInstallComposition(install.open && phase === 'idle') @@ -978,9 +978,8 @@ function InstallDialog({ : null} {registryShown ? createPortal( - {t('registryCustomHint')} - , + , document.body, ) : null} diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 35bcc433f9..80018300db 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -107,6 +107,9 @@ export type RegistryChoice = /** The choice shown until the Host has said which registry it asks first: the one pnpm's own configuration names. */ const OFFICIAL_REGISTRY: RegistryChoice = { kind: 'offered', registry: null } +/** Hold the manual-refresh spinner at least this long so a fast read does not flash it. */ +const REFRESH_SPINNER_MIN_MS = 400 + /** * The registry a choice asks, as the Host's install plan compares registries: pnpm's own configuration stands for * the URL it names, once the Host has read it. @@ -727,6 +730,7 @@ export class PluginManagerController { /** Keep manual refresh feedback until its coalesced reads settle, without clearing cached cards. */ private async refresh(): Promise { if (this.disposed || this.getSnapshot().refreshStatus === 'refreshing') return + const startedAt = Date.now() this.patch({ refreshStatus: 'refreshing', ...this.getSnapshot().notice?.kind === 'refresh-failed' ? { notice: null } : {}, @@ -737,10 +741,15 @@ export class PluginManagerController { // A rejected transport request leaves the cached cards available for retry. this.patch({ status: 'error' }) } finally { - const failed = this.getSnapshot().status === 'error' - this.patch(failed && this.hasCachedInventory - ? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } } - : { refreshStatus: failed ? 'failed' : 'idle' }) + // Hold the spinner to its minimum so a fast read does not flash it, then settle. + const remaining = REFRESH_SPINNER_MIN_MS - (Date.now() - startedAt) + if (remaining > 0) await new Promise((resolve) => { setTimeout(resolve, remaining) }) + if (!this.disposed) { + const failed = this.getSnapshot().status === 'error' + this.patch(failed && this.hasCachedInventory + ? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } } + : { refreshStatus: failed ? 'failed' : 'idle' }) + } } } diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index b0e82bb12c..08a0f1e0f8 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -1232,6 +1232,33 @@ describe('PluginManagerController', () => { await loading expect(state()).toBe(before) }) + + it('drops a manual refresh that settles after disposal', async () => { + const gate = deferred>>() + const { plugins, face, state, controller } = bench() + await controller.load() + plugins.listBundles.mockReturnValueOnce(gate.promise) + face.refresh() + await vi.waitFor(() => { expect(state().refreshStatus).toBe('refreshing') }) + controller.dispose() + const before = state() + gate.resolve(ok([{ ...BUNDLE, version: '9.9.9' }])) + await new Promise((resolve) => { setTimeout(resolve, 10) }) + expect(state()).toBe(before) + }) + + it('settles a manual refresh whose read outlasts the spinner minimum without an extra hold', async () => { + const { plugins, face, state, controller } = bench() + await controller.load() + plugins.listBundles.mockImplementationOnce(async () => { + await new Promise((resolve) => { setTimeout(resolve, 420) }) + return ok([{ ...BUNDLE, version: '0.42.0' }]) + }) + face.refresh() + await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }, { timeout: 2_000 }) + expect(state().packages[0]?.version).toBe('0.42.0') + expect(state().status).toBe('ready') + }) it('offers the registries the Host configured, starts from its first, and asks the check and the run to start where the person chose', async () => { const gate = deferred>>() const { plugins, face, state, controller, started } = bench({ diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 761358a17e..a6232e59af 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -619,9 +619,6 @@ "packages/client/ui-plan/tests/plan-resource.client.spec.ts": { "fa53d5cd55d87b522c83a98968e3a2bf933b1cc1b84d2f009e79d6c702099786": 1 }, - "packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx": { - "04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1 - }, "packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx": { "6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 1 }, From db5ed228846680b2c6f8426029632e12127525e3 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sun, 27 Sep 2026 12:42:30 +0800 Subject: [PATCH 12/35] test(plugins): make the refresh spinner hold deterministic Extract settleRefresh() so the spinner-minimum hold has one settle point, advance the e2e fake clock past the 400ms hold before asserting the button restores, and drive the disposal-during-hold store test on fake timers for both success and failure reads. --- apps/web/tests/plugin-manager.e2e.ts | 1 + .../src/client/manager-store.ts | 16 +++++---- .../tests/manager-store.client.spec.ts | 34 ++++++++++++------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index bd36ca4970..5381b78a67 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -312,6 +312,7 @@ describe('web e2e: plugin manager', () => { await recordRefresh(`${outcome}: pending`) release.resolve(undefined) + await probe.clock.runFor(400) await expect.poll(() => refresh.isEnabled()).toBe(true) expect(await refresh.getAttribute('aria-busy')).toBe('false') expect(await panel.getAttribute('aria-busy')).toBe('false') diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 80018300db..166702d4d0 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -744,15 +744,19 @@ export class PluginManagerController { // Hold the spinner to its minimum so a fast read does not flash it, then settle. const remaining = REFRESH_SPINNER_MIN_MS - (Date.now() - startedAt) if (remaining > 0) await new Promise((resolve) => { setTimeout(resolve, remaining) }) - if (!this.disposed) { - const failed = this.getSnapshot().status === 'error' - this.patch(failed && this.hasCachedInventory - ? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } } - : { refreshStatus: failed ? 'failed' : 'idle' }) - } + this.settleRefresh() } } + /** Publish refresh feedback only before disposal. */ + private settleRefresh(): void { + if (this.disposed) return + const failed = this.getSnapshot().status === 'error' + this.patch(failed && this.hasCachedInventory + ? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } } + : { refreshStatus: failed ? 'failed' : 'idle' }) + } + private async read(): Promise { try { do { diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 08a0f1e0f8..937c879abf 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -1233,18 +1233,28 @@ describe('PluginManagerController', () => { expect(state()).toBe(before) }) - it('drops a manual refresh that settles after disposal', async () => { - const gate = deferred>>() - const { plugins, face, state, controller } = bench() - await controller.load() - plugins.listBundles.mockReturnValueOnce(gate.promise) - face.refresh() - await vi.waitFor(() => { expect(state().refreshStatus).toBe('refreshing') }) - controller.dispose() - const before = state() - gate.resolve(ok([{ ...BUNDLE, version: '9.9.9' }])) - await new Promise((resolve) => { setTimeout(resolve, 10) }) - expect(state()).toBe(before) + it.each([false, true])('drops refresh feedback when disposed during the spinner hold (failure: %s)', async (failure) => { + vi.useFakeTimers() + try { + const { plugins, face, state, controller } = bench() + await controller.load() + plugins.listBundles.mockResolvedValueOnce(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE])) + face.refresh() + await vi.advanceTimersByTimeAsync(0) + expect(state().refreshStatus).toBe('refreshing') + expect(vi.getTimerCount()).toBe(1) + const before = state() + const listener = vi.fn() + const unsubscribe = face.hooks.pluginManager.subscribe(listener) + onTestFinished(unsubscribe) + controller.dispose() + await vi.advanceTimersByTimeAsync(400) + expect(state()).toBe(before) + expect(listener).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } }) it('settles a manual refresh whose read outlasts the spinner minimum without an extra hold', async () => { From 69b55fb53b562c7eee17ef2caadc407b9762fd9b Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sun, 27 Sep 2026 14:25:27 +0800 Subject: [PATCH 13/35] fix(plugins): preserve registry keyboard selection and focus feedback --- .../invalid-registry.expected.md | 58 ++++++++- apps/web/tests/plugin-install-cancel.e2e.ts | 51 ++++++-- apps/web/tests/plugin-manager.e2e.ts | 58 ++++++++- .../client/ui-plugin-manager/README.i18n.yaml | 8 +- packages/client/ui-plugin-manager/README.md | 4 +- .../client/ui-plugin-manager/README.zh.md | 4 +- .../src/client/PluginManagerPage.module.css | 7 +- .../src/client/PluginManagerPage.tsx | 24 +++- .../src/client/PluginRefreshToast.tsx | 3 +- .../ui-plugin-manager/src/client/locales.ts | 2 - .../src/client/manager-store.ts | 6 +- .../tests/components.client.spec.tsx | 30 +++++ .../tests/manager-store.client.spec.ts | 117 +++++++++++++----- packages/client/ui-primitives/src/index.ts | 1 + 14 files changed, 314 insertions(+), 59 deletions(-) diff --git a/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md b/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md index 28e142d8ab..3be12c0041 100644 --- a/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md @@ -7,6 +7,60 @@ - text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。 { - "outline": "none", - "boxShadow": "none" + "registry": [ + { + "colorScheme": "light", + "focused": { + "outline": "none", + "boxShadow": "rgb(236, 19, 19) 0px 0px 0px 0.5px inset", + "borderColor": "rgb(236, 19, 19)" + }, + "blurred": { + "outline": "none", + "boxShadow": "none", + "borderColor": "rgb(236, 19, 19)" + } + }, + { + "colorScheme": "dark", + "focused": { + "outline": "none", + "boxShadow": "rgb(242, 90, 90) 0px 0px 0px 0.5px inset", + "borderColor": "rgb(242, 90, 90)" + }, + "blurred": { + "outline": "none", + "boxShadow": "none", + "borderColor": "rgb(242, 90, 90)" + } + } + ], + "packageName": [ + { + "colorScheme": "light", + "focused": { + "outline": "none", + "boxShadow": "rgb(236, 19, 19) 0px 0px 0px 0.5px inset", + "borderColor": "rgb(236, 19, 19)" + }, + "blurred": { + "outline": "none", + "boxShadow": "none", + "borderColor": "rgb(236, 19, 19)" + } + }, + { + "colorScheme": "dark", + "focused": { + "outline": "none", + "boxShadow": "rgb(242, 90, 90) 0px 0px 0px 0.5px inset", + "borderColor": "rgb(242, 90, 90)" + }, + "blurred": { + "outline": "none", + "boxShadow": "none", + "borderColor": "rgb(242, 90, 90)" + } + } + ] } diff --git a/apps/web/tests/plugin-install-cancel.e2e.ts b/apps/web/tests/plugin-install-cancel.e2e.ts index 4b08f194b5..284e5a3a17 100644 --- a/apps/web/tests/plugin-install-cancel.e2e.ts +++ b/apps/web/tests/plugin-install-cancel.e2e.ts @@ -4,11 +4,43 @@ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { chromium, type Locator, type Page } from 'playwright' import { expect, it } from 'vitest' import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts' import { ZH_BROWSER_LOCALE } from './support.ts' +async function invalidInputStyles(page: Page, input: Locator) { + const styles = [] + try { + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + await input.focus() + await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(true) + expect(await input.getAttribute('aria-invalid')).toBe('true') + const focused = await input.evaluate((element) => { + const style = getComputedStyle(element) + return { outline: style.outlineStyle, boxShadow: style.boxShadow, borderColor: style.borderColor } + }) + expect(focused.outline).toBe('none') + expect(focused.boxShadow).toContain('inset') + expect(focused.boxShadow).toContain('0px 0px 0px 0.5px') + expect(focused.boxShadow).toContain(focused.borderColor) + await input.evaluate((element) => { (element as HTMLInputElement).blur() }) + await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(false) + const blurred = await input.evaluate((element) => { + const style = getComputedStyle(element) + return { outline: style.outlineStyle, boxShadow: style.boxShadow, borderColor: style.borderColor } + }) + expect(blurred).toEqual({ outline: 'none', boxShadow: 'none', borderColor: focused.borderColor }) + styles.push({ colorScheme, focused, blurred }) + } + return styles + } finally { + await page.emulateMedia({ colorScheme: null }) + } +} + it('cancels installation, retries and highlights the enabled plugin at 40% alpha, and recovers unknown results', async () => { const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-cancel-')) const overlay = join(scratch, 'cordis.patch.yml') @@ -59,16 +91,19 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha await customAddress.waitFor({ state: 'visible' }) await expect.poll(() => customAddress.evaluate(element => document.activeElement === element)).toBe(true) expect(await customAddress.getAttribute('aria-invalid')).toBe('true') - const invalidStyle = await customAddress.evaluate((element) => { - const style = getComputedStyle(element) - return { outline: style.outlineStyle, boxShadow: style.boxShadow } - }) - expect(invalidStyle).toEqual({ outline: 'none', boxShadow: 'none' }) - await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/invalid-registry.expected.md', import.meta.url)), - `${await captureStableAria(page, '[data-install-registry] > [data-checked="true"]', scaffold.workspaceCwd)}\n\n${JSON.stringify(invalidStyle, null, 2)}`, webSnapshotMode()) + const registryStyles = await invalidInputStyles(page, customAddress) + const invalidRegistryAria = await captureStableAria(page, '[data-install-registry] > [data-checked="true"]', scaffold.workspaceCwd) await registryMenu.getByRole('radio').first().check() await page.keyboard.press('Escape') await registryMenu.waitFor({ state: 'hidden' }) + const packageName = dialog.getByRole('textbox', { name: '包名或地址', exact: true }) + await packageName.fill('./relative') + await packageName.press('Enter') + await expect.poll(() => packageName.getAttribute('aria-invalid')).toBe('true') + const packageStyles = await invalidInputStyles(page, packageName) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/invalid-registry.expected.md', import.meta.url)), + `${invalidRegistryAria}\n\n${JSON.stringify({ registry: registryStyles, packageName: packageStyles }, null, 2)}`, webSnapshotMode()) + await packageName.fill('slow-package') // Hold request delivery and the real Host's cancellation reply independently. // Cancellation reaches the Host before the install it names. const cancellationArrived = Promise.withResolvers() diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 5381b78a67..b7c375ec7e 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -374,6 +374,26 @@ describe('web e2e: plugin manager', () => { await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 }) const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' }) expect(await toggle.getAttribute('aria-checked')).toBe('false') + const card = panel.locator('[data-plugin-package="@fixture/bundle"]') + const open = card.getByRole('button', { name: '查看 @fixture/bundle', exact: true }) + await open.hover() + const hoverRadius = await card.evaluate(element => getComputedStyle(element).borderRadius) + await toggle.focus() + await page.keyboard.press('Shift+Tab') + expect(await open.evaluate(element => element.matches(':focus-visible'))).toBe(true) + const focusRing = await open.evaluate((element) => { + const style = getComputedStyle(element, '::after') + return { + radius: style.borderRadius, + cardRadius: style.getPropertyValue('--dsw-radius-xl').trim(), + outlineStyle: style.outlineStyle, + outlineWidth: Number.parseFloat(style.outlineWidth), + } + }) + expect(focusRing.radius).toBe(hoverRadius) + expect(focusRing.radius).toBe(focusRing.cardRadius) + expect(focusRing.outlineStyle).toBe('solid') + expect(focusRing.outlineWidth).toBeGreaterThan(0) // The profile's own group holds its fixture bundle and the scaffold's defaults bundle; the installation's // optional bundles open the Official group, followed by the official plugins that registered their // configuration, and its other bundles stay off the page. @@ -632,9 +652,43 @@ describe('web e2e: plugin manager', () => { let field = specField const customRegistry = 'https://registry.example.test/' if (target === 'custom registry') { - await dialog.getByRole('button', { name: /^安装源/ }).click() - field = probe.locator('[data-install-registry]').getByRole('textbox', { name: '自定义地址', exact: true }) + const registryToggle = dialog.getByRole('button', { name: /^安装源/ }) + await registryToggle.click() + const registry = probe.locator('[data-install-registry]') + const offered = registry.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true }) + const custom = registry.getByRole('radio', { name: '自定义地址', exact: true }) + field = registry.getByRole('textbox', { name: '自定义地址', exact: true }) + expect(await offered.isChecked()).toBe(true) + expect(await custom.isChecked()).toBe(false) + await offered.focus() + await probe.keyboard.press('Tab') + expect(await field.evaluate(element => element === document.activeElement)).toBe(true) + expect(await offered.isChecked()).toBe(true) + expect(await custom.isChecked()).toBe(false) + await probe.keyboard.press('Shift+Tab') + expect(await offered.evaluate(element => element === document.activeElement)).toBe(true) + await probe.keyboard.press('Shift+Tab') + await registry.waitFor({ state: 'detached' }) + expect(await registryToggle.evaluate(element => element === document.activeElement)).toBe(true) + await probe.keyboard.press('Enter') + await custom.focus() + await probe.keyboard.press('Space') + expect(await custom.isChecked()).toBe(true) + expect(await offered.isChecked()).toBe(false) + expect(await field.evaluate(element => element === document.activeElement)).toBe(true) await field.fill(customRegistry) + await probe.keyboard.press('Shift+Tab') + expect(await custom.evaluate(element => element === document.activeElement)).toBe(true) + await probe.keyboard.press('Tab') + expect(await field.evaluate(element => element === document.activeElement)).toBe(true) + await probe.keyboard.press('Tab') + await registry.waitFor({ state: 'detached' }) + expect(await registryToggle.evaluate(element => element === document.activeElement)).toBe(true) + await probe.keyboard.press('Enter') + await custom.focus() + await probe.keyboard.press('Tab') + expect(await field.evaluate(element => element === document.activeElement)).toBe(true) + expect(await custom.isChecked()).toBe(true) } const value = target === 'package' ? spec : customRegistry const assertUnsubmitted = async (label: string) => { diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 75502d52e7..54caf382d5 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 37ba2a927f27ef7e - zh: 562fb22a02f0acd4 + en: 555eccf8bfdd5029 + zh: e0886cbf6e62bfe3 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: 69cb8163271444fc - zh: c4f345d9344ee93c + en: ad46cfbb1d596b85 + zh: 45b8268255e6c650 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: abc90531efa889d5 zh: 0562570f85e69c35 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 426196173c..81b8a01b43 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -33,7 +33,7 @@ Installed bundles and their plugin rows display their own title and description The first list read shows a short Official-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states. -The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. During a manual refresh, the button shows a spinner and stays disabled until all pending reads finish; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select **Refresh** again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner. +The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. During a manual refresh, the button shows a spinner and stays disabled until all pending reads settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select **Refresh** again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner. ### Installing a bundle @@ -41,7 +41,7 @@ On first use, when pnpm uses the unconfigured official npm registry and npmmirro The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved. -**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque shared menu surface, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry menu, and focuses the address with one error-colored border. They float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields use an error-colored border and add a same-color inset ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index ed8c51dc4b..a9e4d6adcc 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" 首次读取列表时,页面显示一条较短的「官方」标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 -**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取结束;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择**刷新**即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。 +**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择**刷新**即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。 ### 安装一个组合包 @@ -41,7 +41,7 @@ kind: "package-reference" Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。 -**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明的共享菜单面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源菜单并聚焦地址框,仅显示错误色边框。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时使用错误色边框,聚焦时加上同色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。 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 505ae1e096..f2241d5bb3 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -271,7 +271,7 @@ content: ''; position: absolute; inset: 0; - border-radius: inherit; + border-radius: var(--dsw-radius-xl); } .cardOpen:focus-visible { @@ -514,6 +514,11 @@ box-shadow: none; } +.installField input[type='text'][aria-invalid='true']:focus, +.registryCustomField[aria-invalid='true']:focus { + box-shadow: inset 0 0 0 0.5px var(--dsw-alias-state-error-primary); +} + /* The guide under the field: opened by a text button, it says what an ID is and shows the three forms with examples. */ .guideToggle { display: inline-flex; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 2dfbb1afee..ced0d9c348 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -16,7 +16,7 @@ import { Button, IconCheckCircleFillRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineMedium, IconChevronRightOutlineRegular, IconCloseOutlineMedium, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, - IconWarningOutlineRegular, Input, Modal, + IconWarningOutlineRegular, Input, Modal, pointerModality, PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, @@ -985,6 +985,19 @@ function InstallDialog({ style={registryPosition ?? { visibility: 'hidden', left: 0, top: 0 }} data-install-registry aria-label={t('registryLegend')} + onKeyDown={(event) => { + if (event.key !== 'Tab' || event.ctrlKey || event.altKey || event.metaKey || event.nativeEvent.isComposing) return + event.preventDefault() + event.stopPropagation() + const radio = event.currentTarget.querySelector('input[type="radio"]:checked') + const field = registryCustomRef.current + if (event.shiftKey && event.target === field) radio?.focus() + else if (!event.shiftKey && event.target !== field) field?.focus() + else { + onToggleRegistry() + registryToggleRef.current?.focus() + } + }} > {offeredRegistries(install.registries).map((registry) => { const checked = choice.kind === 'offered' && choice.registry === registry @@ -1009,7 +1022,10 @@ function InstallDialog({ type="radio" name={registryId} checked={choice.kind === 'custom'} - onChange={() => { registryCustomRef.current?.focus() }} + onChange={() => { + onChooseRegistry({ kind: 'custom', url: customRegistryDraft }) + registryCustomRef.current?.focus() + }} /> {t('registryCustom')} @@ -1022,7 +1038,9 @@ function InstallDialog({ value={choice.kind === 'custom' ? choice.url : customRegistryDraft} aria-invalid={install.registryError} aria-describedby={install.registryError ? registryErrorId : undefined} - onFocus={() => { if (choice.kind !== 'custom') onChooseRegistry({ kind: 'custom', url: customRegistryDraft }) }} + onFocus={() => { + if (pointerModality() && choice.kind !== 'custom') onChooseRegistry({ kind: 'custom', url: customRegistryDraft }) + }} onChange={(event) => { onChooseRegistry({ kind: 'custom', url: event.currentTarget.value }) }} onCompositionStart={registryComposition.onCompositionStart} onCompositionEnd={registryComposition.onCompositionEnd} diff --git a/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx b/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx index b83bcce545..b573026f52 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx @@ -1,4 +1,5 @@ /** Refresh failure feedback hosted outside the Plugins panel's lifetime. */ +import type { ReactNode } from 'react' import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginManagerFace } from './manager-store.ts' @@ -17,7 +18,7 @@ export type PluginRefreshToastProps = InjectFace & Props * @param props - shared notice hook, dismissal action, and locale seat. * @returns the refresh failure toast, or null for other notices. */ -export function PluginRefreshToast({ usePluginManager, dismissNotice, t }: PluginRefreshToastProps) { +export function PluginRefreshToast({ usePluginManager, dismissNotice, t }: PluginRefreshToastProps): ReactNode { const notice = usePluginManager(state => state.notice) if (notice?.kind !== 'refresh-failed') return null return ( diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 2a45761da2..e28336d4e6 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -70,7 +70,6 @@ export const zh = { registryDefault: '默认安装源', registryOfficial: 'npm 官方源', registryNpmmirror: '中国大陆镜像源', - registryWithHost: '{name}({host})', registryCustom: '自定义地址', registryCustomPlaceholder: 'https://npm.example.com/', registryCustomHint: '填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。', @@ -264,7 +263,6 @@ export const en = { registryDefault: 'Default registry', registryOfficial: 'Official npm registry', registryNpmmirror: 'Mainland China mirror', - registryWithHost: '{name} ({host})', registryCustom: 'Custom address', registryCustomPlaceholder: 'https://npm.example.com/', registryCustomHint: 'Enter an internal or private npm registry address starting with http:// or https://. If it requires a login, store the credentials in ~/.npmrc on this machine.', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 166702d4d0..2f319d01ea 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -274,7 +274,11 @@ export interface ConfirmState { export interface PluginManagerState { /** `unavailable` when the Host runs without a managed profile; `error` keeps the last packages. */ readonly status: 'idle' | 'loading' | 'ready' | 'error' | 'unavailable' - /** Manual refresh feedback; background reads do not show the toolbar spinner. */ + /** + * Manual refresh feedback: `refreshing` until reads settle and the 400 ms minimum elapses; + * `failed` after a failed refresh without cached inventory; otherwise `idle`, including + * cached refresh failures reported by toast. Background reads do not start the spinner. + */ readonly refreshStatus: 'idle' | 'refreshing' | 'failed' readonly packages: readonly PackageView[] /** Package names and row keys with an action crossing the wire. */ 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 625672a34d..beaa13ac3d 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -1460,6 +1460,7 @@ describe('PluginManagerPage', () => { field.blur() set({ install: open }) actions.chooseRegistry.mockClear() + fireEvent.pointerDown(target) fireEvent.click(target) expect(actions.chooseRegistry).toHaveBeenCalledExactlyOnceWith({ kind: 'custom', url: '' }) expect(document.activeElement).toBe(field) @@ -1484,6 +1485,35 @@ describe('PluginManagerPage', () => { expect(document.activeElement).toBe(field) }) + it('keeps the selected registry on keyboard focus and selects custom through its radio', () => { + const open = { ...IDLE_INSTALL, open: true, registryOpen: true, registries: REGISTRIES } + const { actions } = renderTab({ install: open }) + const field = screen.getByRole('textbox', { name: en.registryCustom }) + const official = screen.getByRole('radio', { name: OFFICIAL_OPTION }) + official.focus() + fireEvent.keyDown(official, { key: 'Tab' }) + expect(document.activeElement).toBe(field) + expect(official).toHaveProperty('checked', true) + expect(actions.chooseRegistry).not.toHaveBeenCalled() + fireEvent.keyDown(field, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(official) + fireEvent.keyDown(official, { key: 'Tab', shiftKey: true }) + const toggle = screen.getByRole('button', { name: `${en.registryToggle} ${en.registryOfficial}` }) + expect(document.activeElement).toBe(toggle) + expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce() + actions.toggleRegistryOptions.mockClear() + field.focus() + fireEvent.keyDown(field, { key: 'Tab' }) + expect(document.activeElement).toBe(toggle) + expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce() + const custom = screen.getByRole('radio', { name: en.registryCustom }) + custom.focus() + fireEvent.keyDown(custom, { key: ' ' }) + fireEvent.click(custom) + expect(actions.chooseRegistry).toHaveBeenCalledExactlyOnceWith({ kind: 'custom', url: '' }) + expect(document.activeElement).toBe(field) + }) + it('focuses an invalid custom address when submission opens its registry menu', () => { const open = { ...IDLE_INSTALL, open: true, spec: 'dsh-new', registry: { kind: 'custom' as const, url: 'invalid' } } const { actions, set } = renderTab({ install: open }) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 937c879abf..895a4feccd 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -229,6 +229,8 @@ describe('PluginManagerController', () => { }) it('keeps cached packages while a manual refresh reads and ignores repeated refreshes without a success notice', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { plugins, inventory, face, state, controller } = bench() await controller.load() const packages = state().packages @@ -237,11 +239,15 @@ describe('PluginManagerController', () => { face.refresh() expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null }) expect(state().packages).toBe(packages) - await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) face.refresh() face.refresh() gate.resolve(ok([{ ...BUNDLE, version: '0.17.0' }])) - await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + await vi.advanceTimersByTimeAsync(399) + expect(state().refreshStatus).toBe('refreshing') + await vi.advanceTimersByTimeAsync(1) + expect(state().refreshStatus).toBe('idle') expect(state().packages[0]?.version).toBe('0.17.0') expect(state().status).toBe('ready') expect(state().notice).toBeNull() @@ -251,13 +257,16 @@ describe('PluginManagerController', () => { }) it.each(['refused', 'rejected'] as const)('keeps cached packages ready after a %s refresh and clears its toast when retry starts', async (failure) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { inventory, plugins, face, state, controller } = bench() await controller.load() const packages = state().packages if (failure === 'refused') inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline')) else inventory.list.mockRejectedValueOnce(new Error('transport down')) face.refresh() - await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('idle') expect(state()).toMatchObject({ status: 'ready', notice: { kind: 'refresh-failed', seq: 1 } }) expect(state().packages).toBe(packages) const gate = deferred>>() @@ -265,38 +274,49 @@ describe('PluginManagerController', () => { face.refresh() expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null }) expect(state().packages).toBe(packages) - await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) gate.resolve(ok([BUNDLE])) - await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('idle') expect(state()).toMatchObject({ status: 'ready', notice: null }) inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline again')) face.refresh() - await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'refresh-failed', seq: 2 }) }) + await vi.advanceTimersByTimeAsync(400) + expect(state().notice).toEqual({ kind: 'refresh-failed', seq: 2 }) face.dismissNotice() expect(state().notice).toBeNull() }) it.each(['refused', 'rejected'] as const)('keeps a %s first refresh failure inline without a toast', async (failure) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const inventory = failure === 'refused' ? vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')) : vi.fn().mockRejectedValueOnce(new Error('transport down')) const { face, state } = bench({ inventory }) face.refresh() - await vi.waitFor(() => { expect(state().refreshStatus).toBe('failed') }) + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('failed') expect(state()).toMatchObject({ status: 'error', packages: [], notice: null }) }) it('treats an empty successful inventory as cached for refresh failures', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { inventory, face, state, controller } = bench({ listBundles: vi.fn().mockResolvedValue(ok([])) }) await controller.load() expect(state().packages).toEqual([]) inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline')) face.refresh() - await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('idle') expect(state()).toMatchObject({ status: 'ready', packages: [], notice: { kind: 'refresh-failed' } }) }) it('forgets cached inventory when the managed profile becomes unavailable', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { inventory, face, state, controller } = bench() await controller.load() inventory.list.mockResolvedValueOnce(ok({ entries: [], managementAvailable: false })) @@ -304,35 +324,44 @@ describe('PluginManagerController', () => { expect(state()).toMatchObject({ status: 'unavailable', packages: [] }) inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline')) face.refresh() - await vi.waitFor(() => { expect(state().refreshStatus).toBe('failed') }) + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('failed') expect(state()).toMatchObject({ status: 'error', packages: [], notice: null }) }) it('preserves unrelated notices when a manual refresh starts and succeeds', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { face, state, controller } = bench({ setBundleEnabled: vi.fn().mockResolvedValue(ok({ ...APPLIED, application: 'restart-required' })), }) await controller.load() face.setEnabled(BUNDLE.name, true) - await vi.waitFor(() => { expect(state().notice?.kind).toBe('restart') }) + await vi.advanceTimersByTimeAsync(0) + expect(state().notice?.kind).toBe('restart') const notice = state().notice face.refresh() expect(state().notice).toBe(notice) - await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('idle') expect(state().notice).toBe(notice) }) it('keeps background reads quiet and clears an uncached refresh failure when they succeed', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { plugins, face, state, controller } = bench({ inventory: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')) .mockResolvedValue(ok({ entries: [], managementAvailable: true })), }) face.refresh() - await vi.waitFor(() => { expect(state().refreshStatus).toBe('failed') }) + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('failed') const gate = deferred>>() plugins.listBundles.mockReturnValueOnce(gate.promise) const loading = controller.load() - await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledOnce() }) + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledOnce() expect(state().refreshStatus).toBe('failed') gate.resolve(ok([BUNDLE])) await loading @@ -340,6 +369,8 @@ describe('PluginManagerController', () => { }) it.each(['background', 'manual'] as const)('holds the refresh indicator through coalesced reads when %s starts first', async (first) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { plugins, face, state, controller } = bench() await controller.load() const initial = deferred>>() @@ -347,50 +378,61 @@ describe('PluginManagerController', () => { plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise) const background = first === 'background' ? controller.load() : undefined if (first === 'manual') face.refresh() - await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) expect(state().refreshStatus).toBe(first === 'background' ? 'idle' : 'refreshing') const completion = background ?? controller.load() if (first === 'background') face.refresh() initial.resolve(ok([{ ...BUNDLE, version: '0.17.0' }])) - await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(3) }) + await vi.advanceTimersByTimeAsync(400) + expect(plugins.listBundles).toHaveBeenCalledTimes(3) expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null }) expect(state().packages[0]?.version).toBe('0.17.0') face.refresh() rerun.resolve(ok([{ ...BUNDLE, version: '0.18.0' }])) await completion - await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + await vi.advanceTimersByTimeAsync(0) + expect(state().refreshStatus).toBe('idle') expect(state().packages[0]?.version).toBe('0.18.0') expect(state().notice).toBeNull() expect(plugins.listBundles).toHaveBeenCalledTimes(3) }) it.each([false, true])('reports only the final coalesced refresh result (failure: %s)', async (failure) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { plugins, face, state, controller } = bench() await controller.load() const initial = deferred> | ReturnType>() const rerun = deferred> | ReturnType>() plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise) face.refresh() - await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) const loading = controller.load() initial.resolve(failure ? ok([BUNDLE]) : refused('gateway/internal', 'offline')) - await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(3) }) + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(3) expect(state().refreshStatus).toBe('refreshing') expect(state().notice).toBeNull() rerun.resolve(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE])) await loading - await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }) + await vi.advanceTimersByTimeAsync(400) + expect(state().refreshStatus).toBe('idle') expect(state().status).toBe('ready') expect(state().notice).toEqual(failure ? { kind: 'refresh-failed', seq: 1 } : null) }) it.each([false, true])('does not publish a manual refresh that settles after disposal (failure: %s)', async (failure) => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { plugins, face, state, controller } = bench() await controller.load() const gate = deferred> | ReturnType>() plugins.listBundles.mockReturnValueOnce(gate.promise) face.refresh() - await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + await vi.advanceTimersByTimeAsync(0) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) const completion = controller.load() const before = state() const listener = vi.fn() @@ -400,12 +442,15 @@ describe('PluginManagerController', () => { gate.resolve(failure ? refused('gateway/internal', 'offline') : ok([{ ...BUNDLE, version: '0.17.0' }])) await completion face.refresh() + await vi.advanceTimersByTimeAsync(400) expect(state()).toBe(before) expect(listener).not.toHaveBeenCalled() expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) it('reports a Host without a managed profile as unavailable and keeps the last packages across a failed read', async () => { + vi.useFakeTimers() + onTestFinished(() => { vi.useRealTimers() }) const { inventory, plugins, face, state, controller } = bench() await controller.load() expect(state().packages).toHaveLength(1) @@ -424,7 +469,8 @@ describe('PluginManagerController', () => { await controller.load() expect(state().status).toBe('error') face.refresh() - await vi.waitFor(() => { expect(state().status).toBe('ready') }) + await vi.advanceTimersByTimeAsync(400) + expect(state().status).toBe('ready') }) it('enables a bundle, marks it busy meanwhile, and says when a restart is needed or a layer overrides it', async () => { @@ -1258,16 +1304,25 @@ describe('PluginManagerController', () => { }) it('settles a manual refresh whose read outlasts the spinner minimum without an extra hold', async () => { - const { plugins, face, state, controller } = bench() - await controller.load() - plugins.listBundles.mockImplementationOnce(async () => { - await new Promise((resolve) => { setTimeout(resolve, 420) }) - return ok([{ ...BUNDLE, version: '0.42.0' }]) - }) - face.refresh() - await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }, { timeout: 2_000 }) - expect(state().packages[0]?.version).toBe('0.42.0') - expect(state().status).toBe('ready') + vi.useFakeTimers() + try { + const { plugins, face, state, controller } = bench() + await controller.load() + const gate = deferred>>() + plugins.listBundles.mockReturnValueOnce(gate.promise) + face.refresh() + await vi.advanceTimersByTimeAsync(401) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + expect(state().refreshStatus).toBe('refreshing') + gate.resolve(ok([{ ...BUNDLE, version: '0.42.0' }])) + await vi.advanceTimersByTimeAsync(0) + expect(state().refreshStatus).toBe('idle') + expect(state().packages[0]?.version).toBe('0.42.0') + expect(state().status).toBe('ready') + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } }) it('offers the registries the Host configured, starts from its first, and asks the check and the run to start where the person chose', async () => { const gate = deferred>>() diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 0e4cc0a527..57511f4dab 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -59,6 +59,7 @@ export type { export { projectUserText, type UserTextReferences } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' +export { pointerModality } from './input-modality.ts' export { Toast } from './Toast.tsx' export { fileSizeText } from './file-size.ts' export { writeClipboard } from './clipboard.ts' From 142b65524155c5959fd5698497ee5db92ac6b1ef Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sun, 27 Sep 2026 14:48:49 +0800 Subject: [PATCH 14/35] fix(plugins): use shared focus color for invalid inputs --- .../invalid-registry.expected.md | 8 +++---- apps/web/tests/plugin-install-cancel.e2e.ts | 22 ++++++++++++++++--- .../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 | 2 +- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md b/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md index 3be12c0041..2decb95ebb 100644 --- a/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md @@ -12,7 +12,7 @@ "colorScheme": "light", "focused": { "outline": "none", - "boxShadow": "rgb(236, 19, 19) 0px 0px 0px 0.5px inset", + "boxShadow": "rgb(65, 118, 230) 0px 0px 0px 0.5px inset", "borderColor": "rgb(236, 19, 19)" }, "blurred": { @@ -25,7 +25,7 @@ "colorScheme": "dark", "focused": { "outline": "none", - "boxShadow": "rgb(242, 90, 90) 0px 0px 0px 0.5px inset", + "boxShadow": "rgb(122, 170, 255) 0px 0px 0px 0.5px inset", "borderColor": "rgb(242, 90, 90)" }, "blurred": { @@ -40,7 +40,7 @@ "colorScheme": "light", "focused": { "outline": "none", - "boxShadow": "rgb(236, 19, 19) 0px 0px 0px 0.5px inset", + "boxShadow": "rgb(65, 118, 230) 0px 0px 0px 0.5px inset", "borderColor": "rgb(236, 19, 19)" }, "blurred": { @@ -53,7 +53,7 @@ "colorScheme": "dark", "focused": { "outline": "none", - "boxShadow": "rgb(242, 90, 90) 0px 0px 0px 0.5px inset", + "boxShadow": "rgb(122, 170, 255) 0px 0px 0px 0.5px inset", "borderColor": "rgb(242, 90, 90)" }, "blurred": { diff --git a/apps/web/tests/plugin-install-cancel.e2e.ts b/apps/web/tests/plugin-install-cancel.e2e.ts index 284e5a3a17..2dd7679b28 100644 --- a/apps/web/tests/plugin-install-cancel.e2e.ts +++ b/apps/web/tests/plugin-install-cancel.e2e.ts @@ -18,14 +18,30 @@ async function invalidInputStyles(page: Page, input: Locator) { await input.focus() await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(true) expect(await input.getAttribute('aria-invalid')).toBe('true') - const focused = await input.evaluate((element) => { + const { focused, focusRingColor, errorBorderColor } = await input.evaluate((element) => { const style = getComputedStyle(element) - return { outline: style.outlineStyle, boxShadow: style.boxShadow, borderColor: style.borderColor } + const probe = document.createElement('span') + probe.style.display = 'none' + probe.style.color = style.getPropertyValue('--dsw-focus-ring-color').trim() || style.getPropertyValue('--dsw-alias-state-business-primary') + element.after(probe) + try { + const focusRingColor = getComputedStyle(probe).color + probe.style.color = style.getPropertyValue('--dsw-alias-state-error-primary') + return { + focused: { outline: style.outlineStyle, boxShadow: style.boxShadow, borderColor: style.borderColor }, + focusRingColor, + errorBorderColor: getComputedStyle(probe).color, + } + } finally { + probe.remove() + } }) expect(focused.outline).toBe('none') expect(focused.boxShadow).toContain('inset') expect(focused.boxShadow).toContain('0px 0px 0px 0.5px') - expect(focused.boxShadow).toContain(focused.borderColor) + expect(focused.boxShadow).toContain(focusRingColor) + expect(focused.borderColor).toBe(errorBorderColor) + expect(focusRingColor).not.toBe(errorBorderColor) await input.evaluate((element) => { (element as HTMLInputElement).blur() }) await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(false) const blurred = await input.evaluate((element) => { diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 54caf382d5..c4a2f1d3b1 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -18,8 +18,8 @@ en: 555eccf8bfdd5029 zh: e0886cbf6e62bfe3 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: ad46cfbb1d596b85 - zh: 45b8268255e6c650 + en: 915fdd040b4d211c + zh: d77e0846f9cbc334 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: abc90531efa889d5 zh: 0562570f85e69c35 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 81b8a01b43..f11efd5b2a 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -41,7 +41,7 @@ On first use, when pnpm uses the unconfigured official npm registry and npmmirro The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved. -**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields use an error-colored border and add a same-color inset ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index a9e4d6adcc..c8a68347ea 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。 -**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时使用错误色边框,聚焦时加上同色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。 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 f2241d5bb3..09810fe85b 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -516,7 +516,7 @@ .installField input[type='text'][aria-invalid='true']:focus, .registryCustomField[aria-invalid='true']:focus { - box-shadow: inset 0 0 0 0.5px var(--dsw-alias-state-error-primary); + box-shadow: inset 0 0 0 0.5px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); } /* The guide under the field: opened by a text button, it says what an ID is and shows the three forms with examples. */ From 361ac7858faab1d71578654623d0f5c8f02fe2f3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:56:27 +0800 Subject: [PATCH 15/35] docs(ui-chat): describe the whale tail shown below the running transcript --- .../ui-chat/src/client/conversation-nodes/README.i18n.yaml | 4 ++-- .../client/ui-chat/src/client/conversation-nodes/README.md | 2 +- .../client/ui-chat/src/client/conversation-nodes/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 1388879236..569aacffe6 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -24,8 +24,8 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 70e98703051fbfe1 - zh: cbeeb922dabb2712 + en: 6e8b44964abb10d0 + zh: 045b2abf2c174b4c /chat-conversation-node-rules/display-modes: en: ada5a60d2c46f6a8 zh: f3eaeb5cb4d9b3de diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 139c7f7bf3..1801e0f99a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -99,7 +99,7 @@ Clicking Load older anchors the first visible content item below that button in Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor. -A running clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. Completion fixes the duration; cancellation and failure replace it with their status. Lifecycle changes have a polite announcement; clock ticks do not. This control is Chat's only Turn-level running indicator. +A running clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. Completion fixes the duration; cancellation and failure replace it with their status. Lifecycle changes have a polite announcement; clock ticks do not. This control is Chat's only Turn-level running indicator; while the Session runs, Chat also appends a decorative whale tail animation below the last flow row, hidden from assistive technology and static under reduced motion. Automatic collapse keeps the process open if hiding it would hide keyboard focus. Manual closing focuses the process control before hiding its members. Closing a whole Turn resets its groups and inner reasoning/tool disclosures; it does not reset unrelated renderer state. Browser find can reveal searchable hidden content. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index bf9fff2c1d..74358768b1 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -99,7 +99,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。 -运行时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。完成后固定时长,取消与失败使用对应状态替代。生命周期变化有礼貌播报,时钟跳动不播报。该控件是 Chat 唯一的轮次级运行指示。 +运行时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。完成后固定时长,取消与失败使用对应状态替代。生命周期变化有礼貌播报,时钟跳动不播报。该控件是 Chat 唯一的轮次级运行指示;会话运行期间,Chat 另在最后一个流式行下方附加装饰性的鲸鱼摆尾动画,对辅助技术隐藏,启用减少动态效果时保持静态。 自动收起若会隐藏键盘焦点,则保持过程展开。手动收起先将焦点移到过程控件,再隐藏成员。收起整轮会重置内部组及推理、工具的开合,不重置渲染器的其他状态。浏览器查找可以显示可搜索的隐藏内容。 From fa3d555cd89e22eef174ee564aafd9e94767dba0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:56:44 +0800 Subject: [PATCH 16/35] feat(ui-chat): animate a whale tail below the transcript while the Session runs --- apps/web/tests/reasoning-preview.e2e.ts | 29 +++++++ .../src/client/chat/ChatView.module.css | 9 +++ .../ui-chat/src/client/chat/ChatView.tsx | 2 + .../src/client/chat/RunningWhaleTail.tsx | 64 +++++++++++++++ .../ui-chat/tests/chat-view.client.spec.tsx | 12 +++ .../tests/running-whale-tail.client.spec.tsx | 79 +++++++++++++++++++ .../ui-theme/src/styles/design-platform.css | 2 + 7 files changed, 197 insertions(+) create mode 100644 packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx create mode 100644 packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts index 0d5911edde..addc24022c 100644 --- a/apps/web/tests/reasoning-preview.e2e.ts +++ b/apps/web/tests/reasoning-preview.e2e.ts @@ -65,6 +65,35 @@ it('shows completed paragraph first lines across blank lines with a right-edge f const reasoning = page.locator('[data-variant="think"][data-state="running"]') await expandOwningTurnProcess(page, reasoning) await reasoning.waitFor() + const whale = page.locator('[data-chat-running] svg') + await whale.locator('animate').waitFor({ state: 'attached' }) + const positions = await whale.evaluate(async (element) => { + const svg = element as SVGSVGElement + const path = svg.querySelector('path')! + const time = svg.getCurrentTime() + svg.pauseAnimations() + try { + const positions: number[][] = [] + for (const at of [0.3, 0.9, 3.3]) { + svg.setCurrentTime(at) + await new Promise(resolve => requestAnimationFrame(() => { resolve() })) + const point = path.getPointAtLength(path.getTotalLength() * 0.4) + positions.push([point.x, point.y]) + } + return positions + } finally { + svg.setCurrentTime(time) + svg.unpauseAnimations() + } + }) + expect(positions[1]).not.toEqual(positions[0]) + expect(positions[2]).toEqual(positions[0]) + await whale.evaluate((element) => { element.setAttribute('data-whale-before-preference', 'true') }) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await expect.poll(() => whale.locator('animate').count()).toBe(0) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await whale.locator('animate').waitFor({ state: 'attached' }) + expect(await whale.getAttribute('data-whale-before-preference')).toBeNull() expect(await reasoning.getAttribute('data-preview')).toBeNull() first.proceed.resolve(undefined) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 7470e195c3..cf0aabc7a5 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -101,6 +101,15 @@ line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } +/* Decorative whale below the last flow row while the Session runs. */ +.runningWhale { + display: inline-flex; + flex: none; + width: calc(14px + var(--dsh-content-font-delta, 0px)); + height: calc(14px + var(--dsh-content-font-delta, 0px)); + color: var(--dsw-alias-label-deep-diving); +} + .openError { color: var(--dsw-alias-state-error-primary); font-size: var(--dsh-content-font-size-secondary, 13px); diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 0514068aed..3c9650da71 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -18,6 +18,7 @@ import { ChatGroupSeat } from './ChatGroupSeat.tsx' import { chatRenderKey } from './render-entry.ts' import { assertNever } from '@deepseek-ai/dsh-util-values' import { TurnNavigator } from './TurnNavigator.tsx' +import { RunningWhaleTail } from './RunningWhaleTail.tsx' import { mergeTurnRailItems } from './turn-rail-items.ts' import { useChatScroll } from './use-chat-scroll.ts' import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' @@ -269,6 +270,7 @@ export function ChatView({ t={t} /> + {running && } {/* No pending placeholders: questions (ui-user-questions) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx b/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx new file mode 100644 index 0000000000..23d4cbb4ad --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx @@ -0,0 +1,64 @@ +/** Native SVG contour animation for the whale Chat shows while the Session runs. */ +import { useEffect, useState } from 'react' +import css from './ChatView.module.css' + +const REDUCED_MOTION = '(prefers-reduced-motion: reduce)' +const REST_PATH = 'M8.844 13.742C8.967 12.328 8.45 10.4 8.45 9.65C8.45 8.94 8.88 8.43 9.6 8.43C11.285 8.43 12.106 8.281 12.685 8.104C13.71 7.791 14.585 6.768 15.055 5.945C15.137 5.803 14.99 5.641 14.829 5.671C13.829 5.86 12.828 5.376 11.827 4.978C10.659 4.514 9.491 4.707 8.935 4.876C8.805 4.915 8.658 4.819 8.636 4.686C8.468 3.643 7.405 2.615 5.498 2.238C4.54 2.048 3.748 1.574 3.347 1.202C3.252 1.113 3.088 1.125 3.03 1.242C2.628 2.059 2.168 3.82 5.248 6.115C5.82 6.494 6.31 6.785 6.574 7.637C6.72 8.104 6.157 9.168 6.061 9.368C5.157 11.27 5.089 12.19 4.926 13.742' + +/** Same cubic segments in every pose keep both tail-base endpoints stationary. */ +const MOTION_PATHS = [ + REST_PATH, + REST_PATH, + 'M8.844 13.742C8.967 12.328 8.453 10.404 8.455 9.655C8.457 8.946 8.89 8.438 9.61 8.441C11.295 8.448 12.116 8.302 12.696 8.127C13.722 7.818 14.601 6.796 15.074 5.97C15.156 5.827 15.01 5.663 14.849 5.693C13.848 5.881 12.848 5.391 11.849 4.989C10.681 4.521 9.513 4.712 8.956 4.88C8.827 4.919 8.679 4.823 8.658 4.689C8.492 3.645 7.429 2.615 5.523 2.237C4.565 2.047 3.773 1.572 3.373 1.201C3.277 1.112 3.113 1.124 3.056 1.241C2.653 2.057 2.193 3.808 5.267 6.11C5.837 6.49 6.326 6.782 6.587 7.636C6.73 8.103 6.164 9.165 6.068 9.365C5.157 11.265 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.476 10.437 8.498 9.697C8.519 8.997 8.969 8.506 9.689 8.533C11.373 8.596 12.198 8.481 12.784 8.327C13.82 8.055 14.734 7.049 15.232 6.219C15.318 6.075 15.177 5.903 15.015 5.93C14.009 6.092 13.025 5.562 12.037 5.128C10.883 4.62 9.709 4.785 9.148 4.939C9.018 4.975 8.873 4.874 8.856 4.739C8.717 3.685 7.676 2.629 5.778 2.213C4.824 2.004 4.041 1.515 3.649 1.136C3.555 1.045 3.391 1.054 3.331 1.17C2.912 1.978 2.423 3.678 5.425 6.063C5.984 6.458 6.463 6.766 6.695 7.627C6.823 8.099 6.221 9.144 6.12 9.342C5.154 11.226 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.473 10.432 8.492 9.69C8.51 8.989 8.957 8.495 9.676 8.519C11.361 8.573 12.186 8.453 12.771 8.298C13.805 8.023 14.714 7.034 15.213 6.234C15.299 6.095 15.158 5.929 14.996 5.954C13.99 6.106 13.007 5.59 12.021 5.158C10.87 4.655 9.696 4.804 9.134 4.952C9.003 4.987 8.859 4.886 8.842 4.752C8.712 3.706 7.688 2.639 5.797 2.19C4.847 1.964 4.073 1.459 3.688 1.072C3.595 0.98 3.431 0.986 3.369 1.101C2.936 1.901 2.407 3.656 5.406 6.06C5.964 6.459 6.443 6.767 6.679 7.628C6.808 8.099 6.212 9.147 6.111 9.346C5.155 11.233 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.457 10.41 8.463 9.662C8.469 8.955 8.904 8.45 9.624 8.458C11.309 8.475 12.131 8.335 12.713 8.167C13.741 7.87 14.627 6.885 15.113 6.112C15.198 5.978 15.055 5.821 14.893 5.846C13.889 6.004 12.898 5.52 11.908 5.109C10.752 4.629 9.579 4.78 9.019 4.932C8.889 4.967 8.744 4.868 8.726 4.736C8.587 3.701 7.565 2.64 5.676 2.183C4.726 1.954 3.954 1.444 3.57 1.055C3.479 0.962 3.314 0.967 3.252 1.082C2.815 1.881 2.27 3.708 5.305 6.084C5.87 6.477 6.353 6.777 6.607 7.633C6.747 8.102 6.174 9.161 6.077 9.361C5.156 11.258 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.425 10.366 8.403 9.607C8.383 8.888 8.794 8.361 9.513 8.335C11.197 8.276 12.012 8.096 12.585 7.897C13.598 7.547 14.436 6.515 14.883 5.708C14.961 5.569 14.81 5.417 14.65 5.45C13.655 5.661 12.641 5.221 11.631 4.858C10.452 4.434 9.289 4.648 8.737 4.828C8.608 4.871 8.459 4.779 8.434 4.647C8.242 3.618 7.163 2.612 5.252 2.261C4.29 2.084 3.492 1.618 3.087 1.252C2.99 1.164 2.826 1.178 2.77 1.296C2.378 2.118 1.937 3.949 5.086 6.167C5.669 6.531 6.167 6.806 6.461 7.649C6.623 8.111 6.097 9.191 6.008 9.393C5.161 11.31 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.426 10.368 8.407 9.61C8.388 8.892 8.8 8.365 9.52 8.342C11.204 8.286 12.019 8.107 12.592 7.907C13.605 7.553 14.443 6.483 14.878 5.614C14.954 5.464 14.8 5.302 14.641 5.34C13.649 5.577 12.629 5.12 11.611 4.759C10.422 4.338 9.264 4.59 8.716 4.786C8.589 4.832 8.437 4.742 8.409 4.608C8.193 3.567 7.074 2.592 5.15 2.321C4.183 2.185 3.365 1.758 2.945 1.409C2.845 1.326 2.682 1.347 2.631 1.467C2.275 2.305 1.917 4.029 5.086 6.183C5.673 6.536 6.174 6.807 6.469 7.649C6.63 8.11 6.101 9.189 6.012 9.391C5.16 11.307 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.439 10.385 8.43 9.631C8.421 8.917 8.843 8.399 9.563 8.388C11.248 8.362 12.066 8.198 12.642 8.008C13.661 7.672 14.517 6.601 14.963 5.717C15.04 5.564 14.889 5.396 14.729 5.433C13.735 5.663 12.719 5.181 11.705 4.804C10.52 4.364 9.36 4.611 8.81 4.803C8.682 4.847 8.531 4.755 8.505 4.621C8.297 3.572 7.181 2.589 5.257 2.315C4.29 2.178 3.473 1.751 3.053 1.402C2.953 1.319 2.79 1.34 2.739 1.46C2.382 2.297 2.024 3.975 5.165 6.16C5.747 6.519 6.244 6.796 6.524 7.643C6.678 8.106 6.131 9.178 6.038 9.379C5.159 11.288 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.462 10.417 8.472 9.671C8.481 8.965 8.921 8.464 9.64 8.476C11.325 8.505 12.149 8.371 12.731 8.204C13.761 7.908 14.653 6.889 15.135 6.057C15.218 5.913 15.074 5.746 14.913 5.775C13.91 5.954 12.916 5.448 11.919 5.034C10.757 4.551 9.586 4.735 9.028 4.899C8.898 4.937 8.752 4.839 8.732 4.705C8.575 3.656 7.517 2.618 5.613 2.231C4.655 2.037 3.865 1.56 3.467 1.186C3.372 1.097 3.208 1.108 3.149 1.225C2.743 2.039 2.278 3.763 5.327 6.093C5.893 6.478 6.379 6.776 6.629 7.632C6.766 8.101 6.186 9.157 6.088 9.356C5.156 11.25 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.463 10.418 8.474 9.673C8.485 8.968 8.925 8.468 9.645 8.482C11.33 8.513 12.153 8.381 12.736 8.218C13.766 7.929 14.661 6.94 15.152 6.153C15.237 6.017 15.095 5.856 14.933 5.881C13.928 6.038 12.94 5.541 11.951 5.122C10.796 4.633 9.623 4.786 9.062 4.937C8.932 4.972 8.787 4.873 8.77 4.739C8.633 3.699 7.608 2.638 5.717 2.189C4.766 1.963 3.993 1.457 3.607 1.07C3.515 0.977 3.351 0.983 3.288 1.098C2.855 1.899 2.321 3.692 5.344 6.075C5.906 6.47 6.388 6.773 6.635 7.631C6.771 8.101 6.189 9.155 6.09 9.355C5.156 11.248 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.436 10.381 8.425 9.626C8.414 8.911 8.833 8.391 9.553 8.377C11.238 8.344 12.055 8.178 12.631 7.987C13.649 7.65 14.502 6.601 14.953 5.753C15.031 5.607 14.88 5.446 14.72 5.48C13.725 5.696 12.712 5.229 11.702 4.853C10.522 4.414 9.359 4.64 8.807 4.824C8.679 4.867 8.529 4.774 8.504 4.641C8.308 3.599 7.214 2.601 5.297 2.282C4.333 2.122 3.527 1.674 3.115 1.315C3.017 1.229 2.854 1.246 2.799 1.365C2.423 2.193 2.02 3.939 5.152 6.154C5.733 6.518 6.229 6.797 6.512 7.644C6.666 8.107 6.124 9.18 6.032 9.382C5.159 11.292 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.449 10.399 8.448 9.648C8.447 8.938 8.876 8.427 9.596 8.426C11.282 8.423 12.102 8.272 12.681 8.092C13.705 7.774 14.577 6.731 15.041 5.876C15.121 5.728 14.973 5.562 14.812 5.595C13.813 5.8 12.808 5.309 11.802 4.916C10.628 4.456 9.462 4.671 8.909 4.849C8.78 4.89 8.631 4.795 8.608 4.661C8.425 3.613 7.339 2.602 5.424 2.27C4.461 2.103 3.658 1.649 3.249 1.287C3.152 1.201 2.988 1.217 2.933 1.335C2.55 2.161 2.137 3.872 5.235 6.128C5.81 6.5 6.302 6.787 6.569 7.638C6.716 8.104 6.154 9.168 6.059 9.369C5.157 11.272 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.472 10.431 8.491 9.69C8.509 8.988 8.956 8.494 9.676 8.517C11.36 8.571 12.185 8.451 12.77 8.294C13.804 8.016 14.712 7.011 15.207 6.188C15.293 6.046 15.151 5.876 14.989 5.903C13.984 6.066 12.998 5.545 12.008 5.115C10.853 4.614 9.68 4.779 9.119 4.934C8.989 4.97 8.844 4.869 8.826 4.735C8.686 3.684 7.646 2.629 5.748 2.211C4.793 2.001 4.012 1.511 3.62 1.13C3.526 1.039 3.362 1.048 3.302 1.164C2.882 1.971 2.388 3.691 5.399 6.068C5.96 6.463 6.44 6.768 6.677 7.628C6.807 8.099 6.211 9.147 6.111 9.346C5.155 11.233 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.47 10.428 8.487 9.686C8.503 8.983 8.949 8.488 9.668 8.509C11.353 8.558 12.178 8.435 12.762 8.279C13.795 8.002 14.701 7.021 15.2 6.237C15.286 6.102 15.145 5.939 14.983 5.963C13.977 6.11 12.994 5.602 12.009 5.173C10.859 4.672 9.685 4.813 9.122 4.959C8.992 4.993 8.848 4.892 8.832 4.758C8.705 3.717 7.69 2.645 5.804 2.178C4.855 1.943 4.087 1.43 3.705 1.039C3.613 0.946 3.449 0.95 3.386 1.064C2.945 1.861 2.395 3.646 5.393 6.06C5.951 6.46 6.43 6.768 6.668 7.628C6.799 8.1 6.206 9.149 6.106 9.348C5.155 11.237 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.457 10.409 8.462 9.662C8.468 8.954 8.903 8.449 9.623 8.456C11.308 8.472 12.13 8.332 12.711 8.164C13.739 7.866 14.625 6.882 15.111 6.111C15.195 5.978 15.052 5.821 14.891 5.846C13.886 6.003 12.896 5.521 11.906 5.11C10.75 4.63 9.577 4.781 9.017 4.932C8.886 4.967 8.742 4.869 8.724 4.736C8.585 3.702 7.564 2.64 5.675 2.182C4.725 1.952 3.954 1.441 3.571 1.052C3.479 0.959 3.315 0.964 3.252 1.078C2.815 1.877 2.267 3.708 5.303 6.084C5.867 6.477 6.351 6.777 6.606 7.633C6.745 8.102 6.173 9.161 6.076 9.361C5.156 11.259 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.432 10.376 8.418 9.62C8.404 8.904 8.82 8.382 9.54 8.364C11.225 8.323 12.041 8.152 12.616 7.961C13.633 7.623 14.483 6.603 14.94 5.806C15.019 5.669 14.87 5.516 14.71 5.547C13.712 5.744 12.704 5.295 11.699 4.92C10.525 4.482 9.359 4.68 8.805 4.853C8.676 4.894 8.527 4.801 8.504 4.668C8.325 3.638 7.262 2.618 5.355 2.239C4.397 2.048 3.605 1.57 3.206 1.198C3.11 1.109 2.946 1.12 2.888 1.238C2.485 2.054 2.016 3.888 5.137 6.146C5.716 6.517 6.211 6.799 6.496 7.645C6.652 8.109 6.115 9.184 6.024 9.385C5.159 11.298 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.429 10.371 8.411 9.614C8.394 8.897 8.808 8.372 9.528 8.351C11.212 8.301 12.028 8.125 12.602 7.927C13.616 7.578 14.458 6.518 14.899 5.663C14.975 5.515 14.823 5.354 14.664 5.391C13.67 5.619 12.653 5.161 11.638 4.796C10.453 4.37 9.293 4.61 8.743 4.801C8.616 4.846 8.465 4.755 8.438 4.622C8.23 3.582 7.123 2.596 5.202 2.303C4.236 2.156 3.424 1.718 3.007 1.364C2.908 1.28 2.745 1.299 2.692 1.419C2.326 2.252 1.947 3.995 5.104 6.173C5.689 6.531 6.188 6.804 6.479 7.648C6.639 8.11 6.107 9.187 6.017 9.389C5.16 11.304 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.438 10.384 8.428 9.629C8.419 8.915 8.839 8.397 9.559 8.384C11.244 8.356 12.062 8.191 12.638 8.001C13.657 7.664 14.512 6.597 14.959 5.721C15.036 5.57 14.884 5.403 14.725 5.44C13.73 5.667 12.715 5.189 11.701 4.813C10.518 4.373 9.357 4.616 8.807 4.806C8.679 4.851 8.528 4.759 8.502 4.625C8.296 3.577 7.184 2.591 5.262 2.309C4.295 2.167 3.481 1.736 3.062 1.385C2.962 1.301 2.799 1.321 2.748 1.441C2.387 2.276 2.02 3.969 5.16 6.159C5.742 6.519 6.239 6.797 6.52 7.643C6.674 8.107 6.128 9.179 6.036 9.38C5.159 11.289 5.089 12.19 4.926 13.742', + 'M8.844 13.742C8.967 12.328 8.45 10.4 8.45 9.65C8.451 8.941 8.881 8.431 9.601 8.431C11.286 8.432 12.107 8.283 12.686 8.107C13.711 7.796 14.587 6.785 15.061 5.98C15.143 5.841 14.997 5.681 14.836 5.71C13.835 5.89 12.836 5.41 11.838 5.011C10.673 4.544 9.503 4.725 8.946 4.89C8.816 4.928 8.67 4.832 8.649 4.698C8.488 3.659 7.437 2.622 5.536 2.221C4.579 2.02 3.793 1.534 3.398 1.157C3.303 1.067 3.139 1.077 3.08 1.193C2.667 2.005 2.182 3.794 5.253 6.109C5.824 6.491 6.312 6.784 6.576 7.636C6.72 8.104 6.157 9.167 6.062 9.368C5.157 11.27 5.089 12.19 4.926 13.742', + REST_PATH, + REST_PATH, +].join(';') +const MOTION_TIMES = '0;0.028571;0.057143;0.095238;0.114286;0.133333;0.171429;0.2;0.219048;0.257143;0.285714;0.380952;0.409524;0.447619;0.47619;0.495238;0.52381;0.552381;0.571429;0.638095;0.8;1' + +// jsdom (the unit lane) implements no matchMedia despite lib.dom's non-optional typing. +function motionPreference(): MediaQueryList | undefined { + return typeof matchMedia === 'function' ? matchMedia(REDUCED_MOTION) : undefined +} + +/** + * Loop the tail every three seconds after a 0.3s delay; reduced motion keeps the resting contour. + * @returns the decorative whale, remounted so the SVG timeline restarts when motion is re-enabled. + */ +export function RunningWhaleTail() { + const [reducedMotion, setReducedMotion] = useState(() => motionPreference()?.matches ?? false) + useEffect(() => { + const preference = motionPreference() + if (preference === undefined) return + const update = () => { setReducedMotion(preference.matches) } + update() + preference.addEventListener('change', update) + return () => { preference.removeEventListener('change', update) } + }, []) + return ( + + ) +} diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 3bda5cad62..5eead2ddd4 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -4089,4 +4089,16 @@ describe('ChatView', () => { expect(failedView.getByText('Compaction cancelled.')).toBeTruthy() expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull() }) + + it('appends the whale below the last flow row only while the Session runs', () => { + const h = makeHarness({ nodes: [user(1, 'do the thing'), assistant(2, 'working')] }, { running: true }) + const view = render() + const column = view.container.querySelector('[data-chat-flow]')! + const whale = column.querySelector('[data-chat-running]') + expect(column.lastElementChild).toBe(whale) + expect(whale?.getAttribute('aria-hidden')).toBe('true') + expect(whale?.querySelector('animate')).not.toBeNull() + act(() => { h.setSession({ running: false }) }) + expect(column.querySelector('[data-chat-running]')).toBeNull() + }) }) diff --git a/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx b/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx new file mode 100644 index 0000000000..1d196f0a4f --- /dev/null +++ b/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom + +import { StrictMode } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RunningWhaleTail } from '../src/client/chat/RunningWhaleTail.tsx' + +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +function motionPreference(matches: boolean) { + const media = Object.assign(new EventTarget(), { + matches, media: '(prefers-reduced-motion: reduce)', onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + }) + vi.stubGlobal('matchMedia', vi.fn(() => media)) + return media +} + +describe('RunningWhaleTail', () => { + it('animates when the environment exposes no motion preference', () => { + expect(typeof matchMedia).toBe('undefined') + const view = render() + expect(view.container.firstElementChild?.getAttribute('aria-hidden')).toBe('true') + expect(view.container.querySelector('animate')).not.toBeNull() + }) + + it.each([false, true])('initializes with reduced motion set to %s', (reduced) => { + motionPreference(reduced) + const view = render() + expect(view.container.firstElementChild?.getAttribute('aria-hidden')).toBe('true') + expect(view.container.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 16 16') + const animation = view.container.querySelector('animate') + if (reduced) { + expect(animation).toBeNull() + return + } + expect(animation?.getAttribute('attributeName')).toBe('d') + expect(animation?.getAttribute('begin')).toBe('0.3s') + expect(animation?.getAttribute('dur')).toBe('3s') + expect(animation?.getAttribute('repeatCount')).toBe('indefinite') + const paths = animation!.getAttribute('values')!.split(';') + const times = animation!.getAttribute('keyTimes')!.split(';').map(Number) + expect(paths.length).toBeGreaterThan(1) + expect(paths).toHaveLength(times.length) + expect(paths[0]).toBe(view.container.querySelector('path')?.getAttribute('d')) + expect(paths.at(-1)).toBe(paths[0]) + expect(times[0]).toBe(0) + expect(times.at(-1)).toBe(1) + expect(times.slice(1).every((time, i) => time > times[i]!)).toBe(true) + }) + + it('restores the static pose and restarts the SVG timeline when motion is re-enabled', () => { + const media = motionPreference(false) + const view = render() + const original = view.container.querySelector('svg') + const restingPath = original?.querySelector('path')?.getAttribute('d') + act(() => { media.matches = true; media.dispatchEvent(new Event('change')) }) + const still = view.container.querySelector('svg') + expect(still).not.toBe(original) + expect(still?.querySelector('animate')).toBeNull() + expect(still?.querySelector('path')?.getAttribute('d')).toBe(restingPath) + act(() => { media.matches = false; media.dispatchEvent(new Event('change')) }) + expect(view.container.querySelector('svg')).not.toBe(still) + expect(view.container.querySelector('animate')?.getAttribute('begin')).toBe('0.3s') + }) + + it('removes each preference listener across StrictMode setup and unmount', () => { + const media = motionPreference(false) + const add = vi.spyOn(media, 'addEventListener') + const remove = vi.spyOn(media, 'removeEventListener') + const view = render() + expect(add).toHaveBeenCalledTimes(2) + expect(remove).toHaveBeenCalledTimes(1) + view.unmount() + expect(remove).toHaveBeenCalledTimes(2) + expect(remove.mock.calls).toEqual(add.mock.calls) + }) +}) diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 672e90c20a..3a52fa25a0 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -211,6 +211,7 @@ body { --dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-75); --dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06); --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400); + --dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-500) 70%, var(--dsw-static-blue-950)); --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200); --dsw-alias-label-primary-bluish: var(--dsw-static-blue-900); --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950); @@ -321,6 +322,7 @@ body[data-ds-dark-theme] { --dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-800); --dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08); --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600); + --dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-450) 55%, var(--dsw-static-neutral-bluish-400)); --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750); --dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50); --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100); From 398c2cde259f84089324cc1738594709a2365d26 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:03:40 +0800 Subject: [PATCH 17/35] docs(ui-chat): describe the bottom running status --- .../ui-chat/src/client/conversation-nodes/README.i18n.yaml | 4 ++-- .../client/ui-chat/src/client/conversation-nodes/README.md | 2 +- .../client/ui-chat/src/client/conversation-nodes/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 569aacffe6..30a8b2b0de 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -24,8 +24,8 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 6e8b44964abb10d0 - zh: 045b2abf2c174b4c + en: 3ed49f605b762a0a + zh: 0de0dc8000782dc1 /chat-conversation-node-rules/display-modes: en: ada5a60d2c46f6a8 zh: f3eaeb5cb4d9b3de diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 1801e0f99a..339c560522 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -99,7 +99,7 @@ Clicking Load older anchors the first visible content item below that button in Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor. -A running clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. Completion fixes the duration; cancellation and failure replace it with their status. Lifecycle changes have a polite announcement; clock ticks do not. This control is Chat's only Turn-level running indicator; while the Session runs, Chat also appends a decorative whale tail animation below the last flow row, hidden from assistive technology and static under reduced motion. +While the Session runs, Chat appends its only Turn-level running indicator below the last flow row: a whale tail animation beside shimmering elapsed time. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. When the Turn settles, the process control replaces the running indicator with a fixed duration; cancellation and failure use their corresponding status. Lifecycle changes have a polite announcement; clock ticks do not. The whale is hidden from assistive technology and remains static under reduced motion. Automatic collapse keeps the process open if hiding it would hide keyboard focus. Manual closing focuses the process control before hiding its members. Closing a whole Turn resets its groups and inner reasoning/tool disclosures; it does not reset unrelated renderer state. Browser find can reveal searchable hidden content. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 74358768b1..ca2c159c75 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -99,7 +99,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。 -运行时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。完成后固定时长,取消与失败使用对应状态替代。生命周期变化有礼貌播报,时钟跳动不播报。该控件是 Chat 唯一的轮次级运行指示;会话运行期间,Chat 另在最后一个流式行下方附加装饰性的鲸鱼摆尾动画,对辅助技术隐藏,启用减少动态效果时保持静态。 +会话运行期间,Chat 在最后一个流式行下方附加唯一的轮次级运行指示:鲸鱼摆尾动画旁显示闪烁的已用时间。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。轮次结束后,过程控件以固定时长替换运行指示;取消与失败使用对应状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。 自动收起若会隐藏键盘焦点,则保持过程展开。手动收起先将焦点移到过程控件,再隐藏成员。收起整轮会重置内部组及推理、工具的开合,不重置渲染器的其他状态。浏览器查找可以显示可搜索的隐藏内容。 From cae515d5046f45fb2e9bbb9a4cbf8ca13d216ba0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:03:49 +0800 Subject: [PATCH 18/35] feat(ui-chat): restore the complete running status --- .../src/client/chat/ChatView.module.css | 48 ++++++++++++- .../ui-chat/src/client/chat/ChatView.tsx | 11 ++- .../ui-chat/src/client/chat/RunningStatus.tsx | 45 ++++++++++++ .../src/client/chat/RunningWhaleTail.tsx | 2 +- .../src/client/chat/TurnProcessNodeView.tsx | 38 ++++------- packages/client/ui-chat/src/client/locale.ts | 4 +- .../ui-chat/tests/chat-view.client.spec.tsx | 66 ++++++++++-------- .../tests/running-status.client.spec.tsx | 68 +++++++++++++++++++ 8 files changed, 221 insertions(+), 61 deletions(-) create mode 100644 packages/client/ui-chat/src/client/chat/RunningStatus.tsx create mode 100644 packages/client/ui-chat/tests/running-status.client.spec.tsx diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index cf0aabc7a5..086dc40ee4 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -101,13 +101,55 @@ line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } -/* Decorative whale below the last flow row while the Session runs. */ -.runningWhale { +.running { + display: flex; + flex-direction: column; + align-items: flex-start; + --dsh-chat-flow-gap: 12px; + color: var(--dsw-alias-label-deep-diving); + font-size: calc(var(--dsh-content-font-size, 14px) - 2px); + line-height: calc(22px + var(--dsh-content-font-delta, 0px)); +} + +/* The last visible row before the status must contain output, not an input. + Hidden and empty seats do not separate a user message from the status. */ +.column > :nth-last-child(2 of :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is( + .flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]), + [data-chat-group-key] +) ~ .running > .runningDivider { + display: block; +} + +.runningDivider { + display: none; + width: 100%; + height: 0.5px; + margin: 8px 0 10px; + background: color-mix(in srgb, var(--dsw-alias-border-l1) 75%, var(--dsw-alias-border-l2)); + pointer-events: none; +} + +.runningContent { + display: inline-flex; + align-items: center; + min-width: 0; + gap: 6px; +} + +.runningText { + min-width: 0; +} + +.runningNumber { + font-family: var(--ds-font-family-code); + font-variant-numeric: tabular-nums; +} + +.runningIcon { display: inline-flex; flex: none; width: calc(14px + var(--dsh-content-font-delta, 0px)); height: calc(14px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-deep-diving); } .openError { diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 3c9650da71..460f98b014 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -18,7 +18,7 @@ import { ChatGroupSeat } from './ChatGroupSeat.tsx' import { chatRenderKey } from './render-entry.ts' import { assertNever } from '@deepseek-ai/dsh-util-values' import { TurnNavigator } from './TurnNavigator.tsx' -import { RunningWhaleTail } from './RunningWhaleTail.tsx' +import { RunningStatus } from './RunningStatus.tsx' import { mergeTurnRailItems } from './turn-rail-items.ts' import { useChatScroll } from './use-chat-scroll.ts' import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' @@ -112,6 +112,13 @@ export function ChatView({ // both the data and its change signal: the array identity moves only when a // Turn enters, leaves, or changes its preview. const turnNavigationItems = useChat(s => s.navigation.items()) + const latestTurnAnchor = turnNavigationItems.at(-1)?.anchorKey + const runningStartTime = useChatNode(latestTurnAnchor ?? '', (node) => { + const location = node?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.status === 'open' ? location.turn.start?.time : undefined + : undefined + }) // Host-computed whole-log outline; the merge is view-layer only (the // conversation snapshot never carries projection values). const turnOutline = useProjection('turnOutline') @@ -270,7 +277,7 @@ export function ChatView({ t={t} /> - {running && } + {running && } {/* No pending placeholders: questions (ui-user-questions) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx new file mode 100644 index 0000000000..c6b17921ed --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx @@ -0,0 +1,45 @@ +/** Running Turn clock isolated from the transcript's render cycle. */ +import { memo, useEffect, useState } from 'react' +import { TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import { formatLiveRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts' +import { RunningWhaleTail } from './RunningWhaleTail.tsx' +import a11yCss from './accessibility.module.css' +import css from './ChatView.module.css' + +interface RunningStatusProps { + readonly startTime: number | undefined + readonly t: ChatViewSlotProps['t'] +} + +/** + * Show live elapsed time after the current Turn's content without announcing ticks. + * @param props - Current Turn start time and localized copy. + * @returns the blue running indicator; mount only while the Session is running. + */ +export const RunningStatus = memo(function RunningStatus({ startTime, t }: RunningStatusProps) { + const [now, setNow] = useState(Date.now) + useEffect(() => { + if (startTime === undefined) return + setNow(Date.now()) + const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS) + return () => { clearInterval(timer) } + }, [startTime]) + const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', { + duration: formatLiveRunDuration(Math.max(1000, now - startTime), t), + }) + return ( +
+ {t('chat.deepDiving')} +
+ ) +}) diff --git a/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx b/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx index 23d4cbb4ad..19ecae8359 100644 --- a/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx +++ b/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx @@ -52,7 +52,7 @@ export function RunningWhaleTail() { return () => { preference.removeEventListener('change', update) } }, []) return ( -