From b8ede96fd88fc0b12acc6dfd9aba345dd60d815c Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 29 Oct 2026 20:03:57 +0800 Subject: [PATCH 1/9] feat(client): move native app opening into Session menus --- apps/web/tests/context-menus.e2e.ts | 3 +- .../tests/expected/context-menus/session.txt | 1 - .../expected/context-menus/ssh-header.txt | 5 + apps/web/tests/open-in-app-menus.e2e.ts | 164 ++++++++ apps/web/tests/open-in-app-ssh.e2e.ts | 6 + apps/web/tsconfig.json | 1 + .../session-header-menu/guide.i18n.yaml | 16 + .../session-header-menu/guide.md | 17 + .../session-header-menu/guide.zh.md | 17 + packages/bundle/web-app/cordis.patch.yml | 6 +- .../ui-context-menu/src/client/View.tsx | 7 +- .../ui-context-menu/src/client/index.ts | 4 +- .../ui-context-menu/src/client/locales.ts | 18 +- .../tests/overlay.client.spec.tsx | 11 +- .../client/ui-open-in-app/README.i18n.yaml | 28 +- packages/client/ui-open-in-app/README.md | 20 +- packages/client/ui-open-in-app/README.zh.md | 20 +- packages/client/ui-open-in-app/locale/zh.json | 2 +- packages/client/ui-open-in-app/package.json | 3 +- .../src/client/OpenInAppAction.tsx | 2 +- .../src/client/context-menus.tsx | 6 +- .../ui-open-in-app/src/client/controller.ts | 9 +- .../client/ui-open-in-app/src/client/index.ts | 21 +- .../ui-open-in-app/src/client/locales.ts | 8 + .../src/client/session-open-with.ts | 52 +++ .../src/client/session-reveal.tsx | 37 -- .../tests/browser-plugin.client.spec.ts | 51 +-- .../tests/session-open-with.client.spec.ts | 124 ++++++ .../tests/session-reveal.client.spec.ts | 48 --- packages/client/ui-open-in-app/tsconfig.json | 3 + .../client/ui-primitives/README.i18n.yaml | 8 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../client/ui-primitives/src/Menu.module.css | 33 +- packages/client/ui-primitives/src/Menu.tsx | 236 +++++++++--- .../src/menu-submenu-position.ts | 78 ++++ .../ui-primitives/tests/atoms.client.spec.tsx | 16 +- .../tests/menu-submenu.client.spec.tsx | 363 ++++++++++++++++++ .../session-log-export/README.i18n.yaml | 12 +- .../session-log-export/README.md | 6 +- .../session-log-export/README.zh.md | 6 +- .../session-log-export/package.json | 2 + .../src/client/HeaderAction.tsx | 98 ++--- .../session-log-export/src/client/index.ts | 51 ++- .../tests/client-apply.client.spec.tsx | 96 ++++- .../tests/header-action.client.spec.tsx | 221 +++-------- .../session-log-export/tsconfig.client.json | 1 + pnpm-lock.yaml | 6 + tsconfig.host.json | 1 + 49 files changed, 1412 insertions(+), 544 deletions(-) create mode 100644 apps/web/tests/expected/context-menus/ssh-header.txt create mode 100644 apps/web/tests/open-in-app-menus.e2e.ts create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md create mode 100644 packages/client/ui-open-in-app/src/client/session-open-with.ts delete mode 100644 packages/client/ui-open-in-app/src/client/session-reveal.tsx create mode 100644 packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts delete mode 100644 packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts create mode 100644 packages/client/ui-primitives/src/menu-submenu-position.ts create mode 100644 packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts index 0cc7a7abf8..dfa506b6bc 100644 --- a/apps/web/tests/context-menus.e2e.ts +++ b/apps/web/tests/context-menus.e2e.ts @@ -70,7 +70,8 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', const menu = page.getByRole('menu') await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor() const names = await menuLabels(menu) - expect(names.slice(0, 5)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Show in folder']) + expect(names.slice(0, 4)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID']) + expect(names).not.toContain('Open in') expect(names.at(-1)).toBe('Archive session') expect(await menu.getByRole('menuitem').evaluateAll(rows => rows.every(row => row.querySelector('svg[data-icon]')))).toBe(true) expect(await menu.getByRole('separator').count()).toBe(2) diff --git a/apps/web/tests/expected/context-menus/session.txt b/apps/web/tests/expected/context-menus/session.txt index 5468d20e51..ac477e2304 100644 --- a/apps/web/tests/expected/context-menus/session.txt +++ b/apps/web/tests/expected/context-menus/session.txt @@ -2,5 +2,4 @@ Pin session Rename Fork session Copy Session ID -Show in folder Archive session diff --git a/apps/web/tests/expected/context-menus/ssh-header.txt b/apps/web/tests/expected/context-menus/ssh-header.txt new file mode 100644 index 0000000000..00155b9379 --- /dev/null +++ b/apps/web/tests/expected/context-menus/ssh-header.txt @@ -0,0 +1,5 @@ +- menu: + - menuitem "Copy Session as Markdown" + - menuitem "Download session log" + - separator + - menuitem "Feedback" diff --git a/apps/web/tests/open-in-app-menus.e2e.ts b/apps/web/tests/open-in-app-menus.e2e.ts new file mode 100644 index 0000000000..e3448b18fa --- /dev/null +++ b/apps/web/tests/open-in-app-menus.e2e.ts @@ -0,0 +1,164 @@ +/** Session menu opening over built plugins; only native application discovery and launch are simulated. */ +import { mkdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { launchWebScaffold, seedSession, type WebScaffold } from './scaffold.ts' + +const FIRST = 'open-app-menu-foreground' +const OTHER = 'open-app-menu-background' +const ICON = '' +const devices = [ + { name: 'macOS desktop', platform: 'MacIntel', width: 1280, height: 800, touch: false, mobile: false }, + { name: 'Windows desktop', platform: 'Win32', width: 1280, height: 800, touch: false, mobile: false }, + { name: 'Linux desktop', platform: 'Linux x86_64', width: 1280, height: 800, touch: false, mobile: false }, + { name: 'narrow desktop', platform: 'MacIntel', width: 390, height: 844, touch: false, mobile: false }, + { name: 'iPhone', platform: 'iPhone', width: 390, height: 844, touch: true, mobile: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1' }, + { name: 'Android', platform: 'Linux armv8l', width: 412, height: 915, touch: true, mobile: true, userAgent: 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' }, + { name: 'iPad portrait', platform: 'MacIntel', width: 820, height: 1180, touch: true, mobile: true }, + { name: 'iPad landscape', platform: 'MacIntel', width: 1180, height: 820, touch: true, mobile: true }, +] as const + +/** Choose the recorded Session through the real sidebar before testing viewport changes. */ +async function openSeed(page: Page, url: string): Promise { + await page.goto(url) + const group = page.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Ungrouped' }) + await group.click() + await page.locator(`[data-row-key="session:${FIRST}"]`).click() + await page.getByText('DONE', { exact: true }).waitFor() +} + +/** Every visible parent and child menu stays inside the viewport, including long lists. */ +async function expectMenusInside(page: Page): Promise { + const bounds = await page.getByRole('menu').evaluateAll(nodes => nodes.map((node) => { + const rect = node.getBoundingClientRect() + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: innerWidth, height: innerHeight } + })) + expect(bounds.length).toBeGreaterThan(0) + for (const rect of bounds) { + expect(rect.left).toBeGreaterThanOrEqual(0) + expect(rect.top).toBeGreaterThanOrEqual(0) + expect(rect.right).toBeLessThanOrEqual(rect.width) + expect(rect.bottom).toBeLessThanOrEqual(rect.height) + } +} + +/** Install an instance-local native-operation simulation without changing the menu implementation. */ +async function nativeRoutes( + context: BrowserContext, + catalog: readonly string[], + launch: (body: { app: string; path: string }) => Promise, +): Promise { + await context.route('**/open-in-app/apps', route => route.fulfill({ json: { apps: catalog } })) + await context.route('**/open-in-app/icon/*', route => route.fulfill({ body: ICON, contentType: 'image/svg+xml' })) + await context.route('**/open-in-app/open', async (route) => { + const status = await launch(route.request().postDataJSON() as { app: string; path: string }) + await route.fulfill({ status, json: { ok: status === 200 } }) + }) +} + +describe('Open in Session menus', () => { + let scaffold: WebScaffold + let browser: Browser + let otherDirectory: string + beforeAll(async () => { + // The real Host receives SSH facts to avoid probing or starting this runner's native applications. + scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([ + { source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }, + ]) }) + const recorded = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8') + otherDirectory = join(scaffold.workspaceCwd, 'background-directory') + await mkdir(otherDirectory) + await seedSession(scaffold, recorded, FIRST) + await seedSession({ ...scaffold, workspaceCwd: otherDirectory }, recorded, OTHER) + browser = await chromium.launch() + }) + afterAll(async () => { + try { await browser?.close() } finally { await scaffold?.close() } + }) + + it.each(devices)('opens through Header More on $name with device-specific feedback', async (device) => { + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, locale: 'en-US', hasTouch: device.touch, + ...('userAgent' in device ? { userAgent: device.userAgent } : {}), + }) + onTestFinished(() => context.close()) + await context.addInitScript(({ platform, touch }) => { + Object.defineProperty(navigator, 'platform', { value: platform }) + Object.defineProperty(navigator, 'maxTouchPoints', { value: touch ? 5 : 0 }) + }, device) + const launches: { app: string; path: string }[] = [] + await nativeRoutes(context, ['vscode', 'cursor', 'finder'], async (body) => { launches.push(body); return 200 }) + const page = await context.newPage() + await openSeed(page, scaffold.authenticatedUrl) + await page.setViewportSize({ width: device.width, height: device.height }) + const more = page.getByRole('button', { name: 'More actions', exact: true }) + expect(await page.getByRole('banner').locator('[data-open-target="directory"]').count()).toBe(0) + if (device.touch) await more.tap(); else await more.click() + const parent = page.getByRole('menuitem', { name: 'Open in', exact: true }) + await parent.waitFor() + expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).count()).toBe(1) + expect(await page.getByRole('menuitem', { name: 'Pin session', exact: true }).count()).toBe(0) + if (device.touch) await parent.tap(); else await parent.click() + await page.getByRole('menuitem', { name: 'Cursor', exact: true }).waitFor() + await expectMenusInside(page) + if (device.width === 390) { + expect(await page.getByRole('menuitem', { name: 'Back', exact: true }).count()).toBe(1) + } + const choice = page.getByRole('menuitem', { name: 'Cursor', exact: true }) + if (device.touch) await choice.tap(); else await choice.click() + await expect.poll(() => launches).toEqual([{ app: 'cursor', path: scaffold.workspaceCwd }]) + await expect.poll(() => page.getByRole('menu').count()).toBe(0) + await expect.poll(() => page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"cursor"') + if (device.mobile) await page.getByText('Opened on another device', { exact: true }).waitFor() + else expect(await page.getByText('Opened on another device', { exact: true }).count()).toBe(0) + }) + + it('captures a background Session, remembers only success, and prevents a second launch while busy', async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' }) + onTestFinished(() => context.close()) + const response = Promise.withResolvers() + onTestFinished(() => { response.resolve(502) }) + const launches: { app: string; path: string }[] = [] + await nativeRoutes(context, ['vscode', 'cursor', 'finder'], async (body) => { launches.push(body); return response.promise }) + const page = await context.newPage() + await openSeed(page, scaffold.authenticatedUrl) + const background = page.locator(`[data-row-key="session:${OTHER}"]`) + await background.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Open in', exact: true }).click() + await page.getByRole('menuitem', { name: 'Cursor', exact: true }).click() + await expect.poll(() => launches).toEqual([{ app: 'cursor', path: otherDirectory }]) + expect(await background.getAttribute('aria-selected')).toBe('false') + await page.getByRole('button', { name: 'More actions', exact: true }).click() + const disabled = page.getByRole('menuitem', { name: 'Open in', exact: true }) + await expect.poll(() => disabled.isDisabled()).toBe(true) + expect(launches).toHaveLength(1) + await page.keyboard.press('Escape') + response.resolve(502) + await page.getByText('Could not open. Try again.', { exact: true }).waitFor() + expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBeNull() + await background.hover() + await background.getByRole('button', { name: /actions/i }).click() + await expect.poll(() => page.getByRole('menuitem', { name: 'Open in', exact: true }).isEnabled()).toBe(true) + expect(await page.getByRole('menuitem', { name: 'Show in Finder', exact: true }).count()).toBe(0) + }) + + it.each([{ catalog: [] }, { catalog: ['unnameable'] }, { catalog: ['finder'] }])('keeps empty and single-application catalogs consistent: $catalog', async ({ catalog }) => { + const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' }) + onTestFinished(() => context.close()) + await nativeRoutes(context, catalog, async () => 200) + const page = await context.newPage() + await openSeed(page, scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'More actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).waitFor() + const open = page.getByRole('menuitem', { name: 'Open in', exact: true }) + if (catalog[0] !== 'finder') expect(await open.count()).toBe(0) + else { + await open.click() + await page.getByRole('menuitem', { name: 'Finder', exact: true }).waitFor() + expect(await page.getByRole('menuitem', { name: /^Finder/ }).count()).toBe(1) + await expectMenusInside(page) + } + }) +}) diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts index ff57285d88..52ca0d0f27 100644 --- a/apps/web/tests/open-in-app-ssh.e2e.ts +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -65,6 +65,12 @@ describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => { const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'header.expected.md'), snapshot, MODE) + await page.getByRole('button', { name: 'More actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).waitFor() + expect(await page.getByRole('menuitem', { name: 'Open in', exact: true }).count()).toBe(0) + await compareOrRefreshGolden(join(import.meta.dirname, 'expected/context-menus/ssh-header.txt'), + await captureStableAria(page, '[data-context-menu]', scaffold.workspaceCwd), MODE) + await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['header.expected.md']) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7d094253a5..8592244a30 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -182,6 +182,7 @@ "tests/file-upload-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/open-in-app-ssh.e2e.ts", + "tests/open-in-app-menus.e2e.ts", "tests/fork-mid-turn.e2e.ts", "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml new file mode 100644 index 0000000000..8ff580f16d --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md +/: + en: 269f7f4bd0420430 + zh: e9425a80ccc3ef2f +/session-export-requires-context-menus: + en: 835e8e217c9ac011 + zh: 2d97e450247f0c78 +/session-export-requires-context-menus/change: + en: 9041761cf940965b + zh: ee5051b2c7d5f3db +/session-export-requires-context-menus/migration: + en: e8a0ea0a7f7e333b + zh: 30d2541e92b6f6a8 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md new file mode 100644 index 0000000000..b2312568aa --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "Custom Web compositions using session-log-export must provide the context-menu service." +--- + +# Session export requires context menus + +English | [中文](guide.zh.md) + +## Change + +The browser half of `@deepseek-ai/dsh-session-log-export` requires `contextMenus` to render its Header More menu. Custom Web compositions that previously mounted session-log-export without a context-menu provider must add one; otherwise the browser plugin waits for the missing service. The default Web composition already includes the provider. Host export routes and the `/export` command are unchanged. + +## Migration + +1. In a custom Web composition that lacks the provider, add `@deepseek-ai/dsh-client-ui-context-menu` to the same client plugin tree as session-log-export, with the menu package’s required services. See [context-menu composition](../../../../packages/client/ui-context-menu/README.md#use-this-package). Default Web profiles need no configuration change. +2. Restart the profile. Open a Session’s Header More menu and confirm that Copy Session as Markdown and Download session log are available; confirm that downloading still opens its Session-scoped dialog. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md new file mode 100644 index 0000000000..47cce55be7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "使用 session-log-export 的自定义 Web 组合必须提供右键菜单服务。" +--- + +# 会话导出需要右键菜单服务 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-log-export` 的浏览器半包需要 `contextMenus` 来渲染头部更多菜单。此前挂载 session-log-export 但没有右键菜单提供者的自定义 Web 组合必须补充该提供者,否则浏览器插件会等待缺失的服务。默认 Web 组合已包含提供者。Host 导出路由及 `/export` 命令不变。 + +## 迁移 + +1. 自定义 Web 组合缺少提供者时,将 `@deepseek-ai/dsh-client-ui-context-menu` 加入 session-log-export 所在的客户端插件树,并提供菜单包要求的服务。参见[右键菜单组合](../../../../packages/client/ui-context-menu/README.zh.md#use-this-package)。默认 Web profile 无需修改配置。 +2. 重启 profile,打开会话头部更多菜单,确认复制会话为 Markdown 与下载会话日志可用,并确认下载仍显示会话范围的弹窗。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 53d2b2971e..c07bbe81c2 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -67,9 +67,9 @@ - id: session-log-download name: '@deepseek-ai/dsh-session-log-export' - # Session-header "Open In..." split button over the host application - # resolution (macOS/Windows/Linux; a host with no resolved application - # renders no button). Two halves: the host routes and the browser surface. + # Session-menu application choices over Host resolution on macOS, Windows, + # and Linux. An empty catalog hides the action. The Host routes and browser + # controls also serve the file-tree directory opener. - id: open-in-app name: '@deepseek-ai/dsh-host-open-in-app' config: diff --git a/packages/client/ui-context-menu/src/client/View.tsx b/packages/client/ui-context-menu/src/client/View.tsx index 58a9808f3a..c9dbe390c5 100644 --- a/packages/client/ui-context-menu/src/client/View.tsx +++ b/packages/client/ui-context-menu/src/client/View.tsx @@ -1,7 +1,7 @@ /** Shared Web renderer for Client menu contributions. */ import { Menu, Toast } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsStore, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsStore, PropsRenderSlots, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { Shortcuts, ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { createMenuStore } from './store.ts' import type { MenuActionView } from './types.ts' @@ -39,14 +39,15 @@ export interface MenuOverlayInjected { select: (id: string) => void } type Props = PropsStore> - & PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace + & PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace & PropsLocale<'contextMenu'> /** @param props - registered state, target-specific legacy rows, and actions. @returns one menu and persistent feedback. */ -export function MenuOverlay({ useStore, actions, renderSlot, close, select, returnFocus, useCatalog }: Props) { +export function MenuOverlay({ useStore, actions, renderSlot, close, select, returnFocus, useCatalog, t }: Props) { const catalog = useCatalog(value => value) const { target, point, items, notice, occurrence, closing, replacing } = useStore(s => s) return <> new DOMRect(point.x, point.y, 0, 0)} items={menuEntries(items.filter(item => !item.afterContent), catalog, renderSlot)} diff --git a/packages/client/ui-context-menu/src/client/index.ts b/packages/client/ui-context-menu/src/client/index.ts index ac4fc288be..b6c3c6ba8c 100644 --- a/packages/client/ui-context-menu/src/client/index.ts +++ b/packages/client/ui-context-menu/src/client/index.ts @@ -33,7 +33,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Optional React rows for existing extension positions; data contributions need no renderer. */ 'contextMenu.content': { kind: 'keyed'; scope: 'root'; owner: { target: ContextMenuTarget; dismiss: () => void } } } - interface LocaleNamespaceMap { /** Menu operation feedback. */ contextMenu: 'failed' } + interface LocaleNamespaceMap { /** Menu operation feedback. */ contextMenu: 'failed' | 'back' } } /** Maximum time to prepare each contribution before omitting its pending actions. */ @@ -141,7 +141,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { } }, 'context menu: lifetime') ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id: 'context-menu', store, + name: 'shell.overlay', id: 'context-menu', store, locale: 'contextMenu', children: { 'contextMenu.content': { kind: 'keyed', scope: 'root' }, 'contextMenu.icon': { kind: 'keyed', scope: 'root', inline: true }, diff --git a/packages/client/ui-context-menu/src/client/locales.ts b/packages/client/ui-context-menu/src/client/locales.ts index d8b33a422e..f44a8f96c3 100644 --- a/packages/client/ui-context-menu/src/client/locales.ts +++ b/packages/client/ui-context-menu/src/client/locales.ts @@ -1,11 +1,11 @@ /** Context-menu operation feedback, separate from plugin display metadata. */ export const dictionaries = { - zh: { failed: '操作失败,请重试' }, - en: { failed: 'Could not complete the action. Try again.' }, - ja: { failed: '操作に失敗しました。再試行してください。' }, - es: { failed: 'No se pudo completar la acción. Inténtelo de nuevo.' }, - fr: { failed: 'Impossible de terminer l’action. Réessayez.' }, - de: { failed: 'Die Aktion konnte nicht abgeschlossen werden. Versuchen Sie es erneut.' }, - ru: { failed: 'Не удалось выполнить действие. Повторите попытку.' }, - pt: { failed: 'Não foi possível concluir a ação. Tente novamente.' }, -} satisfies Record + zh: { back: '返回', failed: '操作失败,请重试' }, + en: { back: 'Back', failed: 'Could not complete the action. Try again.' }, + ja: { back: '戻る', failed: '操作に失敗しました。再試行してください。' }, + es: { back: 'Atrás', failed: 'No se pudo completar la acción. Inténtelo de nuevo.' }, + fr: { back: 'Retour', failed: 'Impossible de terminer l’action. Réessayez.' }, + de: { back: 'Zurück', failed: 'Die Aktion konnte nicht abgeschlossen werden. Versuchen Sie es erneut.' }, + ru: { back: 'Назад', failed: 'Не удалось выполнить действие. Повторите попытку.' }, + pt: { back: 'Voltar', failed: 'Não foi possível concluir a ação. Tente novamente.' }, +} satisfies Record diff --git a/packages/client/ui-context-menu/tests/overlay.client.spec.tsx b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx index 54194a811b..42268256b9 100644 --- a/packages/client/ui-context-menu/tests/overlay.client.spec.tsx +++ b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react import { afterEach, expect, it, onTestFinished, vi } from 'vitest' import { bindSnapshotSelector, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts' @@ -21,7 +22,7 @@ it('renders legacy Session actions before data contributions and restores More f const useStore = bindSnapshotSelector(store) const view = render(<> - select([])} useStore={useStore} actions={store.actions} close={close} select={run} + key === 'back' ? 'Back' : 'Failed'} useCatalog={select => select([])} useStore={useStore} actions={store.actions} close={close} select={run} renderSlot={(_key, owner: object) => { const { target, dismiss } = owner as { target: ContextMenuTarget; dismiss: () => void } return { @@ -55,7 +56,7 @@ it('renders disabled, grouped and nested data actions and keeps feedback after m { id: 'parent', label: 'Export', children: [{ id: 'child', label: 'Original' }, { id: 'other', label: 'Other', separatorBefore: true }] }, { id: 'footer', label: 'Folder', afterContent: true }, ]) - const view = render( select([])} useStore={bindSnapshotSelector(store)} actions={store.actions} + const view = render( key === 'back' ? 'Back' : 'Failed'} useCatalog={select => select([])} useStore={bindSnapshotSelector(store)} actions={store.actions} close={() => { store.actions.close() }} select={select} renderSlot={() => null} />) expect(view.getByRole('menuitem', { name: 'Unavailable' }).hasAttribute('disabled')).toBe(true) fireEvent.click(view.getByRole('menuitem', { name: 'Open' })) @@ -90,7 +91,9 @@ it('dispatches captured icon keys in rows, submenus and footers and retires rend const runtime = await SlotTestRuntime.create() onTestFinished(() => runtime.dispose()) runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) - runtime.ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } }, ({ renderSlot }) => <>{renderSlot('shell.overlay', {})}) const menuPlugin = await runtime.mount({ inject, apply }) @@ -143,7 +146,7 @@ it('tracks configured shortcuts for nested and footer actions without invoking c { id: 'footer', label: 'Footer', commandId, afterContent: true }, ]) const select = vi.fn() - const view = render( key === 'back' ? 'Back' : 'Failed'} useStore={bindSnapshotSelector(store)} actions={store.actions} useCatalog={bindSnapshotSelector(shortcuts.catalog)} close={vi.fn()} select={select} renderSlot={() => null} />) const item = view.getByRole('menuitem', { name: 'Rename' }) expect(item.getAttribute('aria-keyshortcuts')).toBe('Alt+Meta+G') diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 16367c590b..437a2b672c 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -3,32 +3,32 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-open-in-app/README.md /: - en: be11314755d8f4f9 - zh: dd75b03074eea543 + en: 8caca7252f2cf5c7 + zh: 47a157352d33265e /deepseek-ai-dsh-client-ui-open-in-app: en: 004b805ac638e884 zh: 6ac98df58affefcf /deepseek-ai-dsh-client-ui-open-in-app/summary: - en: e69e4c6df4ddffa1 - zh: d1ef0a722f48e9bd + en: 1e68d4eb8f246ed2 + zh: d8bf9abe0c1cccee /deepseek-ai-dsh-client-ui-open-in-app/table-of-contents: en: d152484eb41ac6b4 zh: 725b47765ee8f30b /deepseek-ai-dsh-client-ui-open-in-app/use-this-package: - en: 056e2bd9e3c78dd8 - zh: be35d2ba53be0442 + en: 812131f17abf1bd7 + zh: 84481ac1622a2762 /deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect: - en: 51e6019f011f394e - zh: 818391262086ae10 + en: e07d3a607d54cb47 + zh: 9a7342f05b64db38 /deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation: - en: ec7a7c56aaeafce6 - zh: 4cee0cd2cb7531df + en: 522e07823c4cbb5f + zh: 042ffeb95f569cc3 /deepseek-ai-dsh-client-ui-open-in-app/further-exploration: - en: 562c58d589fb7afa - zh: 9ba88b381160cc96 + en: 81c9d99c0a1a1dd0 + zh: 446056602f245293 /deepseek-ai-dsh-client-ui-open-in-app/model-experience: - en: 5f9e6e0fb0438628 - zh: fc9a81185ea6cb14 + en: aad141aa922e23f7 + zh: 18432b4f70971fa7 /deepseek-ai-dsh-client-ui-open-in-app/model-experience/kv-cache-effect: en: ca75c51c89c2c9b0 zh: c2241315dc0c89c9 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index c2fb730478..0944b9b7fa 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -1,5 +1,5 @@ --- -description: "Web \"Open In...\" controls: the Session-header split button launching the remembered application on the workspace directory, and the document preview's default-application open and reveal controls for one file." +description: "Open Session directories from application submenus and open previewed files through their native applications." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the browser surface of the open-in-app feature. A Session-header split button opens the current session's workspace directory (the summary's `cwd`) in the remembered application, and its chevron lists every catalog application the host probed as installed; availability, icons, and launches come from the host routes of [`dsh-host-open-in-app`](../../host/open-in-app/README.md), so mount the two packages together. In the right Sidebar's document preview, an "Open" split button and an empty-state button open the previewed file in its default application or show its location, through the Session Remote. A host without the capability renders none of these controls. +Open a Session’s directory through **Open in** in its Header More menu or its row/title context menu. The submenu lists installed, localized applications in Host order with icons and names, without a default marker. File-tree, document-preview, and delivery controls also open files or directories through native applications. Availability, icons, and launches come from [`dsh-host-open-in-app`](../../host/open-in-app/README.md); mount both packages together. A Session without a known directory or eligible application has no Open in item. ## Table of Contents @@ -25,23 +25,23 @@ This package provides the browser surface of the open-in-app feature. A Session- ## Use this package -Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); the pair composes the whole feature in two cordis.yml rows and this row takes no config. The Session header grows an "Open In..." split button whenever the host probed at least one installed catalog application and the session has a known workspace directory. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) grows its file controls whenever the Host reports a desktop through the Session Remote's `session.canOpenWorkspacePath`; removing this row removes every control at once. +Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); this row takes no config. A known Session directory and at least one installed, localized catalog application enable its Open in submenu. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) shows file controls when the Host reports a desktop through `session.canOpenWorkspacePath`; removing this row removes all its controls and menu contributions. ### What to expect -The Session header, file-tree header, and document header use the same 24px-high split button with 9px corners. These headers show only the icon; their tooltip names the default application or the reveal action. They show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The file-tree opener targets the displayed directory supplied as `absolutePath` and shares the application choice and launch state with the Session header. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. +The Session Header places **Open in** in a separated group after download and before feedback. Session-row More, row right-click, and title right-click place it after Copy session ID and before Archive. Even one application stays in a submenu. The file-tree and document headers retain their 24px-high, 9px-radius icon-only split buttons. Their tooltips name the default application or reveal action, and their menus retain the default marker. All application icons fall back to a native-open glyph when loading fails. The file-tree opener targets its owner-supplied `absolutePath` and shares application choice and launch occupancy with Session menus. Directory choices persist in `dsh.open-in-app.choice` only after successful launches; unavailable choices fall back to the first eligible application. A busy directory launch disables both the submenu parent and all application rows. On iOS and Android phones and tablets, including installed PWAs, an acknowledged native open shows “Opened on another device”; reveal shows “File location shown on another device”. Desktop clients stay silent on success, including narrow browser windows and installed desktop PWAs. Every client receives a failure toast when the Host refuses or cannot be reached. Success means the Host accepted the native operation, not that the destination application finished loading the file. The feedback uses the existing mobile-device detector rather than viewport width or installation mode. -The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. +The **Open locally** shortcut captures the main Session’s directory and remembered application. The effective binding remains in the file-tree opener tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and that Session has a directory and installed application to open. An in-flight launch blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller’s launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. -Workspace context menus open the captured folder through the same directory application catalog as the Session header. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save. +Workspace context menus reveal the captured folder through the shared directory application catalog. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save. -Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session reveal follows the installed file-manager catalog and registers a structured Session contribution at order 500, between Copy session ID and Archive. Native context-menu operations share the feature's overlay toast and mobile/desktop feedback policy after dismissal. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast. +Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session submenus resolve the captured Session ID’s directory rather than the main view, and observe application, operation, Session-list, and locale changes. Native operations report through the feature’s global toast after menu dismissal or target changes, without a second registry toast. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast. File menus copy absolute paths without line numbers; URL copying preserves query and fragment. Image files also expose image copy and original download, alongside the existing file actions. Successful downloads and saves show the shared Toast; picker cancellation is silent. @@ -55,7 +55,7 @@ A file menu offers “Open in sidebar” and “Open in floating panel”, which
Implementation internals — click to expand -Both directory slots use `OpenInAppAction` with an explicit `absolutePath`. The `conversation.session.header.utilities` registration reads the Session cwd, while `sidebar.right.tab.files.actions` receives the displayed directory from its owner. The plugin registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes the captured directory and busy state. Adapters resolve native and transport failures to named outcomes; ignored or cancelled requests return a skipped outcome and announce nothing. +[`session-open-with.ts`](src/client/session-open-with.ts) shares one resolver across `session` and the Header owner’s `sessionHeader` target; other contributions do not cross between those targets. The file-tree slot uses `OpenInAppAction` with its owner’s `absolutePath`; there is no independent Session-header opener slot. A page-lifetime [`controller`](src/client/controller.ts) owns availability, persisted choice, and launch POST state. Menu observers and injected component hooks read those same sources. Document-relative routes and payload types come from the Host’s browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. Adapters resolve native and transport failures to named outcomes; skipped requests announce nothing. The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, and sizing. Its injected reporter sends outcomes to one feature-owned `shell.overlay` toast, so switching Sessions or closing the initiating preview cannot discard the result. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. @@ -67,7 +67,7 @@ The directory and file adapters supply application metadata and operations to [` ## Further Exploration - [dsh-host-open-in-app](../../host/open-in-app/README.md) — the host routes serving availability, icons, and launches, and the catalog behind them. -- [dsh-session-log-export](../../session-query/session-log-export/README.md) — the sibling Session-header action. +- [dsh-session-log-export](../../session-query/session-log-export/README.md) — the Header More trigger and its independent context-menu target. - [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.md) — the document preview declaring the header and empty-state child slots the file controls occupy. - [ui-deliverables](../ui-deliverables/README.md) — the delivery cards, which still open declared files through their own routes. - [Web client architecture](../../../docs/subsystems/web-client.md) — how browser plugin rows load and register slots. @@ -77,7 +77,7 @@ The directory and file adapters supply application metadata and operations to [` ## Model Experience -None, as the split button is browser chrome; nothing here reaches a model request. +None, as these opening controls and menus are browser chrome; nothing here reaches a model request. #### KV Cache effect diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 04aa5edc1e..a5290914b8 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web \"Open In...\" 控件:会话头部在记住的应用中打开 workspace 目录的分体按钮,以及文档预览里用默认应用打开、显示单个文件位置的控件。" +description: "通过应用子菜单打开会话目录,并通过原生应用打开预览文件。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供 open-in-app 功能的浏览器表面。会话头部的分体按钮在记住的应用中打开当前会话的 workspace 目录(会话摘要的 `cwd`),下拉箭头列出主机探测到已安装的全部 catalog 应用;可用性、图标与启动均来自 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 的主机路由,所以两个包要一起挂载。在右侧 Sidebar 的文档预览里,一个「打开」分体按钮和一个空态按钮经由 Session Remote 用默认应用打开当前文件或显示其位置。主机没有这项能力时,这些控件一个都不渲染。 +通过会话头部更多菜单或会话行、标题右键菜单中的**在应用中打开**打开会话目录。子菜单按 Host 顺序列出已安装且已本地化的应用,仅显示图标和名称,不标注默认应用。文件树、文档预览和交付控件也可通过原生应用打开文件或目录。可用性、图标和启动由 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 提供;两个包应一起挂载。会话目录未知或没有符合条件的应用时,不显示在应用中打开。 ## 目录 @@ -25,23 +25,23 @@ kind: "package-reference" ## 使用本包 -把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂进 Web 组合;这对包用两行 cordis.yml 组成完整功能,本行不接受任何配置。只要主机探测到至少一个已安装的 catalog 应用且会话有已知的 workspace 目录,会话头部就会出现 "Open In..." 分体按钮。只要 Host 通过 Session Remote 的 `session.canOpenWorkspacePath` 报告有桌面,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览就会长出文件控件;删掉这一行会一次去掉所有控件。 +把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂载到 Web 组合;本行不接受配置。会话目录已知且至少有一个已安装、已本地化的目录应用时,显示在应用中打开子菜单。Host 通过 `session.canOpenWorkspacePath` 报告桌面可用时,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览显示文件控件;删除本行会移除其全部控件与菜单贡献。 ### 预期行为 -会话标题栏、文件树标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。这些标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。它们都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。文件树打开按钮以 `absolutePath` 传入的显示目录为目标,并与会话头部共享应用选择和启动状态。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 +会话头部把**在应用中打开**放在下载之后、反馈之前的独立分隔组。会话行更多、行右键及标题右键菜单把它放在复制 Session ID 之后、归档之前。只有一个应用时仍保留子菜单。文件树与文档标题栏保留高 24px、圆角 9px 的纯图标分体按钮。这些按钮的悬停提示显示默认应用或定位动作,其菜单保留默认标记。所有应用图标加载失败时均使用原生打开图标。文件树按钮以所属组件传入的 `absolutePath` 为目标,并与会话菜单共享应用选择及启动占用状态。目录选择仅在启动成功后持久化到 `dsh.open-in-app.choice`;原选择不可用时回退到首个符合条件的应用。目录启动忙碌时,禁用子菜单父项和全部应用行。 在 iOS、Android 手机和平板上(包括已安装的 PWA),原生打开获得确认后显示「已在其它设备打开」,文件定位则显示「已在其它设备显示文件位置」。电脑端成功时不提示,包括窄窗口浏览器与已安装的桌面 PWA。Host 拒绝请求或无法连通时,所有客户端都会收到失败 toast。成功表示 Host 已接受原生操作,不代表目标应用已完成文件加载。反馈复用现有移动设备判定,不依据窗口宽度或安装模式。 -**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令;其结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。 +**在本地打开**快捷键捕获主会话目录及已记住的应用。有效绑定继续显示在文件树打开按钮的 tooltip 和 `aria-keyshortcuts` 中;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面且该会话具有可打开目录与已安装应用时才执行命令。启动进行中会阻止命令;结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享控制器的启动状态,因此重复操作不会启动两次,也不会在启动期间更改已记住的选择。 文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 -工作区右键菜单通过与会话标题相同的目录应用列表打开被点击的文件夹。文件树目录提供本机显示位置和复制文件夹路径,不提供文件预览或保存。 +工作区右键菜单通过共享的目录应用列表定位被点击的文件夹。文件树目录提供原生定位与复制文件夹路径,不提供文件预览或保存。 -右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话定位沿用已安装文件管理器目录,以顺序 500 注册为结构化 Session 贡献,位于复制 Session ID 与归档之间。右键原生操作在菜单关闭后,通过本功能的浮层 toast 沿用移动端与电脑端反馈规则。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对该原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及其完成提示,包括通过提示中的操作定位保存目标。 +右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话子菜单解析被捕获会话 ID 的目录,不使用主视图,并观察应用、操作、会话列表及语言变化。原生操作在菜单关闭或目标切换后仍通过本功能的全局 toast 报告,不产生第二个注册表提示。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及完成提示,包括通过提示定位保存目标。 文件菜单复制不含行号的绝对路径;网址复制保留查询参数与锚点。图片文件在既有文件操作之外,还提供复制图片与下载原图。下载和保存成功后显示共享 Toast,取消选择器时不提示。 @@ -55,7 +55,7 @@ kind: "package-reference"
实现内幕——点击展开 -两个目录插槽均通过显式的 `absolutePath` 使用 `OpenInAppAction`。`conversation.session.header.utilities` 的注册函数读取会话 cwd,`sidebar.right.tab.files.actions` 则接收所属组件传入的显示目录。插件以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与等待状态。适配器将原生操作和传输失败解析为具名结果;被忽略或取消的请求返回跳过结果,不作播报。 +[`session-open-with.ts`](src/client/session-open-with.ts) 为 `session` 和头部所属包声明的 `sessionHeader` 目标共用一个解析器;其他贡献不会在这两种目标间扩散。文件树插槽通过所属组件的 `absolutePath` 使用 `OpenInAppAction`;会话头部没有独立打开按钮插槽。页面生命周期的 [`controller`](src/client/controller.ts) 持有可用性、持久化偏好与启动 POST 状态。菜单观察器和组件注入 hooks 读取同一组源。文档相对路由与载荷类型来自 Host 的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。适配器将原生操作及传输失败解析为具名结果;跳过的请求不播报。 目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标和尺寸。注入的回调将结果交给本功能在 `shell.overlay` 中持有的唯一 toast,因此切换会话或关闭发起操作的预览不会丢失结果。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 @@ -67,7 +67,7 @@ kind: "package-reference" ## 进一步探索 - [dsh-host-open-in-app](../../host/open-in-app/README.zh.md)——提供可用性、图标与启动的主机路由,及其背后的目录。 -- [dsh-session-log-export](../../session-query/session-log-export/README.zh.md)——会话头部的姊妹动作。 +- [dsh-session-log-export](../../session-query/session-log-export/README.zh.md)——头部更多入口及其独立的右键菜单目标。 - [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.zh.md)——声明文件控件所占头部与空态子 slot 的文档预览。 - [ui-deliverables](../ui-deliverables/README.zh.md)——交付卡片,仍通过自己的路由打开声明过的文件。 - [Web client 架构](../../../docs/subsystems/web-client.zh.md)——浏览器插件行如何加载并注册 slot。 @@ -77,7 +77,7 @@ kind: "package-reference" ## 模型体验 -无。分体按钮是浏览器 chrome;这里没有任何东西进入模型请求。 +无。这些打开控件与菜单是浏览器界面;不会进入模型请求。 #### KV Cache 影响 diff --git a/packages/client/ui-open-in-app/locale/zh.json b/packages/client/ui-open-in-app/locale/zh.json index 078d8a6924..d167d8c088 100644 --- a/packages/client/ui-open-in-app/locale/zh.json +++ b/packages/client/ui-open-in-app/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { "title": "外部应用打开界面", - "description": "在会话和文档预览中提供「打开方式」入口。" + "description": "在应用中打开会话目录或预览文件。" } } diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 6d7778f135..88bf9d219c 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-open-in-app", - "description": "Web \"Open In...\" controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file", + "description": "Session application submenus and native opening controls for directories and previewed files", "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" @@ -79,6 +79,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-host-open-in-app": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-log-export": "workspace:*", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:*", "@deepseek-ai/dsh-native-command": "workspace:*", diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index ceb84fc611..cdbf77d3b6 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -7,7 +7,7 @@ import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/cli import { NS } from './locales.ts' import { OpenTargetButton, type OpenTargetReporter } from './OpenTargetButton.tsx' -/** Browser operations and state shared by the Session header and file tree contributions. */ +/** Directory operations and state shared with Session application submenus. */ export interface OpenInAppActionInjected extends OpenTargetReporter { hooks: { openInAppApps: ObservableSnapshot diff --git a/packages/client/ui-open-in-app/src/client/context-menus.tsx b/packages/client/ui-open-in-app/src/client/context-menus.tsx index 2f6fb14295..5810a3aab7 100644 --- a/packages/client/ui-open-in-app/src/client/context-menus.tsx +++ b/packages/client/ui-open-in-app/src/client/context-menus.tsx @@ -1,4 +1,5 @@ /** Native path and URL menu contributions, independent of their trigger components. */ +import { useState } from 'react' import { IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { Context } from '@deepseek-ai/cordis' @@ -141,5 +142,8 @@ export function registerContextMenus( /** @param props - icon metadata captured with the resolved action. @returns Application image or the native-open glyph. */ export function ContextMenuApplicationIcon({ icon }: Pick, 'icon'>) { - return icon.src ? : + const [failedSource, setFailedSource] = useState() + return icon.src && icon.src !== failedSource + ? { setFailedSource(icon.src) }} /> + : } diff --git a/packages/client/ui-open-in-app/src/client/controller.ts b/packages/client/ui-open-in-app/src/client/controller.ts index c2e25085e8..bd43f8af7e 100644 --- a/packages/client/ui-open-in-app/src/client/controller.ts +++ b/packages/client/ui-open-in-app/src/client/controller.ts @@ -1,4 +1,4 @@ -/** Browser availability/choice state and the launch carrier for the split button. */ +/** Shared directory application catalog, preference, and launch carrier for controls and menus. */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { @@ -20,7 +20,7 @@ type Fetch = (input: string | URL, init?: RequestInit) => Promise /** * Owns the once-per-page availability read, the persisted last choice, and * the launch POST. Availability and choice publish through uSES-safe sources - * so every Session header shares one truth. + * so Session menus and directory controls share the same catalog and choice. */ export class OpenInAppController { /** Installed app ids in host menu order; null until the host answered. */ @@ -107,9 +107,8 @@ export class OpenInAppController { const payload = await response.json() as OpenInAppAppsPayload if (Array.isArray(payload.apps)) apps = payload.apps.filter(id => typeof id === 'string') } - } catch { - // Swallows network failures: an unreachable host reads as no apps, and - // the header simply shows no button rather than a broken one. + } catch (_error) { + // An unreachable Host supplies no directory openers in menus or controls. } this.apps.set(apps) } diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index 49e5fe25e4..e706f9eb25 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -7,8 +7,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' * file defaults and application lists come from the serving Host desktop. */ -import { createElement } from 'react' -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -23,8 +21,8 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/remote' import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared' import { OpenInAppController } from './controller.ts' -import { registerSessionReveal } from './session-reveal.tsx' -import { OpenInAppAction, type OpenInAppActionInjected, type OpenInAppActionProps } from './OpenInAppAction.tsx' +import { registerSessionOpenWith } from './session-open-with.ts' +import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx' import { OpenInAppPathController } from './open-path.ts' import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx' import { FileRouteAction } from './FileRouteAction.tsx' @@ -35,7 +33,7 @@ import { createOpenInAppToastSource, OpenInAppToast } from './open-toast.tsx' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Session-header "open workspace in application" copy. */ + /** Native application menu and control copy. */ 'open-in-app': OpenInAppKey } } @@ -70,7 +68,7 @@ export function apply(ctx: ClientContext): void { yield ctx.slots.register({ name: 'contextMenu.icon', key: 'open-in-app.application' }, ContextMenuApplicationIcon) }) registerContextMenus(ctx, paths, t, controller, announcement.report) - registerSessionReveal(ctx, controller, t, announcement.report) + registerSessionOpenWith(ctx, controller, t, announcement.report) ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'open-in-app.toast', locale: NS, inject: () => ({ hooks: announcement.hooks, dismiss: announcement.dismiss }), @@ -113,17 +111,6 @@ export function apply(ctx: ClientContext): void { choose: (appId) => { controller.choose(appId) }, iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${appId}`, }) - function SessionOpenInAppAction( - props: PropsRuntime<'conversation.session.header.utilities'> & Omit, - ) { - const { sessionId, useSessions } = props - const cwd = useSessions(state => state.byId[sessionId]?.cwd) - return cwd ? createElement(OpenInAppAction, { ...props, absolutePath: cwd }) : null - } - ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({ - name: 'conversation.session.header.utilities', - id: 'open-in-app', order: -10, locale: NS, inject: directoryInjected, - }, SessionOpenInAppAction)) ctx.slots.inject('sidebar.right.tab.files.actions', () => ctx.slots.register({ name: 'sidebar.right.tab.files.actions', id: 'open-in-app', locale: NS, inject: directoryInjected, diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index bd87413a4e..966834479c 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -39,6 +39,7 @@ const PRODUCT_NAMES = { /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'menu.openWith': '在应用中打开', 'menu.openInSidebar': '在侧边栏打开', 'menu.openInFloat': '在浮窗中打开', 'menu.finder': '在 Finder 中显示', @@ -73,6 +74,7 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { + 'menu.openWith': 'Open in', 'menu.openInSidebar': 'Open in sidebar', 'menu.openInFloat': 'Open in floating panel', 'menu.finder': 'Show in Finder', @@ -110,6 +112,7 @@ export type OpenInAppKey = keyof typeof zh /** Japanese copy, checked against the English keys. */ export const ja = { + 'menu.openWith': 'アプリケーションで開く', 'menu.openInSidebar': 'サイドバーで開く', 'menu.openInFloat': 'フローティングパネルで開く', 'menu.finder': 'Finder に表示', @@ -144,6 +147,7 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { + 'menu.openWith': 'Abrir con', 'menu.openInSidebar': 'Abrir en la barra lateral', 'menu.openInFloat': 'Abrir en un panel flotante', 'menu.finder': 'Mostrar en Finder', @@ -177,6 +181,7 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { + 'menu.openWith': 'Ouvrir avec', 'menu.openInSidebar': 'Ouvrir dans la barre latérale', 'menu.openInFloat': 'Ouvrir dans un panneau flottant', 'menu.finder': 'Afficher dans le Finder', @@ -210,6 +215,7 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { + 'menu.openWith': 'Öffnen mit', 'menu.openInSidebar': 'In der Seitenleiste öffnen', 'menu.openInFloat': 'In schwebendem Panel öffnen', 'menu.finder': 'Im Finder anzeigen', @@ -243,6 +249,7 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { + 'menu.openWith': 'Открыть с помощью', 'menu.openInSidebar': 'Открыть на боковой панели', 'menu.openInFloat': 'Открыть на плавающей панели', 'menu.finder': 'Показать в Finder', @@ -276,6 +283,7 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { + 'menu.openWith': 'Abrir com', 'menu.openInSidebar': 'Abrir na barra lateral', 'menu.openInFloat': 'Abrir em painel flutuante', 'menu.finder': 'Mostrar no Finder', diff --git a/packages/client/ui-open-in-app/src/client/session-open-with.ts b/packages/client/ui-open-in-app/src/client/session-open-with.ts new file mode 100644 index 0000000000..07f794d581 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/session-open-with.ts @@ -0,0 +1,52 @@ +/** Application submenus for captured Sessions, shared by header and row menus. */ +import type { Context } from '@deepseek-ai/cordis' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ContextMenuTargetMap, MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client' +import type {} from '@deepseek-ai/dsh-session-log-export/client' +import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared' +import { APP_LABEL_KEY } from './applications.ts' +import type { OpenInAppController } from './controller.ts' +import type { OpenInAppToastSource } from './open-toast.tsx' + +/** + * Share directory availability, successful choices, and launch feedback with the file-tree control. + * @param ctx - owning plugin context. + * @param controller - directory application catalog and launcher. + * @param t - localized action labels. + * @param report - app-wide native operation feedback; launch failures resolve without registry feedback. + */ +export function registerSessionOpenWith( + ctx: Context, + controller: OpenInAppController, + t: PropsLocale<'open-in-app'>['t'], + report: OpenInAppToastSource['report'], +): void { + const resolve = ({ sessionId }: ContextMenuTargetMap['sessionHeader']): readonly MenuAction[] => { + const directory = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd + const applications = (controller.apps.getSnapshot() ?? []).flatMap((id) => { + const key = APP_LABEL_KEY[id] + return key === undefined ? [] : [{ id, name: t(key) }] + }) + if (!directory || applications.length === 0) return [] + const disabled = controller.operation.getSnapshot().phase === 'busy' + return [{ + id: 'openWith', label: t('menu.openWith'), icon: { key: 'open-in-app.application' }, disabled, + children: applications.map(({ id, name }) => ({ + id: `app:${id}`, label: name, disabled, + icon: { key: 'open-in-app.application', src: `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${id}` }, + run: async () => { + const outcome = await controller.launch(id, directory) + if (outcome.kind === 'opened') controller.choose(id) + report(outcome) + }, + })), + }] + } + for (const target of ['session', 'sessionHeader'] as const) { + ctx.effect(() => ctx.contextMenus.register({ + id: 'open-in-app.openWith', target, order: 500, + observe: () => [controller.apps, controller.operation, ctx.sessions.list, ctx.locale], + resolve: value => resolve(value).map(action => target === 'sessionHeader' ? { ...action, separatorBefore: true } : action), + }), `open-in-app: ${target} directory menu`) + } +} diff --git a/packages/client/ui-open-in-app/src/client/session-reveal.tsx b/packages/client/ui-open-in-app/src/client/session-reveal.tsx deleted file mode 100644 index 0dcf0c3a86..0000000000 --- a/packages/client/ui-open-in-app/src/client/session-reveal.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** Native directory reveal contributed to the captured Session's structured menu. */ -import type { Context } from '@deepseek-ai/cordis' -import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import type { OpenInAppController } from './controller.ts' -import type { OpenInAppToastSource } from './open-toast.tsx' - -/** - * Share directory availability and launch feedback with the existing application controls. - * @param ctx - owning plugin context. - * @param controller - directory application catalog and launcher. - * @param t - localized action and failure labels. - * @param report - native operation feedback shared with the application controls. - */ -export function registerSessionReveal( - ctx: Context, - controller: OpenInAppController, - t: PropsLocale<'open-in-app'>['t'], - report: OpenInAppToastSource['report'], -): void { - ctx.effect(() => ctx.contextMenus.register({ - id: 'open-in-app.reveal', target: 'session', order: 500, - observe: () => [controller.apps, ctx.sessions.list, ctx.locale], - resolve: ({ sessionId }) => { - const directory = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd - const applications = controller.apps.getSnapshot() - const manager = applications?.find(id => ['finder', 'explorer', 'filemanager'].includes(id)) - return [{ - id: 'reveal', label: t(applications?.includes('finder') ? 'menu.finder' : 'menu.reveal'), icon: { key: 'open-in-app.reveal' }, - disabled: !directory || manager === undefined, failureLabel: t('path.revealError'), - ...(directory && manager !== undefined ? { run: async () => { - const outcome = await controller.launch(manager, directory) - report(outcome.kind === 'failed' ? { kind: 'failed', failure: 'revealError' } : outcome) - } } : {}), - }] - }, - }), 'open-in-app: Session directory menu') -} diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index e0457da73f..ca27f69e50 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -10,7 +10,6 @@ import { isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives' * proving removal (HMR safety) and the injected controller faces. */ -import type { PropsRuntime, SlotComponent } from '@deepseek-ai/dsh-client-ui-slots' import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -118,6 +117,11 @@ describe('open-in-app browser half', () => { it('renders captured application images and the default glyph without an image URL', () => { const view = render(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application', src: '/captured.png' } })) expect(view.container.querySelector('img')?.getAttribute('src')).toBe('/captured.png') + fireEvent.error(view.container.querySelector('img')!) + expect(view.container.querySelector('img')).toBeNull() + expect(view.container.querySelector('svg')).not.toBeNull() + view.rerender(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application', src: '/new.png' } })) + expect(view.container.querySelector('img')?.getAttribute('src')).toBe('/new.png') view.rerender(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application' } })) expect(view.container.querySelector('img')).toBeNull() expect(view.container.querySelector('svg')).not.toBeNull() @@ -141,14 +145,14 @@ describe('open-in-app browser half', () => { 'open-in-app.reveal', 'open-in-app.copy', 'open-in-app.sidebar', 'open-in-app.float', 'open-in-app.application', ]) expect(ctx.slots.entries('sidebar.workspaces.session.menu.item')).toHaveLength(0) - await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Show in Finder', disabled: false }) }) - menu.select('open-in-app.reveal/reveal') + await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Open in', disabled: false }) }) + menu.select(menu.source.getSnapshot()[0]!.children![0]!.id) menu.dispose() - const face: Partial = ctx.slots.entries('conversation.session.header.utilities')[0]!.inject!() + const face: Partial = ctx.slots.entries('sidebar.right.tab.files.actions')[0]!.inject!() await vi.waitFor(() => { expect(face.hooks?.openInAppLaunch.getSnapshot().phase).toBe(success ? 'idle' : 'error') }) expect(toast.hooks.toast.getSnapshot()).toEqual(success ? mobile ? { kind: 'opened', seq: 1 } : null - : { kind: 'failed', failure: 'revealError', seq: 1 }) + : { kind: 'failed', failure: 'openError', seq: 1 }) expect(notify).not.toHaveBeenCalled() expect(fetcher).toHaveBeenCalledWith('open-in-app/open', expect.objectContaining({ body: JSON.stringify({ app: 'finder', path: '/captured' }), @@ -196,7 +200,7 @@ describe('open-in-app browser half', () => { const select = (cwd: string) => { list.set({ ...list.getSnapshot(), ids: [id], byId: { [id]: { id, displayTitle: 'Main', cwd, running: false, blank: false, updatedAt: 0, retainedBy: { mainView: 1 } }, } }) } - const entry = ctx.slots.entries('conversation.session.header.utilities')[0] + const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0] const face = (entry?.inject as unknown as () => OpenInAppActionInjected)() await vi.waitFor(() => { expect(face.hooks.openInAppApps.getSnapshot()).toEqual(['finder', 'cursor']) }) select('/first') @@ -281,7 +285,7 @@ describe('open-in-app browser half', () => { list.set({ ...list.getSnapshot(), ids: [id], byId: { [id]: { id, displayTitle: 'Main', cwd: '/workspace', running: false, blank: false, updatedAt: 0, retainedBy: { mainView: 1 } }, } }) - const entry = ctx.slots.entries('conversation.session.header.utilities')[0] + const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0] const face = (entry?.inject as unknown as () => OpenInAppActionInjected)() await vi.waitFor(() => { expect(face.hooks.openInAppApps.getSnapshot()).toEqual(['finder']) }) const command = commands.get('workspace.openLocal')! @@ -343,30 +347,13 @@ describe('open-in-app browser half', () => { expect(ctx.slots.entries('shell.overlay')).toHaveLength(0) }) - it('adapts the Session directory, shares the controller with the file tree, and removes both on teardown', async () => { + it('keeps the file-tree control without an independent Session header opener and removes it on teardown', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 }))) - const { ctx, fiber, list } = await bench() - try { - const entry = ctx.slots.entries('conversation.session.header.utilities')[0] - const files = ctx.slots.entries('sidebar.right.tab.files.actions')[0] - expect(files?.component).toBe(OpenInAppAction) - expect(files?.inject).toBe(entry?.inject) - expect(entry?.options).toMatchObject({ id: 'open-in-app' }) - const header = entry?.component as SlotComponent, 'sessionId' | 'useSessions' - >> - const id = 'main' as SessionId - const props = { sessionId: id, useSessions: (select: (state: SessionListState) => T) => select(list.getSnapshot()) } - expect(header(props)).toBeNull() - for (const cwd of ['', '/first', '/second']) { - list.set({ ...list.getSnapshot(), ids: [id], byId: { - [id]: { id, displayTitle: 'Main', cwd, running: false, blank: false, updatedAt: 0, retainedBy: {} }, - } }) - if (cwd === '') expect(header(props)).toBeNull() - else expect(header(props)).toMatchObject({ type: OpenInAppAction, props: { absolutePath: cwd } }) - } - } finally { await fiber.dispose() } - expect(headerEntryIds(ctx)).not.toContain('open-in-app') + const { ctx, fiber } = await bench() + expect(headerEntryIds(ctx)).toEqual([]) + expect(ctx.slots.entries('sidebar.right.tab.files.actions')[0]?.component).toBe(OpenInAppAction) + await fiber.dispose() + expect(headerEntryIds(ctx)).toEqual([]) expect(ctx.slots.entries('sidebar.right.tab.files.actions')).toHaveLength(0) }) @@ -381,7 +368,7 @@ describe('open-in-app browser half', () => { }) vi.stubGlobal('fetch', fetcher) const { ctx, fiber } = await bench() - const entry = ctx.slots.entries('conversation.session.header.utilities')[0] + const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0] const injected: Partial | undefined = entry?.inject?.() if (injected?.hooks === undefined || injected.iconUrl === undefined || injected.choose === undefined || injected.launch === undefined || injected.report === undefined) { @@ -417,7 +404,7 @@ describe('open-in-app browser half', () => { return new Response('', { status: 502 }) })) const { ctx, fiber } = await bench() - const entry = ctx.slots.entries('conversation.session.header.utilities')[0] + const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0] const injected: Partial | undefined = entry?.inject?.() if (injected?.hooks === undefined || injected.launch === undefined) throw new Error('expected the injected launch action') const { hooks } = injected diff --git a/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts b/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts new file mode 100644 index 0000000000..b8db4a0154 --- /dev/null +++ b/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts @@ -0,0 +1,124 @@ +import { Context } from '@deepseek-ai/cordis' +import { expect, it, onTestFinished, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { OpenInAppController } from '../src/client/controller.ts' +import { registerSessionOpenWith } from '../src/client/session-open-with.ts' +import { en, zh, NS } from '../src/client/locales.ts' + +function bench(apps: readonly string[] | null = ['finder', 'cursor'], cwd: string | undefined = '/captured') { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const locale = new LocaleRuntime(ctx) + ctx.provide('locale', locale) + locale.register(NS, { en, zh }); locale.setLocale('en') + const failed = vi.fn(), report = vi.fn() + const registry = new MenuRegistry(failed) + onTestFinished(() => { registry.dispose() }) + ctx.provide('contextMenus', { register: registry.register.bind(registry) } as never) + const sessionId = SessionId('captured'), mainId = SessionId('main') + const row = { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} } + const list = createSnapshotStore({ ids: [sessionId, mainId], byId: { + [sessionId]: row, [mainId]: { ...row, id: mainId, cwd: '/main', retainedBy: { mainView: 1 } }, + }, phase: 'ready', projectionsBySession: {} }) + ctx.provide('sessions', { list } as never) + const gate = Promise.withResolvers() + const fetcher = vi.fn(() => gate.promise) + const controller = new OpenInAppController(fetcher) + controller.choice.set(''); controller.apps.set(apps) + const choose = vi.spyOn(controller, 'choose') + const fiber = ctx.plugin((scope) => { registerSessionOpenWith(scope, controller, locale.bind(NS), report) }) + const resolve = (kind: 'session' | 'sessionHeader' = 'session') => registry.resolve(kind === 'session' + ? { kind, value: { sessionId, displayTitle: 'Captured' } } + : { kind, value: { sessionId } }) + return { ctx, registry, locale, controller, list, row, sessionId, fetcher, gate, choose, report, failed, fiber, resolve } +} + +it.each([null, [], ['unlabelled']])('omits unavailable catalog %j from both menu targets', async (apps) => { + const b = bench(apps) + await b.fiber.await() + for (const kind of ['session', 'sessionHeader'] as const) expect(b.resolve(kind).source.getSnapshot()).toEqual([]) +}) + +it('omits unknown directories and follows metadata, locale and applications without labeling preferences', async () => { + const b = bench() + await b.fiber.await() + b.list.set({ ...b.list.getSnapshot(), byId: {} }) + const menu = b.resolve(), header = b.resolve('sessionHeader') + expect(menu.source.getSnapshot()).toEqual([]) + b.list.set({ ...b.list.getSnapshot(), byId: { [b.sessionId]: b.row } }) + expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Open in', disabled: false }) + expect(menu.source.getSnapshot()[0]?.separatorBefore).toBeUndefined() + expect(header.source.getSnapshot()[0]?.separatorBefore).toBe(true) + expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Finder', 'Cursor']) + b.controller.choose('cursor') + expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Finder', 'Cursor']) + b.locale.setLocale('zh') + expect(menu.source.getSnapshot()[0]?.label).toBe('在应用中打开') + expect(header.source.getSnapshot()[0]?.children?.[1]?.label).toBe('Cursor') + b.controller.apps.set(['cursor', 'unknown', 'finder']) + expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Cursor', '访达']) + b.controller.apps.set(['finder']) + expect(menu.source.getSnapshot()[0]?.children).toHaveLength(1) + expect(menu.source.getSnapshot()[0]?.children?.[0]).toMatchObject({ + label: '访达', icon: { key: 'open-in-app.application', src: 'open-in-app/icon/finder' }, + }) + b.controller.apps.set([]) + expect(menu.source.getSnapshot()).toEqual([]) + expect(header.source.getSnapshot()).toEqual([]) +}) + +it.each(['session', 'sessionHeader'] as const)('launches the captured %s directory, disables all openers, and remembers only after success', async (kind) => { + const b = bench() + await b.fiber.await() + const menu = b.resolve(kind), observing = b.resolve(kind === 'session' ? 'sessionHeader' : 'session') + const action = menu.source.getSnapshot()[0]!.children![1]! + menu.select(action.id) + expect(b.fetcher).toHaveBeenCalledExactlyOnceWith('open-in-app/open', expect.objectContaining({ body: JSON.stringify({ app: 'cursor', path: '/captured' }) })) + expect(b.choose).not.toHaveBeenCalled() + expect(observing.source.getSnapshot()[0]).toMatchObject({ disabled: true }) + expect(observing.source.getSnapshot()[0]?.children?.every(row => row.disabled)).toBe(true) + observing.select(observing.source.getSnapshot()[0]!.children![0]!.id) + expect(b.fetcher).toHaveBeenCalledOnce() + b.list.set({ ...b.list.getSnapshot(), byId: { [b.sessionId]: { ...b.row, cwd: '/changed' } } }) + menu.dispose() + b.gate.resolve(new Response()) + await vi.waitFor(() => { expect(b.report).toHaveBeenCalledExactlyOnceWith({ kind: 'opened' }) }) + expect(b.choose).toHaveBeenCalledExactlyOnceWith('cursor') + expect(b.controller.choice.getSnapshot()).toBe('cursor') + expect(observing.source.getSnapshot()[0]?.disabled).toBe(false) + expect(b.failed).not.toHaveBeenCalled() +}) + +it.each([{ kind: 'failed', failure: 'openError' }, { kind: 'skipped' }] as const)('does not remember a %j launch or duplicate its feedback', async (outcome) => { + const b = bench() + await b.fiber.await() + vi.spyOn(b.controller, 'launch').mockResolvedValue(outcome) + const menu = b.resolve() + menu.select(menu.source.getSnapshot()[0]!.children![1]!.id) + await vi.waitFor(() => { expect(b.report).toHaveBeenCalledExactlyOnceWith(outcome) }) + expect(b.choose).not.toHaveBeenCalled() + expect(b.controller.choice.getSnapshot()).toBe('') + expect(b.failed).not.toHaveBeenCalled() +}) + +it('withdraws both registrations and their observers on unload and reinstalls without duplicates', async () => { + const b = bench() + await b.fiber.await() + const menu = b.resolve(), header = b.resolve('sessionHeader') + const id = menu.source.getSnapshot()[0]!.children![0]!.id + await b.fiber.dispose() + expect(menu.source.getSnapshot()).toEqual([]) + expect(header.source.getSnapshot()).toEqual([]) + menu.select(id) + expect(b.fetcher).not.toHaveBeenCalled() + b.controller.apps.set(['cursor']) + expect(b.resolve().source.getSnapshot()).toEqual([]) + const replacement = b.ctx.plugin((scope) => { registerSessionOpenWith(scope, b.controller, b.locale.bind(NS), b.report) }) + await replacement.await() + expect(b.resolve().source.getSnapshot()).toHaveLength(1) + expect(b.resolve('sessionHeader').source.getSnapshot()).toHaveLength(1) +}) diff --git a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts b/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts deleted file mode 100644 index 1638722be5..0000000000 --- a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import { expect, it, onTestFinished, vi } from 'vitest' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts' -import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' -import { SessionId } from '@deepseek-ai/dsh-session/types' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { OpenInAppController } from '../src/client/controller.ts' -import { registerSessionReveal } from '../src/client/session-reveal.tsx' -import { en, zh, NS } from '../src/client/locales.ts' - -it.each([ - [['finder'], '/workspace', 'Show in Finder', false], - [['explorer'], '/workspace', 'Show in folder', false], - [['filemanager'], '/workspace', 'Show in folder', false], - [['vscode'], '/workspace', 'Show in folder', true], - [null, '/workspace', 'Show in folder', true], - [['finder'], undefined, 'Show in Finder', true], -] as const)('reveals the captured directory with catalog %j and path %s', async (apps, cwd, label, disabled) => { - const ctx = new Context() - onTestFinished(() => ctx.fiber.dispose()) - const locale = new LocaleRuntime(ctx) - ctx.provide('locale', locale) - locale.register(NS, { en, zh }); locale.setLocale('en') - const registry = new MenuRegistry(vi.fn()) - onTestFinished(() => { registry.dispose() }) - ctx.provide('contextMenus', { register: registry.register.bind(registry) } as never) - const sessionId = SessionId('captured') - const list = createSnapshotStore({ ids: [sessionId], byId: { - [sessionId]: { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} }, - }, phase: 'ready', projectionsBySession: {} }) - ctx.provide('sessions', { list } as never) - const controller = new OpenInAppController() - controller.apps.set(apps) - const launch = vi.spyOn(controller, 'launch').mockResolvedValue({ kind: 'opened' }) - const report = vi.fn() - registerSessionReveal(ctx, controller, locale.bind(NS), report) - const menu = registry.resolve({ kind: 'session', value: { sessionId, displayTitle: 'Captured' } }) - expect(menu.source.getSnapshot()[0]).toMatchObject({ label, disabled }) - menu.select('open-in-app.reveal/reveal') - if (disabled) { - expect(launch).not.toHaveBeenCalled() - expect(report).not.toHaveBeenCalled() - } else { - expect(launch).toHaveBeenCalledWith(apps?.[0], cwd) - await vi.waitFor(() => { expect(report).toHaveBeenCalledWith({ kind: 'opened' }) }) - } -}) diff --git a/packages/client/ui-open-in-app/tsconfig.json b/packages/client/ui-open-in-app/tsconfig.json index 9e6a381554..80b13b76e1 100644 --- a/packages/client/ui-open-in-app/tsconfig.json +++ b/packages/client/ui-open-in-app/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../host/open-in-app" }, + { + "path": "../../session-query/session-log-export/tsconfig.client.json" + }, { "path": "../locale/tsconfig.client.json" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 8bb6988406..5b96268749 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: ead6be55c7c9d7cf zh: 125ad36db7328551 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 289fe5c01b026e81 - zh: 5680066a8443fba9 + en: 088b6d7b57b307e4 + zh: 1a0f4085124d217e /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: f13b4ba09521d300 zh: a6093993d8fd0cdb @@ -30,8 +30,8 @@ en: 81e58c7b342baf3c zh: 6cd47b16124b215c /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: - en: 6fbd9df222b41e09 - zh: 3bdb4a6b9b6bff9e + en: 7edcce1f9fde5341 + zh: 6e56fddb617aef5f /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: en: fd510d34404eb090 zh: d05b0890db929ae6 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4e538ecaeb..d0913fac73 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. | | `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. | -| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | +| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Shift+Tab closes back to the anchor; Escape returns from an open submenu before closing the root. Selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | | `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. | @@ -141,10 +141,12 @@ The atoms cannot read the application locale, so every piece of user-facing copy `Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. The `outline` variant preserves a transparent 0.5px layout border and paints its stroke with an inset shadow. The stroke reads ui-theme's DPR-aware `--dsh-hairline-width`, with a 0.5px fallback outside the theme. Feature classes customize its color through `--dsh-button-outline-color` and preserve the border width, transparent border color, and box-shadow. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry. -Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted. +Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. Pointer presses on that control or its descendants stay inside the menu interaction, so its click handler can toggle the popup. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted. `Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color. +Submenu parents show a right chevron and open through focus, click, or hover-capable non-touch pointer events. ArrowRight enters the child; ArrowLeft and Escape return focus to the parent. Each child portals outside the parent's scrolling area, prefers the right side, flips left when it fits, and clamps vertically with internal scrolling. When neither side fits, click or ArrowRight replaces the visible parent panel with the child and a return row; focus alone keeps the parent panel visible. `submenuBackLabel` supplies its localized text; when omitted, the return row uses the caller's parent label beside a left arrow and never selects the parent. Placement depends on available space, not the device's user agent; touch clicks open children on wide screens too. Resizing retains focused child nodes. Compatibility mouse hover events do not open or collapse children. The pointer grace permits crossing the gap between cards; sibling rows close the previous child. Grace dismissal returns focus left in the child to its parent. Removing or disabling the parent, or emptying its children, restores the root panel and cancels that submenu. Focus left in the removed child returns to its enabled parent, the first enabled root row, or the anchor; focus already moved elsewhere stays there. + `Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur. `MenuGroup` renders a `role="group"` section named by its localized heading, with an instance-owned heading id. It shares heading typography, spacing, and sticky positioning between custom menus and listboxes. Headings are transparent at rest; only `data-stuck` enables the theme's 94%-opaque group-header fill in either palette. Outside `data-platform="darwin"`, headings use `--dsw-radius-md` corners; the enclosing menu keeps its translucent material. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 5e0262cdc2..094f5baff1 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -49,7 +49,7 @@ kind: "package-library" | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 | | `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。工具栏带 `data-document-zoom-controls`,可见时另带 `data-document-zoom-visible`。owner 的 `className` 覆盖 `bottom` 与 `max-width`,不受样式表加载顺序影响;`inset-inline-start` 和 `transform` 负责居中与进出场位移,`opacity` 负责淡入淡出,三者都由原语保留。渲染器共用导出的缩放范围。 | -| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | +| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Shift+Tab 关闭并把焦点还给锚点;Escape 先退出已打开的子菜单,再关闭根菜单。选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | | `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 | @@ -141,10 +141,12 @@ kind: "package-library" `Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。`outline` 变体保留 0.5px 透明边框用于布局,并通过内阴影绘制描边。描边宽度读取 ui-theme 随设备像素比更新的 `--dsh-hairline-width`,未安装主题时回退到 0.5px。功能样式通过 `--dsh-button-outline-color` 定制描边颜色,并保留边框宽度、透明边框颜色和 box-shadow。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。 -锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。 +锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。该控件及其子元素上的指针按下仍属于菜单内部交互,让控件的点击处理器可以切换弹窗。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。 `Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。 +子菜单父项显示向右箭头,可通过聚焦、点击或支持悬停的非触摸指针事件展开。ArrowRight 进入子级;ArrowLeft 与 Escape 将焦点还给父项。每个子级通过 portal 脱离父级滚动区域,优先向右展开,左侧能容纳时向左翻转,并在垂直方向限制位置、内部限高滚动。两侧均放不下时,点击或 ArrowRight 用子级及返回行替换当前可见的父菜单面板;仅聚焦时仍显示父面板。`submenuBackLabel` 提供该行的本地化文案;省略时使用调用方的父项标签配合向左箭头,返回操作不会选定父项。布局取决于可用空间,不取决于设备 UA;宽屏触控设备也可通过点击展开子级。调整窗口大小时保留已聚焦子项的 DOM 节点。兼容鼠标悬停事件不会展开或收起子级。指针宽限允许跨越卡片间隙;同级其他行会关闭原子级。宽限结束关闭子级时,仍在子级中的焦点会回到父项。父项被移除、禁用或其子项清空时,恢复根面板并取消该子菜单。留在被移除子级中的焦点会回到仍可用的父项、首个可用根菜单行或锚点;已经移到其他位置的焦点保持不变。 + `Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。 `MenuGroup` 渲染以本地化标题命名的 `role="group"` 区段,标题 id 由各实例独立持有。自定义菜单与列表框共用其标题字体、间距和吸顶定位。标题原位透明;只有 `data-stuck` 才启用主题在浅/深色模式下的 94% 不透明分组标题填充。`data-platform="darwin"` 以外的标题使用 `--dsw-radius-md` 圆角;外围菜单保留半透明材质。 diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index d3c24aad2b..6c5fc1d185 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -31,7 +31,7 @@ left: 0; z-index: 100; min-width: 144px; - max-width: 360px; + max-width: min(360px, calc(100vw - 24px)); } /* Legacy slots can render no rows; keep empty context popups out of layout. */ @@ -64,9 +64,8 @@ /* Viewport fit: the card stops 12px short of the viewport's top/bottom edges * (the portal MARGIN in Menu.tsx; the top side widens to the frame's published * overlay inset) and taller content scrolls inside - * .viewport, so a pinned .footer stays visible. Menus with submenu rows skip - * this class — the overflow clip would crop the side card, so they rely on - * staying short. */ + * .viewport, so a pinned .footer stays visible. Submenus escape the scroll + * clip through their own body portal. */ .scrollable { max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px))); } @@ -290,24 +289,22 @@ display: block; } -/* The submenu aligns with the parent's bottom edge. Its hit bridge covers - * the menu inset and the 6px gap so pointer movement keeps it open. */ +/* Child cards remain mounted while resizing between side and single-panel views. */ .submenu { - position: absolute; - top: auto; - bottom: -4px; - left: calc(100% + 10px); - z-index: 101; + position: fixed; + z-index: 1101; min-width: 163px; + max-width: min(360px, calc(100vw - 24px)); } -.submenu::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: -10px; - width: 10px; +/* Retain the parent's geometry and rows for placement and focus restoration. */ +.parentPage { + visibility: hidden; + pointer-events: none; +} + +.submenu > .item { + flex: none; } .list[data-menu-phase='dismiss'], diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 753de26f95..f27e728005 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -2,7 +2,8 @@ import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode, SyntheticEvent } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' -import { IconCheckOutlineRegular } from './icons/index.tsx' +import { IconCheckOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular } from './icons/index.tsx' +import { useMenuSubmenuPosition } from './menu-submenu-position.ts' import { overlayTopMargin } from './overlay-top-margin.ts' import { usePointerGrace } from './pointer-grace.ts' import { isBehindModal } from './useModalLayer.ts' @@ -23,7 +24,7 @@ export interface MenuItem { icon?: ReactNode /** Destructive row: error-colored text/icon and danger hover fill. */ danger?: boolean - /** Nested card opened to the right on hover/focus. */ + /** Child choices opened by hover, focus, click, or ArrowRight; placement follows available space. */ submenu?: readonly MenuEntry[] } @@ -143,14 +144,20 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * list instead — and Escape or Shift+Tab close it and return focus to the * anchor's first button, and selecting a row does the same — the rows unmount * with the list. Only a keyboard on the trigger or inside the list is - * intercepted; Tab presses elsewhere on the page stay the browser's. + * intercepted; Tab presses elsewhere on the page stay the browser's. Escape + * first returns from a visible child, while ArrowRight enters and ArrowLeft returns. + * When neither side fits, click or ArrowRight opens a single-panel child page; + * focus alone retains the parent page. * @param props.contextual - cursor positioning, context dismissal, and per-divider icon alignment. * @param props.exiting - retain an inert popup during dismissal or replacement fade. - * @param props.returnFocus - original control captured before asynchronous menu preparation. + * @param props.returnFocus - original control captured before asynchronous menu preparation; + * pointer presses on it or its descendants stay inside the menu interaction. * @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable data rows and optional separators (default none; with no `children` either, the list is empty). + * @param props.submenuBackLabel - localized single-panel return label; omitted uses the caller's + * parent-row label beside a back arrow, without selecting that row. * @param props.selectedId - row shown as selected. * @param props.selectedIds - rows shown as selected when a menu contains independent option groups. * @param props.onSelect - data-row activation callback (not called for disabled rows or submenu parents that only open children). @@ -192,7 +199,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * document.body outside the owner's DOM subtree. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus }: { +export function Menu({ open, anchor, items = [], children, submenuBackLabel, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus }: { open: boolean /** Pointer-positioned popup: cursor clearance, grouped icons, and context gestures. */ contextual?: boolean @@ -203,6 +210,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI autoFocus?: boolean anchor: ReactNode items?: readonly MenuEntry[] + /** Localized return action; omitted uses the caller's parent label with a back arrow. */ + submenuBackLabel?: string | undefined children?: ReactNode footer?: readonly MenuEntry[] selectedId?: string | undefined @@ -272,14 +281,88 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI queueMicrotask(() => { if (openRef.current && exitingRef.current === undefined) return const active = document.activeElement - if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor(navigation) + if (active === null || active === document.body || listRef.current?.contains(active) === true + || submenuRef.current?.contains(active) === true) refocusAnchor(navigation) }) } const exitingRef = useRef(exiting) exitingRef.current = exiting const openRef = useRef(open) openRef.current = open - const [openSubmenuId, setOpenSubmenuId] = useState(null) + const [openSubmenu, setOpenSubmenu] = useState<{ id: string; activated: boolean } | null>(null) + const liveSubmenu = [...items, ...(footer ?? [])].find((entry): entry is MenuItem => + !isSeparator(entry) && !isLabel(entry) && entry.id === openSubmenu?.id + && entry.disabled !== true && entry.submenu !== undefined && entry.submenu.length > 0) + const openSubmenuId = liveSubmenu?.id ?? null + const submenuInvalidated = openSubmenu !== null && liveSubmenu === undefined + const submenuRef = useRef(null) + const submenuTrigger = useRef(null) + // Capture focus ownership before React removes an invalidated child from the DOM. + const restoreInvalidatedFocus = submenuInvalidated && open && exiting === undefined + && (submenuRef.current?.contains(document.activeElement) === true || submenuTrigger.current === document.activeElement) + const enterSubmenu = useRef(false) + const submenuBackFocused = useRef(false) + const focusFirstChild = (): void => { + const child = submenuRef.current + const first = child?.querySelector('button[role="menuitem"]:not(:disabled):not([data-menu-back])') + ?? child?.querySelector('button:not(:disabled)') + first?.focus() + } + const returningFromSubmenu = useRef(false) + const submenuPosition = useMenuSubmenuPosition( + open && exiting === undefined ? openSubmenuId : null, listRef, submenuTrigger, submenuRef, contextual, + ) + const submenuPage = openSubmenuId !== null && openSubmenu?.activated === true && submenuPosition?.page === true + const submenuHidden = submenuPosition === null || (submenuPosition.page && !submenuPage) + const submenuState = useRef({ id: openSubmenuId, page: submenuPage }) + submenuState.current = { id: submenuHidden ? null : openSubmenuId, page: submenuPage } + const { arm: armSubmenuClose, cancel: cancelSubmenuClose } = usePointerGrace(() => { + if (submenuState.current.page) return + if (submenuRef.current?.contains(document.activeElement)) returnToParent() + else setOpenSubmenu(null) + }) + const openChild = (id: string, trigger: HTMLButtonElement, enter = false): void => { + cancelSubmenuClose() + submenuTrigger.current = trigger + enterSubmenu.current = enter + setOpenSubmenu(current => current?.id === id + ? enter && !current.activated ? { id, activated: true } : current + : { id, activated: enter }) + if (enter) focusFirstChild() + } + const returnToParent = (): void => { + cancelSubmenuClose() + setOpenSubmenu(null) + returningFromSubmenu.current = true + } + useLayoutEffect(() => { + if (submenuInvalidated) { + cancelSubmenuClose() + enterSubmenu.current = false + setOpenSubmenu(null) + if (restoreInvalidatedFocus + && (document.activeElement === document.body || document.activeElement === submenuTrigger.current)) { + const trigger = submenuTrigger.current + const target = trigger?.isConnected === true && !trigger.disabled ? trigger + : listRef.current?.querySelector('button:not(:disabled)') + returningFromSubmenu.current = true + if (target != null) target.focus() + else refocusAnchor() + returningFromSubmenu.current = false + } + } + if (openSubmenuId === null) submenuBackFocused.current = false + if (openSubmenuId === null && returningFromSubmenu.current) { + submenuTrigger.current?.focus() + returningFromSubmenu.current = false + } + if (openSubmenuId === null || submenuHidden) return + if (enterSubmenu.current || (submenuPage && listRef.current?.contains(document.activeElement)) + || (!submenuPage && submenuBackFocused.current)) { + focusFirstChild() + enterSubmenu.current = false + } + }, [openSubmenuId, submenuPosition, submenuPage, submenuHidden, submenuInvalidated, restoreInvalidatedFocus, cancelSubmenuClose]) const [fixedPos, setFixedPos] = useState(null) const close = useRef(onClose) close.current = onClose @@ -368,7 +451,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI useEffect(() => { if (!open || exiting !== undefined || (portal && fixedPos === null) || !autoFocus - || listRef.current?.contains(document.activeElement)) return + || listRef.current?.contains(document.activeElement) || submenuRef.current?.contains(document.activeElement)) return const first = listRef.current?.querySelector('button:not(:disabled)') walkIndex.current = first === undefined || first === null ? null : 0 if (first != null) focusWithoutRing(first) @@ -376,7 +459,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI useEffect(() => { if (!open || exiting !== undefined) { - setOpenSubmenuId(null) + setOpenSubmenu(null) walkIndex.current = null return } @@ -384,8 +467,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI const onPointerDown = (e: PointerEvent) => { if (!(e.target instanceof Node) || (contextual && e.button === 2)) return // The portaled list is outside the anchor subtree; check both. - if (rootRef.current?.contains(e.target) === true) return - if (listRef.current?.contains(e.target) === true) return + if (rootRef.current?.contains(e.target) === true || returnFocus?.()?.contains(e.target) === true) return + if (listRef.current?.contains(e.target) === true || submenuRef.current?.contains(e.target) === true) return onClose() } const onKeyDown = (e: KeyboardEvent) => { @@ -394,23 +477,36 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI // Where the keyboard is, computed once: the menu owns it when it holds a // row or sits on its anchor region. const focused = document.activeElement - const insideList = listRef.current?.contains(focused) === true + const insideChild = submenuRef.current?.contains(focused) === true + const insideList = listRef.current?.contains(focused) === true || insideChild const anchored = focused === returnFocus?.() || rootRef.current?.contains(focused) === true || insideList if (e.key === 'Escape' && !e.shiftKey) { e.preventDefault() if (e.repeat) return + if (submenuState.current.id !== null) { returnToParent(); return } // Closing hands the keyboard back when the menu had it — and, as this // primitive always did for autoFocus menus, when it held the keyboard // and lost it again (a row that unmounted under it). onClose() if (anchored || autoFocus) refocusAnchor() } + if (e.key === 'ArrowLeft' && anchored && submenuState.current.id !== null) { + e.preventDefault() + returnToParent() + return + } + if (e.key === 'ArrowRight' && anchored && focused instanceof HTMLButtonElement + && focused.getAttribute('aria-haspopup') === 'menu') { + e.preventDefault() + focused.click() + return + } // Tab settles like Enter and Shift+Tab leaves like Escape, so a menu's // keys mean what they mean in the composer. Only a keyboard already on // the trigger or inside the list is intercepted: Tab elsewhere on the // page keeps the browser's traversal even while a menu is open. if (e.key === 'Tab') { - const list = listRef.current + const list = submenuState.current.page ? submenuRef.current : listRef.current if (list === null || !anchored) return if (e.shiftKey) { e.preventDefault() @@ -447,7 +543,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI // otherwise re-enter at the near end on every press and the walk would // alternate between two rows. if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return - const list = listRef.current + const list = insideChild || submenuState.current.page ? submenuRef.current : listRef.current if (list === null || !anchored) return const buttons = Array.from(list.querySelectorAll('button:not(:disabled)')) if (buttons.length === 0) return @@ -481,7 +577,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI document.removeEventListener('keydown', onEscape, true) window.removeEventListener('blur', onWindowBlur) } - }, [open, onClose, autoFocus, contextual, exiting]) + }, [open, onClose, autoFocus, contextual, exiting, returnFocus]) useLayoutEffect(() => { if (exiting === 'dismiss' && listRef.current?.contains(document.activeElement)) refocusAnchor() @@ -492,12 +588,11 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI // effect, not the listener effect above: that one re-runs on every `onClose` // identity change and would cancel the grace mid-transit. useEffect(() => { - if (!open) cancelClose() - }, [open, cancelClose]) + if (!open) { cancelClose(); cancelSubmenuClose() } + }, [open, cancelClose, cancelSubmenuClose]) - // The submenu card is absolutely positioned outside the list box; the - // scroll clip would crop it, so only submenu-free menus get the height cap. - const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0) + const hoverAllowed = (event: { pointerType: string }): boolean => event.pointerType !== 'touch' + && window.matchMedia('(hover: hover)').matches const itemColumns = iconColumns(items, contextual) const footerColumns = iconColumns(footer ?? [], contextual) @@ -516,8 +611,14 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
{ setOpenSubmenuId(entry.id) } : undefined} - onMouseLeave={() => { setOpenSubmenuId(null) }} + onPointerEnter={hasSub ? (event) => { + if (!hoverAllowed(event) || entry.disabled) return + const trigger = event.currentTarget.querySelector('button') + if (trigger !== null) openChild(entry.id, trigger) + } : undefined} + onPointerLeave={hasSub ? (event) => { + if (hoverAllowed(event) && !submenuState.current.page) armSubmenuClose() + } : undefined} > - {subOpen && entry.submenu !== undefined && ( - - {entry.submenu.map(sub => isSeparator(sub) - ?
- : isLabel(sub) ?
{sub.text}
: ( - - ))} - + {subOpen && entry.submenu !== undefined && exiting === undefined && createPortal( + { submenuBackFocused.current = event.target.hasAttribute('data-menu-back') }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) submenuBackFocused.current = false + }} + onClick={(event) => { + event.stopPropagation() + refocusAfterSelection() + }} + > + {submenuPage && <> + +
+ } +
+ {entry.submenu.map(sub => isSeparator(sub) + ?
+ : isLabel(sub) ?
{sub.text}
: ( + + ))} +
+ , document.body, )}
) @@ -575,7 +704,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI const row = e.target instanceof Element ? e.target.closest('button[role="menuitem"]') : null if (row === null || row.getAttribute('aria-haspopup') === 'menu') return if (row.closest('[role="menu"]') !== e.currentTarget) return - setOpenSubmenuId(null) + cancelSubmenuClose() + setOpenSubmenu(null) } // Portal lists render hidden until placed: the placement effect measures @@ -585,10 +715,10 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI const list = open && ( { event.preventDefault(); event.stopPropagation(); onClose() } : undefined} - onMouseOver={collapseSubmenuFrom} + onPointerOver={(event) => { if (hoverAllowed(event)) collapseSubmenuFrom(event) }} onFocus={collapseSubmenuFrom} >
@@ -629,7 +759,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI ref={rootRef} className={clsx(css.root, className)} onPointerEnter={closeOnPointerLeave ? cancelClose : undefined} - onPointerLeave={closeOnPointerLeave ? () => { if (open) armClose() } : undefined} + onPointerLeave={closeOnPointerLeave ? (event) => { if (open && hoverAllowed(event)) armClose() } : undefined} > {anchor} {portal ? (list !== false && createPortal(list, document.body)) : list} diff --git a/packages/client/ui-primitives/src/menu-submenu-position.ts b/packages/client/ui-primitives/src/menu-submenu-position.ts new file mode 100644 index 0000000000..df958d87a8 --- /dev/null +++ b/packages/client/ui-primitives/src/menu-submenu-position.ts @@ -0,0 +1,78 @@ +/** Viewport placement for Menu's portaled child card, independent of input device. */ +import { useLayoutEffect, useState, type RefObject } from 'react' +import { overlayTopMargin } from './overlay-top-margin.ts' + +interface SubmenuPosition { + left: number + top: number + maxHeight: number + width?: number + page: boolean +} + +/** + * Fit a child beside its parent, or over the parent's panel when neither side fits. + * CSS cannot choose a sibling portal's placement from both measured card widths. + * @param active - The mounted parent's row ID, or null while closed. + * @param parent - Parent card, including its scroll region. + * @param trigger - The parent row opening the child. + * @param child - Child card, measured before its first paint. + * @param contextual - Whether frame clearance is excluded for a context menu. + * @returns Viewport coordinates and the single-panel navigation mode, or null before placement. + */ +export function useMenuSubmenuPosition( + active: string | null, + parent: RefObject, + trigger: RefObject, + child: RefObject, + contextual: boolean, +): SubmenuPosition | null { + const [position, setPosition] = useState(null) + useLayoutEffect(() => { + if (active === null) { setPosition(null); return } + const place = () => { + if (parent.current === null || trigger.current === null || child.current === null) return + const panel = parent.current.getBoundingClientRect() + const row = trigger.current.getBoundingClientRect() + const margin = 12 + const gap = 4 + const vw = window.innerWidth + const vh = window.innerHeight + const topMargin = contextual ? margin : overlayTopMargin(margin, row, gap) + const maxHeight = Math.max(0, vh - topMargin - margin) + // Keep natural width measurable after returning from a single-panel page. + child.current.style.width = '' + const width = child.current.offsetWidth + const right = panel.right + gap + const left = panel.left - gap - width + const page = right + width > vw - margin && left < margin + const pageWidth = Math.min(Math.max(panel.width, width), vw - margin * 2) + const actualWidth = page ? pageWidth : width + const x = page ? panel.left : right + width <= vw - margin ? right : left + const height = Math.min(child.current.offsetHeight, maxHeight) + const y = page ? panel.top : row.top + const next: SubmenuPosition = { + left: Math.max(margin, Math.min(x, vw - actualWidth - margin)), + top: Math.max(topMargin, Math.min(y, vh - height - margin)), + maxHeight, + ...(page ? { width: pageWidth } : {}), + page, + } + if (page) child.current.style.width = `${pageWidth}px` + setPosition(current => current?.left === next.left && current.top === next.top + && current.maxHeight === next.maxHeight && current.width === next.width && current.page === next.page ? current : next) + } + place() + let frame: number + const track = () => { place(); frame = requestAnimationFrame(track) } + frame = requestAnimationFrame(track) + window.addEventListener('resize', place) + window.addEventListener('scroll', place, true) + return () => { + cancelAnimationFrame(frame) + window.removeEventListener('resize', place) + window.removeEventListener('scroll', place, true) + } + }, [active, parent, trigger, child, contextual]) + return position +} diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 8b198e4365..c83f54d899 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -173,6 +173,10 @@ describe('MenuItemButton', () => { }) describe('Menu', () => { + beforeEach(() => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) + }) + afterEach(() => { vi.unstubAllGlobals() }) const items = [ { id: 'a', label: 'Alpha' }, { id: 'b', label: 'Beta', disabled: true }, @@ -534,9 +538,9 @@ describe('Menu', () => { expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined() fireEvent.focus(screen.getByRole('menuitem', { name: 'Row' })) expect(screen.queryByRole('menuitem', { name: 'Sub' })).toBeNull() - fireEvent.mouseEnter(parent.parentElement as HTMLElement) + fireEvent.pointerEnter(parent.parentElement as HTMLElement) expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined() - fireEvent.mouseOver(screen.getByRole('menuitem', { name: 'Row' })) + fireEvent.pointerOver(screen.getByRole('menuitem', { name: 'Row' })) expect(screen.queryByRole('menuitem', { name: 'Sub' })).toBeNull() }) @@ -568,13 +572,13 @@ describe('Menu', () => { fireEvent.click(parent) expect(onSelect).not.toHaveBeenCalled() fireEvent.focus(parent) - fireEvent.mouseEnter(wrap) + fireEvent.pointerEnter(wrap) expect(screen.getByTestId('sub-ic')).toBeDefined() expect(screen.getByRole('menuitem', { name: 'Create ok' }).getAttribute('aria-keyshortcuts')).toBe('Meta+N') expect(screen.getByRole('menuitem', { name: 'Create ok' }).querySelectorAll('kbd')).toHaveLength(2) fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' })) expect(onSelect).toHaveBeenCalledWith('ok') - fireEvent.mouseLeave(wrap) + fireEvent.focus(plain) expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull() }) @@ -689,7 +693,7 @@ describe('Menu', () => { expect(onSelect).toHaveBeenCalledWith('new') }) - it('caps the list height for internal scrolling unless a submenu row is present', () => { + it('caps the list height for internal scrolling including menus with submenus', () => { const { rerender } = render( trigger} items={items} onSelect={() => {}} onClose={() => {}} />) expect(screen.getByRole('menu').className).toMatch(/scrollable/) @@ -701,7 +705,7 @@ describe('Menu', () => { onSelect={() => {}} onClose={() => {}} />) - expect(screen.getByRole('menu').className).not.toMatch(/scrollable/) + expect(screen.getByRole('menu').className).toMatch(/scrollable/) }) }) diff --git a/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx b/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx new file mode 100644 index 0000000000..64145f8439 --- /dev/null +++ b/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx @@ -0,0 +1,363 @@ +// @vitest-environment jsdom +/** Submenu interactions use explicit geometry and pointer capabilities, not the host screen. */ +import { useRef, useState } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Menu, MenuItemButton, type MenuEntry } from '../src/Menu.tsx' +import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' + +let panel: DOMRect +let row: DOMRect +let childHeight: number +let hover: boolean + +beforeEach(() => { + panel = new DOMRect(100, 100, 200, 300) + row = new DOMRect(104, 140, 192, 34) + childHeight = 100 + hover = true + vi.stubGlobal('innerWidth', 1024) + vi.stubGlobal('innerHeight', 768) + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: hover }))) + vi.useFakeTimers() + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return this.tagName === 'BUTTON' ? row : panel + }) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(200) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.className.includes('submenu') ? childHeight : panel.height + }) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function pointer( + target: Element, type: 'pointerover' | 'pointerout' | 'pointerdown', pointerType: 'mouse' | 'touch', relatedTarget: EventTarget | null = null, +) { + const event = new MouseEvent(type, { bubbles: true, relatedTarget }) + Object.defineProperty(event, 'pointerType', { value: pointerType }) + fireEvent(target, event) +} + +const entries = [ + { id: 'parent', label: 'Applications', submenu: [ + { id: 'disabled', label: 'Unavailable', disabled: true }, + { id: 'first', label: 'First app' }, + { id: 'second', label: 'Second app' }, + ] }, + { id: 'other', label: 'Other action' }, +] + +function Fixture({ onSelect = () => {}, backLabel = 'Go back' }: { onSelect?: (id: string) => void; backLabel?: string }) { + const [open, setOpen] = useState(false) + return { setOpen(true) }}>More} + items={entries} onClose={() =>{ setOpen(false) }} + onSelect={(id) => { onSelect(id); setOpen(false) }}> + { setOpen(false) }}>Plugin action + +} + +function ExternalTriggerFixture({ onClose }: { onClose: () => void }) { + const trigger = useRef(null) + const [open, setOpen] = useState(false) + return <> + + trigger.current} + getAnchorRect={() => trigger.current?.getBoundingClientRect() ?? null} + items={entries} onClose={() => { onClose(); setOpen(false) }} /> + +} + +function openMenu() { + const trigger = screen.getByRole('button', { name: 'More' }) + act(() =>{ trigger.focus() }) + fireEvent.click(trigger) + return trigger +} + +function childMenu() { + return screen.getByRole('menuitem', { name: 'First app' }).closest('[role="menu"]')! +} + +describe('Menu submenus', () => { + it('lets an external return-focus trigger toggle closed through pointerdown then click', () => { + const close = vi.fn() + render() + const target = screen.getByText('External more') + fireEvent.pointerDown(target) + fireEvent.click(target) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.pointerDown(target) + expect(close).not.toHaveBeenCalled() + fireEvent.click(target) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it.each(['removed', 'disabled', 'empty'] as const)('restores the root page when the active parent becomes %s', (change) => { + vi.stubGlobal('innerWidth', 390) + const props = { open: true, anchor: null, submenuBackLabel: 'Go back', onClose: vi.fn() } + const view = render() + fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' })) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' })) + const changed: MenuEntry[] = change === 'removed' ? [entries[1]!] + : [{ ...entries[0]!, ...(change === 'disabled' ? { disabled: true } : { submenu: [] }) }, entries[1]!] + view.rerender() + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + expect(screen.getByRole('menuitem', { name: 'Other action' })).toBeDefined() + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: change === 'empty' ? 'Applications' : 'Other action' })) + view.rerender() + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + expect(screen.getByRole('menuitem', { name: 'Applications' }).getAttribute('aria-expanded')).toBe('false') + }) + + it.each([390, 1024])('restores focus when an active footer submenu is removed at %ipx', (width) => { + vi.stubGlobal('innerWidth', width) + const props = { open: true, anchor: null, items: [entries[1]!], onClose: vi.fn() } + const view = render() + fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' })) + view.rerender() + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Other action' })) + }) + + it('preserves focus already moved outside an invalidated submenu', () => { + vi.stubGlobal('innerWidth', 390) + const props = { open: true, anchor: , onClose: vi.fn() } + const view = render() + fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' })) + const outside = screen.getByRole('button', { name: 'Outside control' }) + act(() => { outside.focus() }) + view.rerender() + expect(screen.getByRole('menuitem', { name: 'Other action' })).toBeDefined() + expect(document.activeElement).toBe(outside) + }) + + it('returns focus to the anchor when invalidation leaves no enabled root row', () => { + vi.stubGlobal('innerWidth', 390) + const props = { open: true, anchor: , onClose: vi.fn() } + const view = render() + fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' })) + view.rerender() + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'More' })) + }) + + it('shows a chevron, isolates the child keyboard walk, and returns before closing the root', () => { + render() + const trigger = openMenu() + const parent = screen.getByRole('menuitem', { name: 'Applications' }) + expect(parent.querySelector('[data-icon="dsw-icon-chevron-right-outline-regular"]')).not.toBeNull() + act(() =>{ parent.focus() }) + expect(parent.getAttribute('aria-expanded')).toBe('true') + expect(document.activeElement).toBe(parent) + fireEvent.keyDown(parent, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' })) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Second app' })) + fireEvent.keyDown(document.activeElement!, { key: 'Home' }) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' })) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' }) + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + expect(document.activeElement).toBe(parent) + fireEvent.keyDown(parent, { key: 'ArrowRight' }) + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(screen.getByRole('menu')).toBeDefined() + expect(document.activeElement).toBe(parent) + fireEvent.keyDown(parent, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + it.each(['click', 'Tab'])('selects the child with %s once and restores the original trigger', async (method) => { + const select = vi.fn() + render() + const trigger = openMenu() + fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' })) + const child = screen.getByRole('menuitem', { name: 'First app' }) + fireEvent.pointerDown(child) + expect(screen.getAllByRole('menu')).toHaveLength(2) + if (method === 'click') fireEvent.click(child) + else fireEvent.keyDown(child, { key: 'Tab' }) + await act(async () => { await Promise.resolve() }) + expect(select).toHaveBeenCalledExactlyOnceWith('first') + expect(screen.queryByRole('menu')).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + it('portals the child outside parent scrolling and flips left with a viewport height cap', () => { + panel = new DOMRect(780, 620, 200, 120) + row = new DOMRect(784, 680, 192, 34) + childHeight = 1000 + render() + openMenu() + fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' })) + const menu = childMenu() + expect(menu.parentElement).toBe(document.body) + expect(menu.style.left).toBe('576px') + expect(menu.style.top).toBe('12px') + expect(menu.style.maxHeight).toBe('744px') + expect(screen.getByRole('menuitem', { name: 'Applications' }).closest('[role="menu"]')?.contains(menu)).toBe(false) + }) + + it('uses one visible panel at 390px and a localized non-selecting return action', () => { + vi.stubGlobal('innerWidth', 390) + panel = new DOMRect(170, 100, 200, 300) + const select = vi.fn() + render() + openMenu() + const parent = screen.getByRole('menuitem', { name: 'Applications' }) + fireEvent.click(parent) + const menu = childMenu() + expect(screen.getAllByRole('menu')).toHaveLength(1) + expect(menu.style.left).toBe('170px') + expect(menu.style.width).toBe('200px') + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' })) + fireEvent.click(screen.getByRole('menuitem', { name: '返回上级' })) + expect(select).not.toHaveBeenCalled() + expect(document.activeElement).toBe(parent) + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + }) + + it('keeps a narrow menu on its parent page until explicit submenu activation', () => { + vi.stubGlobal('innerWidth', 390) + const close = vi.fn() + render() + const parent = screen.getByRole('menuitem', { name: 'Applications' }) + expect(document.activeElement).toBe(parent) + expect(parent.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + fireEvent.keyDown(parent, { key: 'Escape' }) + expect(close).toHaveBeenCalledOnce() + fireEvent.keyDown(parent, { key: 'ArrowRight' }) + expect(screen.getByRole('menuitem', { name: 'Go back' })).toBeDefined() + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' })) + }) + + it('uses the caller parent label when no explicit return label was supplied', () => { + vi.stubGlobal('innerWidth', 390) + render( {}} />) + fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' })) + const back = screen.getByRole('menuitem', { name: 'Applications' }) + expect(back.querySelector('[data-icon="dsw-icon-chevron-left-outline-regular"]')).not.toBeNull() + fireEvent.click(back) + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + }) + + it('preserves the focused child DOM node while resizing between side and single-panel placement', () => { + render() + openMenu() + fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' })) + const child = screen.getByRole('menuitem', { name: 'Second app' }) + act(() =>{ child.focus() }) + vi.stubGlobal('innerWidth', 390) + fireEvent(window, new Event('resize')) + expect(screen.getAllByRole('menu')).toHaveLength(1) + expect(document.activeElement).toBe(child) + vi.stubGlobal('innerWidth', 1024) + fireEvent(window, new Event('resize')) + expect(screen.getAllByRole('menu')).toHaveLength(2) + expect(document.activeElement).toBe(child) + }) + + it('keeps wide touch devices in side placement and ignores touch and compatibility mouse hover', () => { + hover = false + render() + openMenu() + const parent = screen.getByRole('menuitem', { name: 'Applications' }) + pointer(parent, 'pointerover', 'touch') + fireEvent.mouseOver(parent) + pointer(parent, 'pointerover', 'mouse') + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + pointer(parent, 'pointerdown', 'touch') + fireEvent.click(parent) + expect(screen.getAllByRole('menu')).toHaveLength(2) + expect(childMenu().style.left).toBe('304px') + expect(screen.queryByRole('menuitem', { name: 'Go back' })).toBeNull() + hover = true + pointer(screen.getByRole('menuitem', { name: 'Other action' }), 'pointerover', 'touch') + fireEvent.mouseOver(screen.getByRole('menuitem', { name: 'Other action' })) + expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined() + }) + + it('returns child focus after pointer grace so root arrow navigation can continue', () => { + render() + openMenu() + const parent = screen.getByRole('menuitem', { name: 'Applications' }) + fireEvent.click(parent) + const menu = childMenu() + pointer(menu, 'pointerover', 'mouse', parent) + pointer(menu, 'pointerout', 'mouse', document.body) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + expect(document.activeElement).toBe(parent) + fireEvent.keyDown(parent, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Other action' })) + }) + + it.each(['More', 'Other action'])('preserves focus moved to %s before pointer grace ends', (name) => { + render() + openMenu() + const parent = screen.getByRole('menuitem', { name: 'Applications' }) + fireEvent.click(parent) + const menu = childMenu() + pointer(menu, 'pointerover', 'mouse', parent) + pointer(menu, 'pointerout', 'mouse', document.body) + const target = screen.getByRole(name === 'More' ? 'button' : 'menuitem', { name }) + act(() => { target.focus() }) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + expect(document.activeElement).toBe(target) + }) + + it('allows crossing the hover gap and cancels stale grace after keyboard return', () => { + render() + openMenu() + const parent = screen.getByRole('menuitem', { name: 'Applications' }) + pointer(parent, 'pointerover', 'mouse') + pointer(parent, 'pointerout', 'mouse') + pointer(childMenu(), 'pointerover', 'mouse') + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined() + pointer(parent, 'pointerout', 'mouse') + fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.click(parent) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined() + pointer(screen.getByRole('menuitem', { name: 'Plugin action' }), 'pointerover', 'mouse') + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + }) + + it('moves focus from a removed return row to the first child on width expansion', () => { + vi.stubGlobal('innerWidth', 390) + render() + openMenu() + fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' })) + act(() =>{ screen.getByRole('menuitem', { name: 'Go back' }).focus() }) + vi.stubGlobal('innerWidth', 1024) + fireEvent(window, new Event('resize')) + expect(screen.queryByRole('menuitem', { name: 'Go back' })).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' })) + }) + + it('keeps the return row reachable when a narrow child has no enabled choices', () => { + vi.stubGlobal('innerWidth', 390) + render( {}} + items={[{ id: 'parent', label: 'Applications', submenu: [{ id: 'disabled', label: 'Unavailable', disabled: true }] }]} />) + fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' })) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Go back' })) + }) + + it('does not open disabled submenu parents and dismisses on outside pointerdown', () => { + const close = vi.fn() + render() + pointer(screen.getByRole('menuitem'), 'pointerover', 'mouse') + fireEvent.click(screen.getByRole('menuitem')) + expect(screen.getAllByRole('menu')).toHaveLength(1) + fireEvent.pointerDown(document.body) + expect(close).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index b4cbc02951..0e613d6a4c 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -18,8 +18,8 @@ en: bb12bdeafa8571b5 zh: e7df75c03478f7ed /deepseek-ai-dsh-session-log-export/use-this-package/copy-session-as-markdown: - en: 55c163a4ae5b8d5d - zh: c1dac154ed1fa56d + en: acbc8a46167881c5 + zh: 24fe629ef43db153 /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: en: 4011f686afd24f09 zh: 2710b9995c80ba1f @@ -27,8 +27,8 @@ en: 252bc78a7850557a zh: e695658a94c8c2bf /deepseek-ai-dsh-session-log-export/use-this-package/composition: - en: 4168a2d3e653ab73 - zh: 8bf955cfab7b08e2 + en: 096b8a2a62ebebfb + zh: 74799e9746c3564d /deepseek-ai-dsh-session-log-export/use-this-package/configuration: en: 377a0d8860c46f63 zh: 9eb7698f813b2fa3 @@ -45,8 +45,8 @@ en: 7cb82a6851405f95 zh: b8c525170d19dc6c /deepseek-ai-dsh-session-log-export/understand-the-implementation/design-split: - en: 3bb8e18b107c353f - zh: 3cc45ce2b5699066 + en: 29c6e2eec2f51a6f + zh: 87a067d4de78b8e8 /deepseek-ai-dsh-session-log-export/understand-the-implementation/download-flow: en: bd46d2fa1273a6a4 zh: 4d3eb2cf0a9bf12b diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 58cf4006df..3b91dde81d 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -35,7 +35,7 @@ The formatter reads the committed raw log, so a compaction surface replacement n The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. On hosts with `ClipboardItem` and `navigator.clipboard.write`, the write starts synchronously from the click with a promised `text/plain` Blob while the transcript is read; other hosts fall back to the shared clipboard helper after preparation. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header reflects copying in `aria-busy` and disables its copy row until the operation settles. Success, preparation-failure, or clipboard-failure feedback uses the root overlay toast. Disposal aborts the read and prevents later text from reaching the Blob; an operating-system clipboard write already accepted cannot be rolled back. -When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; both export actions remain available independently. +When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin’s availability; both export actions remain independently available. [`ui-open-in-app`](../../client/ui-open-in-app/README.md) contributes its application submenu in a separated group after download and before feedback. ### When to choose it @@ -61,7 +61,7 @@ export function convertTranscript(events: readonly SessionEvent[], sourcePath?: name: '@deepseek-ai/dsh-session-log-export' ``` -The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, `dsh-client-ui-conversation`, and `dsh-client-ui-layout`. The layout package declares the `shell.overlay` seat used by copy feedback; the other client packages provide the Header seat, locale, and command surface. +The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, `dsh-client-ui-conversation`, `dsh-client-ui-context-menu`, and `dsh-client-ui-layout`. The context-menu service renders the shared Header popup; layout declares the copy-feedback `shell.overlay` seat. The other client packages provide Header, locale, and command services. ### Configuration @@ -100,7 +100,7 @@ This section explains how the package wires the export control and points at the The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` and `/api/session.markdown` Fetch routes to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream, and [`src/markdown.ts`](src/markdown.ts) renders the Markdown transcript. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download and Markdown copy controllers and UI, and observes `command/executed` so only the submitting browser starts a download. -The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control. +The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control. Its captured `sessionHeader` target is declaration-merged by this package and remains independent of Session-row and title context actions. Export and optional feedback contributions observe their controller, availability, and locale sources; the button observes the shared active target for expanded state. The Session-scoped download dialog remains mounted beside the trigger, while menu focus and dismissal belong to the shared overlay. ### Download flow diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 01df07877f..d8182eca6d 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" 剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。 -挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。 +挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。[`ui-open-in-app`](../../client/ui-open-in-app/README.zh.md) 在下载之后、反馈之前的独立分隔组中贡献应用子菜单。 ### 何时选择 @@ -61,7 +61,7 @@ export function convertTranscript(events: readonly SessionEvent[], sourcePath?: name: '@deepseek-ai/dsh-session-log-export' ``` -Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`、`dsh-client-ui-conversation` 和 `dsh-client-ui-layout` 一起挂载。layout 包声明复制反馈使用的 `shell.overlay` 座位;其他客户端包提供 Header 座位、locale 与命令界面。 +Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`、`dsh-client-ui-conversation`、`dsh-client-ui-context-menu` 和 `dsh-client-ui-layout` 一起挂载。右键菜单服务渲染共享的头部菜单;layout 声明复制反馈使用的 `shell.overlay` 插槽。其他客户端包提供 Header、locale 与命令服务。 ### 配置 @@ -100,7 +100,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 本包分为两部分。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` 与 `/api/session.markdown` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流,[`src/markdown.ts`](src/markdown.ts) 渲染 Markdown 记录。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载与 Markdown 复制控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。 -Header 的更多操作入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。 +Header 的更多入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。本包通过声明合并定义被捕获的 `sessionHeader` 目标,独立于会话行及标题右键操作。导出及可选反馈贡献观察各自的控制器、可用性与语言源;按钮通过共享活动目标显示展开状态。会话范围的下载弹窗保持挂载在按钮旁,菜单焦点和关闭由共享浮层管理。 ### 下载流程 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 083b0e2ba7..ae91500d68 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-commands": "workspace:*", + "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-layout": "workspace:*", "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:*", @@ -76,6 +77,7 @@ "inject": [ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-commands", + "@deepseek-ai/dsh-client-ui-context-menu", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx index fcf0cfd862..2404a2b18c 100644 --- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx +++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx @@ -1,92 +1,62 @@ -import { useState } from 'react' +/** Session Header trigger for the shared menu, retaining its Session-scoped download dialog. */ import type { ReactNode } from 'react' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { - Button, IconCopyOutlineRegular, IconDownloadOutlineRegular, IconEllipsisOutlineRegular, - IconPaperPlaneOutlineRegular, Menu, -} from '@deepseek-ai/dsh-client-ui-primitives' +import type { ContextMenuTarget, MenuPoint } from '@deepseek-ai/dsh-client-ui-context-menu/client' +import { Button, IconEllipsisOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { SessionLogDownloadDialog, type SessionLogDownloadDialogProps } from './Dialog.tsx' import type { SessionLogDownloadDialogInjected } from './Dialog.tsx' import type { SessionMarkdownCopyState } from './copy-controller.ts' import css from './HeaderAction.module.css' -/** Markdown copy state and action bound into the Session Header contribution. */ -export interface SessionMarkdownCopyHeaderInjected { - hooks: { sessionMarkdownCopy: ObservableSnapshot } +/** Session download controls and observable menu/copy state for the Header trigger. */ +export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDialogInjected { + hooks: SessionLogDownloadDialogInjected['hooks'] & { + sessionMarkdownCopy: ObservableSnapshot + headerMenu: ObservableSnapshot + } /** - * Copy one complete Session transcript to the clipboard. - * @param sessionId - Session captured by the menu click. + * Toggle the shared menu for the captured Header Session. + * @param sessionId - Session displayed by this Header. + * @param point - trigger's viewport anchor. */ - copyMarkdown: (sessionId: SessionId) => Promise + openMenu: (sessionId: SessionId, point: MenuPoint) => void } -/** Session download controls plus Markdown copy and observable feedback availability. */ -export interface SessionLogDownloadHeaderInjected - extends SessionLogDownloadDialogInjected, SessionMarkdownCopyHeaderInjected { - hooks: SessionLogDownloadDialogInjected['hooks'] - & SessionMarkdownCopyHeaderInjected['hooks'] - & { feedbackAvailable: ObservableSnapshot } - /** - * Open the existing Session feedback draft without recording feedback; no-op after the feedback plugin unloads. - * @param sessionId - Session whose feedback form to open. - */ - openFeedback: (sessionId: SessionId) => void -} - -/** Session download props plus the optional feedback action. */ +/** Session runtime, localized copy, and injected Header operations. */ export type SessionLogDownloadHeaderProps = SessionLogDownloadDialogProps & InjectFace /** - * Render the Session Header menu with Markdown copy, download, and optional - * feedback actions. + * Render the Session Header's shared-menu trigger and persistent download dialog. * @param props - Session runtime, download and copy state, and localized copy. - * @returns the persistent Header action and Session-scoped dialog. + * @returns the Header action and Session-scoped dialog. */ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderProps): ReactNode { - const { - sessionId, useSessionLogDownload, useSessionMarkdownCopy, useFeedbackAvailable, - request, copyMarkdown, openFeedback, t, - } = props - const feedbackAvailable = useFeedbackAvailable(value => value) + const { sessionId, useSessionLogDownload, useSessionMarkdownCopy, useHeaderMenu, openMenu, t } = props const entry = useSessionLogDownload(state => state.bySession[String(sessionId)]) const copying = useSessionMarkdownCopy(state => state.copying) + const open = useHeaderMenu(target => target?.kind === 'sessionHeader' && target.value.sessionId === sessionId) const busy = entry?.status === 'downloading' - const [open, setOpen] = useState(false) - return ( <> - { setOpen(false) }} - items={[ - { id: 'copy', label: t('menu.copyMarkdown'), icon: , disabled: copying }, - { id: 'download', label: t('menu.download'), icon: , disabled: busy }, - ...feedbackAvailable ? [{ id: 'feedback', label: t('menu.feedback'), icon: }] : [], - ]} - onSelect={(id) => { - setOpen(false) - if (id === 'feedback') openFeedback(sessionId) - else if (id === 'copy') void copyMarkdown(sessionId) - else void request(sessionId) + - )} - /> + > + + ) diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index 74808d1751..f914a5427c 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -9,6 +9,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-message-feedback/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' +import { IconCopyOutlineRegular, IconDownloadOutlineRegular, IconPaperPlaneOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { SessionLogDownloadController } from './controller.ts' import { SessionMarkdownCopyController } from './copy-controller.ts' import { SessionMarkdownCopyToast, type SessionMarkdownCopyToastInjected } from './CopyToast.tsx' @@ -21,6 +23,13 @@ declare module '@deepseek-ai/cordis' { } } +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { + /** Header utilities, independent of Session-row and title context actions. */ + sessionHeader: { readonly sessionId: SessionId } + } +} + declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { 'session-log-download': SessionLogDownloadKey @@ -29,11 +38,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export type { SessionLogDownloadEntry, SessionLogDownloadState } from './controller.ts' -export const inject = ['slots', 'locale'] +export const inject = ['slots', 'locale', 'contextMenus'] /** * Provide the download controller and mount its modal, the Markdown copy - * controller and its root toast, and both Header actions. + * controller and its root toast, and the shared Header menu actions. * @param ctx - browser context carrying slots and locale services. */ export function apply(ctx: ClientContext): void { @@ -50,6 +59,34 @@ export function apply(ctx: ClientContext): void { return () => { feedbackAvailable.set(false) } }, 'session-log-download: feedback availability') }) + const t = ctx.locale.bind(NS) + ctx.slots.inject('contextMenu.icon', function* () { + for (const [key, Icon] of Object.entries({ + 'session-log-download.copy': IconCopyOutlineRegular, + 'session-log-download.download': IconDownloadOutlineRegular, + 'session-log-download.feedback': IconPaperPlaneOutlineRegular, + })) yield ctx.slots.register({ name: 'contextMenu.icon', key }, Icon) + }) + ctx.effect(() => ctx.contextMenus.register({ + id: 'session-log-download.export', target: 'sessionHeader', order: 100, + observe: () => [controller.store, copyController.store, ctx.locale], + resolve: ({ sessionId }) => [ + { id: 'copy', label: t('menu.copyMarkdown'), icon: { key: 'session-log-download.copy' }, + disabled: copyController.store.getSnapshot().copying, run: () => copyController.copy(sessionId) }, + { id: 'download', label: t('menu.download'), icon: { key: 'session-log-download.download' }, + disabled: controller.store.getSnapshot().bySession[sessionId]?.status === 'downloading', + run: () => controller.download(sessionId) }, + ], + }), 'session-log-download: Header exports') + ctx.effect(() => ctx.contextMenus.register({ + id: 'session-log-download.feedback', target: 'sessionHeader', order: 600, + observe: () => [feedbackAvailable, ctx.locale], + resolve: ({ sessionId }) => feedbackAvailable.getSnapshot() ? [{ + id: 'feedback', label: t('menu.feedback'), icon: { key: 'session-log-download.feedback' }, separatorBefore: true, + // The feedback plugin can unload between menu preparation and selection. + run: () => { ctx.get('feedbackUi')?.openSession(sessionId) }, + }] : [], + }), 'session-log-download: Header feedback') ctx.on('command/executed', (sessionId, commandName, result) => { if (commandName === 'export' && result.kind === 'success') void controller.download(sessionId) }) @@ -70,13 +107,15 @@ export function apply(ctx: ClientContext): void { hooks: { sessionLogDownload: controller.store, sessionMarkdownCopy: copyController.store, - feedbackAvailable, + headerMenu: ctx.contextMenus.active, }, request: (sessionId: SessionId) => controller.download(sessionId), - copyMarkdown: (sessionId: SessionId) => copyController.copy(sessionId), dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, - // The feedback plugin can unload between the menu render and this click. - openFeedback: (sessionId: SessionId) => { ctx.get('feedbackUi')?.openSession(sessionId) }, + openMenu: (sessionId, point) => { + const active = ctx.contextMenus.active.getSnapshot() + if (active?.kind === 'sessionHeader' && active.value.sessionId === sessionId) ctx.contextMenus.close() + else ctx.contextMenus.open({ kind: 'sessionHeader', value: { sessionId } }, point) + }, }), }, SessionLogDownloadHeaderAction)) } diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index f8ed7792bf..58f0f1d333 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -1,5 +1,8 @@ import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts' +import type { ContextMenuTarget } from '@deepseek-ai/dsh-client-ui-context-menu/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -24,6 +27,7 @@ function declare(slots: SlotRegistry): () => void { 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, 'conversation.session.header.utilities': { kind: 'list', scope: 'session' }, 'shell.overlay': { kind: 'list', scope: 'root' }, + 'contextMenu.icon': { kind: 'keyed', scope: 'root', inline: true }, }, } as never, () => null) } @@ -35,40 +39,67 @@ function injectedOf(entry: { inject?: ((...args: never[]) => object) | undefined async function bench() { const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SlotRegistry).await() const slots = ctx.get('slots') as SlotRegistry const declaration = declare(slots) ctx.provide('locale', new LocaleRuntime(ctx)) + ctx.locale.setLocale('en') + const failed = vi.fn(), registry = new MenuRegistry(failed) + onTestFinished(() => { registry.dispose() }) + const active = createSnapshotStore(null) + const open = vi.fn((target: ContextMenuTarget) => { active.set(target) }) + const close = vi.fn(() => { active.set(null) }) + ctx.provide('contextMenus', { active, open, close, register: registry.register.bind(registry) } as never) + const menu = () => registry.resolve({ kind: 'sessionHeader', value: { sessionId: SID } }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, slots, declaration, fiber } + return { ctx, slots, declaration, fiber, registry, menu, active, open, close, failed } } describe('session-log-download browser plugin', () => { - it('follows feedback plugin availability and opens the selected Session form', async () => { + it('follows feedback plugin availability without adding Header actions to Session context menus', async () => { const b = await bench() - const entry = b.slots.entries('conversation.session.header.utilities')[0]! - const injected = injectedOf(entry) as SessionLogDownloadHeaderInjected - expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(false) - injected.openFeedback(SID) - + const menu = b.menu() + expect(menu.source.getSnapshot().map(row => row.label)).toEqual(['Copy Session as Markdown', 'Download session log']) const openSession = vi.fn() const feedback = b.ctx.plugin((ctx) => { ctx.provide('feedbackUi', { openSession }) }) await feedback.await() - await vi.waitFor(() => { expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(true) }) - injected.openFeedback(SID) + await vi.waitFor(() => { expect(menu.source.getSnapshot().map(row => row.label)).toEqual(['Copy Session as Markdown', 'Download session log', 'Feedback']) }) + b.menu().select('session-log-download.feedback/feedback') expect(openSession).toHaveBeenCalledWith(SID) - await feedback.dispose() - await vi.waitFor(() => { expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(false) }) - expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(1) + await vi.waitFor(() => { expect(menu.source.getSnapshot()).toHaveLength(2) }) + const session = b.registry.resolve({ kind: 'session', value: { sessionId: SID, displayTitle: 'Captured' } }) + expect(session.source.getSnapshot()).toEqual([]) + const reloaded = b.ctx.plugin((ctx) => { ctx.provide('feedbackUi', { openSession }) }) + await reloaded.await() + await vi.waitFor(() => { expect(menu.source.getSnapshot()).toHaveLength(3) }) + b.menu().select('session-log-download.feedback/feedback') + expect(openSession).toHaveBeenCalledTimes(2) await b.fiber.dispose() + expect(menu.source.getSnapshot()).toEqual([]) + }) + + it('toggles only its captured Header menu and replaces an unrelated target', async () => { + const b = await bench() + const face = injectedOf(b.slots.entries('conversation.session.header.utilities')[0]) as SessionLogDownloadHeaderInjected + const point = { x: 20, y: 30 } + face.openMenu(SID, point) + expect(b.open).toHaveBeenCalledExactlyOnceWith({ kind: 'sessionHeader', value: { sessionId: SID } }, point) + face.openMenu(SID, point) + expect(b.close).toHaveBeenCalledOnce() + b.active.set({ kind: 'session', value: { sessionId: SID, displayTitle: 'Same' } }) + face.openMenu(SID, point) + expect(b.open).toHaveBeenCalledTimes(2) + face.openMenu('another' as SessionId, point) + expect(b.active.getSnapshot()).toEqual({ kind: 'sessionHeader', value: { sessionId: 'another' } }) }) it('provides one controller and removes its Header and overlay contributions on disposal', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 }))) const b = await bench() - expect(inject).toEqual(['slots', 'locale']) + expect(inject).toEqual(['slots', 'locale', 'contextMenus']) expect(b.ctx.sessionLogDownload).toBeDefined() expect(b.slots.entries('conversation.session.header.actions')).toHaveLength(0) const entry = b.slots.entries('conversation.session.header.utilities')[0] @@ -84,16 +115,49 @@ describe('session-log-download browser plugin', () => { expect(overlay?.component).toBe(SessionMarkdownCopyToast) expect(overlay?.options).toMatchObject({ id: 'session-log-download.copy-toast' }) const overlayInjected = injectedOf(overlay) as SessionMarkdownCopyToastInjected - await injected.copyMarkdown(SID) - expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed') + b.menu().select('session-log-download.export/copy') + await vi.waitFor(() => { expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed') }) overlayInjected.dismiss() expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice).toBeNull() + const retained = b.menu() await b.fiber.dispose() + expect(retained.source.getSnapshot()).toEqual([]) + expect(b.menu().source.getSnapshot()).toEqual([]) + expect(b.slots.entries('contextMenu.icon')).toHaveLength(0) expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) expect(b.slots.entries('shell.overlay')).toHaveLength(0) }) + it('updates only occupied export rows and keeps feedback available during downloads and copy', async () => { + const download = Promise.withResolvers(), copy = Promise.withResolvers() + const fetcher = vi.fn((input: string | URL) => String(input).includes('session.markdown') ? copy.promise : download.promise) + vi.stubGlobal('fetch', fetcher) + const b = await bench() + const feedback = b.ctx.plugin((ctx) => { ctx.provide('feedbackUi', { openSession: vi.fn() }) }) + await feedback.await() + const menu = b.menu() + b.menu().select('session-log-download.export/download') + expect(menu.source.getSnapshot().map(row => [row.label, row.disabled === true])).toEqual([ + ['Copy Session as Markdown', false], ['Download session log', true], ['Feedback', false], + ]) + b.menu().select('session-log-download.export/download') + expect(fetcher).toHaveBeenCalledOnce() + b.menu().select('session-log-download.export/copy') + expect(menu.source.getSnapshot().map(row => row.disabled === true)).toEqual([true, true, false]) + b.menu().select('session-log-download.export/copy') + expect(fetcher).toHaveBeenCalledTimes(2) + download.resolve(new Response('', { status: 500 })) + copy.resolve(new Response('', { status: 500 })) + await vi.waitFor(() => { expect(menu.source.getSnapshot().every(row => !row.disabled)).toBe(true) }) + expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]).toMatchObject({ open: true, status: 'error' }) + const face = injectedOf(b.slots.entries('conversation.session.header.utilities')[0]) as SessionLogDownloadHeaderInjected + expect(face.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed') + expect(b.failed).not.toHaveBeenCalled() + b.ctx.locale.setLocale('zh') + expect(menu.source.getSnapshot()[0]?.label).not.toBe('Copy Session as Markdown') + }) + it('downloads only for an export execution acknowledged by this browser client', async () => { const fetcher = vi.fn(async () => new Response('', { status: 500 })) vi.stubGlobal('fetch', fetcher) diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index 4a64ffb70d..583dda9ab8 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -1,25 +1,19 @@ // @vitest-environment jsdom -import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' +import { contextMenuHook, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { useSyncExternalStore } from 'react' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' -import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { ContextMenuTarget } from '@deepseek-ai/dsh-client-ui-context-menu/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from '../src/client/controller.ts' -import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts' +import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' import { en } from '../src/client/locales.ts' const SID = 'session-export-header' as SessionId -const clipboard: SessionMarkdownClipboard = { - supportsDeferredWrite: () => false, - writeDeferred: async () => {}, - writeText: async () => true, -} -const dictionary: Record = en -const translate: SessionLogDownloadHeaderProps['t'] = key => dictionary[key] ?? key +const translate: SessionLogDownloadHeaderProps['t'] = makeTranslate(en) const unused = (): never => { throw new Error('This isolated component does not consume framework hooks') } const standard: GlobalStandardProps & SessionStandardProps = { sessionId: SID, useSession: unused, useProjection: unused, useAccess: () => true, @@ -31,169 +25,74 @@ const standard: GlobalStandardProps & SessionStandardProps = { addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } -function bindSnapshot(store: ObservableSnapshot) { - return function useSnapshot(selector: (state: State) => T): T { - return useSyncExternalStore( - listener => store.subscribe(listener), - () => selector(store.getSnapshot()), - ) - } -} - -function bench(feedbackAvailable = false, copyController = new SessionMarkdownCopyController( - async () => new Response('markdown'), - clipboard, -)) { - const controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn()) - const request = vi.fn((sessionId: SessionId) => controller.download(sessionId)) - const copyMarkdown = vi.fn((sessionId: SessionId) => copyController.copy(sessionId)) - const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) }) - const openFeedback = vi.fn() - const feedback = createSnapshotStore(feedbackAvailable) - const useSessionLogDownload = bindSnapshot(controller.store) - const useSessionMarkdownCopy = bindSnapshot(copyController.store) +function bench() { + const gate = Promise.withResolvers() + const controller = new SessionLogDownloadController(() => gate.promise, vi.fn()) + const copyController = new SessionMarkdownCopyController(async () => new Response('markdown')) + const active = createSnapshotStore(null) + const openMenu = vi.fn() const props: SessionLogDownloadHeaderProps = { ...standard, - useSessionLogDownload, - useSessionMarkdownCopy, - useFeedbackAvailable: bindSnapshot(feedback), - openFeedback, - request, - copyMarkdown, - dismiss, + useSessionLogDownload: bindSnapshotSelector(controller.store), + useSessionMarkdownCopy: bindSnapshotSelector(copyController.store), + useHeaderMenu: bindSnapshotSelector(active), + openMenu, + request: sessionId => controller.download(sessionId), + dismiss: (sessionId) => { controller.dismiss(sessionId) }, t: translate, } const view = render() - return { controller, copyController, request, copyMarkdown, openFeedback, feedback, view } + return { controller, copyController, active, openMenu, props, view, gate } } afterEach(cleanup) -describe('Session export Header action', () => { - it('opens Session feedback and closes the menu without starting a download', () => { - const b = bench(true) - fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Feedback' })) - expect(b.openFeedback).toHaveBeenCalledWith(SID) - expect(b.request).not.toHaveBeenCalled() - expect(b.view.queryByRole('menu')).toBeNull() - }) - - it('keeps export available without the feedback plugin', () => { +describe('Session export Header trigger', () => { + it('captures its Session and button anchor for the shared menu and restores a focusable trigger', () => { const b = bench() - fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) - expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() - expect(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })).toBeTruthy() - expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() - }) - - it('updates the open menu when feedback becomes available, unloads, and reloads', () => { - const b = bench() - fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) - expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() - - act(() => { b.feedback.set(true) }) - expect(b.view.getByRole('menuitem', { name: 'Feedback' })).toBeTruthy() - act(() => { b.feedback.set(false) }) - expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() - expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() - - act(() => { b.feedback.set(true) }) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Feedback' })) - expect(b.openFeedback).toHaveBeenCalledWith(SID) - expect(b.request).not.toHaveBeenCalled() - expect(b.view.queryByRole('menu')).toBeNull() - }) - - it('copies Markdown from the menu and closes it without starting a download', async () => { - const b = bench() - fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) - expect(b.view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ - 'Copy Session as Markdown', - 'Download session log', - ]) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })) - await waitFor(() => { expect(b.copyMarkdown).toHaveBeenCalledWith(SID) }) - expect(b.request).not.toHaveBeenCalled() - expect(b.view.queryByRole('menu')).toBeNull() - expect(b.copyController.store.getSnapshot().notice?.kind).toBe('success') - }) - - it('disables only the copy row while a copy is pending', async () => { - const gate = Promise.withResolvers() - const copyController = new SessionMarkdownCopyController(() => gate.promise, clipboard) - const b = bench(true, copyController) - const pending = copyController.copy(SID) - expect(copyController.store.getSnapshot().copying).toBe(true) - const button = b.view.getByRole('button', { name: 'More actions' }) - await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) + vi.spyOn(button, 'getBoundingClientRect').mockReturnValue(new DOMRect(40, 50, 28, 28)) fireEvent.click(button) - const copyItem = b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }) - await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) }) - expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false) - expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) - expect(button.getAttribute('aria-busy')).toBe('true') + expect(b.openMenu).toHaveBeenCalledExactlyOnceWith(SID, { x: 40, y: 78 }) + expect(document.activeElement).toBe(button) + expect(button.getAttribute('aria-haspopup')).toBe('menu') + expect(button.hasAttribute('data-context-menu-trigger')).toBe(true) + expect(b.view.queryByRole('menu')).toBeNull() + }) - gate.resolve(new Response('markdown')) - await pending - expect(b.copyController.store.getSnapshot().copying).toBe(false) - await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(false) }) + it('derives expanded state from only the matching Header target and preserves the button across changes', () => { + const b = bench() + const button = b.view.getByRole('button', { name: 'More actions' }) + expect(button.getAttribute('aria-expanded')).toBe('false') + act(() => { b.active.set({ kind: 'sessionHeader', value: { sessionId: SID } }) }) + expect(button.getAttribute('aria-expanded')).toBe('true') + act(() => { b.active.set({ kind: 'session', value: { sessionId: SID, displayTitle: 'Same Session' } }) }) + expect(button.getAttribute('aria-expanded')).toBe('false') + act(() => { b.active.set({ kind: 'sessionHeader', value: { sessionId: 'another' as SessionId } }) }) + expect(button.getAttribute('aria-expanded')).toBe('false') + b.view.rerender() + expect(b.view.getByRole('button', { name: 'More actions' })).toBe(button) + expect(button.getAttribute('aria-expanded')).toBe('true') + act(() => { b.active.set(null) }) + expect(button.getAttribute('aria-expanded')).toBe('false') + }) + + it('retains the Session-scoped download dialog and busy state after the shared menu closes', async () => { + const b = bench() + const button = b.view.getByRole('button', { name: 'More actions' }) + let pending!: Promise + act(() => { pending = b.controller.download(SID) }) + expect(button.getAttribute('aria-busy')).toBe('true') + expect(button.hasAttribute('disabled')).toBe(false) + expect(b.view.getByRole('dialog')).toBeTruthy() + await act(async () => { b.gate.resolve(new Response('zip')); await pending }) + await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('false') }) + expect(b.view.getByRole('dialog', { name: 'Session download started' })).toBeTruthy() + act(() => { b.controller.dismiss(SID) }) + expect(b.view.queryByRole('dialog')).toBeNull() + act(() => { b.copyController.store.set({ ...b.copyController.store.getSnapshot(), copying: true }) }) + expect(button.getAttribute('aria-busy')).toBe('true') + act(() => { b.copyController.store.set({ ...b.copyController.store.getSnapshot(), copying: false }) }) expect(button.getAttribute('aria-busy')).toBe('false') }) - - it('opens the more-actions menu and downloads through the shared controller', async () => { - const b = bench() - const button = b.view.getByRole('button', { name: 'More actions' }) - expect(button.querySelector('svg')).not.toBeNull() - expect(button.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(button) - expect(button.getAttribute('aria-expanded')).toBe('true') - fireEvent.click(b.view.getByRole('menuitem', { name: 'Download session log' })) - await waitFor(() => { expect(b.request).toHaveBeenCalledWith(SID) }) - expect(b.view.queryByRole('menuitem', { name: 'Download session log' })).toBeNull() - expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy() - }) - - it('closes the menu on Escape without downloading', () => { - const b = bench() - fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) - expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() - fireEvent.keyDown(document, { key: 'Escape' }) - expect(b.view.queryByRole('menuitem', { name: 'Download session log' })).toBeNull() - expect(b.request).not.toHaveBeenCalled() - }) - - it('disables the download row while either entry path downloads this Session', async () => { - const b = bench(true) - let release!: (response: Response) => void - const pending = new Promise((resolve) => { release = resolve }) - const controller = new SessionLogDownloadController(() => pending, vi.fn()) - const useSessionLogDownload = bindSnapshot(controller.store) - const rerendered: SessionLogDownloadHeaderProps = { - ...standard, - useSessionLogDownload, - useSessionMarkdownCopy: bindSnapshot(b.copyController.store), - useFeedbackAvailable: bindSnapshot(b.feedback), - openFeedback: b.openFeedback, - request: (sessionId: SessionId) => controller.download(sessionId), - copyMarkdown: b.copyMarkdown, - dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, - t: translate, - } - b.view.rerender() - - const download = controller.download(SID) - const button = b.view.getByRole('button', { name: 'More actions' }) - await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('true') }) - fireEvent.click(button) - const item = b.view.getByRole('menuitem', { name: 'Download session log' }) - expect((item as HTMLButtonElement).disabled).toBe(true) - expect((b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }) as HTMLButtonElement).disabled).toBe(false) - expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) - release(new Response('zip')) - await download - await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('false') }) - expect((item as HTMLButtonElement).disabled).toBe(false) - }) }) diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json index c78dbf6893..8104395dfd 100644 --- a/packages/session-query/session-log-export/tsconfig.client.json +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -21,6 +21,7 @@ { "path": "../../client/locale/tsconfig.client.json" }, { "path": "../../client/store" }, { "path": "../../client/ui-commands" }, + { "path": "../../client/ui-context-menu" }, { "path": "../../client/ui-conversation/tsconfig.client.json" }, { "path": "../../client/ui-layout" }, { "path": "../../client/ui-message-feedback" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 443827158f..c11ca79e91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4944,6 +4944,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-session-log-export': + specifier: workspace:* + version: link:../../session-query/session-log-export '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:* version: link:../../util/workspace-path @@ -14306,6 +14309,9 @@ importers: '@deepseek-ai/dsh-client-ui-commands': specifier: workspace:* version: link:../../client/ui-commands + '@deepseek-ai/dsh-client-ui-context-menu': + specifier: workspace:* + version: link:../../client/ui-context-menu '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:* version: link:../../client/ui-conversation diff --git a/tsconfig.host.json b/tsconfig.host.json index 53a03661e9..81ba20071a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -173,6 +173,7 @@ "apps/web/tests/file-upload-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/open-in-app-ssh.e2e.ts", + "apps/web/tests/open-in-app-menus.e2e.ts", "apps/web/tests/fork-mid-turn.e2e.ts", "apps/web/tests/message-feedback.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", From 8504351cc31bed328e19e3a980748c7f35d4f697 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 29 Oct 2026 20:34:30 +0800 Subject: [PATCH 2/9] test(web): isolate context gestures from native app discovery --- apps/web/tests/context-menus.e2e.ts | 6 +++++- apps/web/tests/context-menus.overlay.yml | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/context-menus.overlay.yml diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts index dfa506b6bc..f5cc9e4b42 100644 --- a/apps/web/tests/context-menus.e2e.ts +++ b/apps/web/tests/context-menus.e2e.ts @@ -1,6 +1,7 @@ /** Context gestures against built plugins, seeded Sessions, and original files. */ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Locator, type Page } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' @@ -38,7 +39,10 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', let scaffold: WebScaffold, browser: Browser, page: Page let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]) }) + scaffold = await launchWebScaffold({ + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), + extraOverlayPath: fileURLToPath(new URL('./context-menus.overlay.yml', import.meta.url)), + }) await mkdir(join(scaffold.workspaceCwd, 'folder')) await scaffold.ctx.workspaceRegistry.create(join(scaffold.workspaceCwd, 'folder'), 'Menu workspace') await writeFile(join(scaffold.workspaceCwd, 'first.txt'), 'FIRST\nSECOND\nTHIRD\n') diff --git a/apps/web/tests/context-menus.overlay.yml b/apps/web/tests/context-menus.overlay.yml new file mode 100644 index 0000000000..ca2581fa55 --- /dev/null +++ b/apps/web/tests/context-menus.overlay.yml @@ -0,0 +1,4 @@ +# Context gestures do not query the runner's native file associations. +- id: session-controller + config: + nativeOpen: false From 16f54f5ecf9e07fa267898b7d1c9fb5eac548856 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 29 Oct 2026 21:57:21 +0800 Subject: [PATCH 3/9] fix(client): address Session menu review and coverage failures --- .../session-menu.expected.md | 2 +- .../session-header-menu/guide.i18n.yaml | 4 +- .../session-header-menu/guide.md | 2 +- .../session-header-menu/guide.zh.md | 2 +- .../ui-context-menu/src/client/types.ts | 2 + .../client/ui-open-in-app/README.i18n.yaml | 8 +- packages/client/ui-open-in-app/README.md | 4 +- packages/client/ui-open-in-app/README.zh.md | 4 +- packages/client/ui-open-in-app/package.json | 1 - .../ui-open-in-app/src/client/locales.ts | 16 +- .../src/client/session-open-with.ts | 3 +- packages/client/ui-open-in-app/tsconfig.json | 3 - .../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/Menu.tsx | 11 +- .../src/menu-submenu-position.ts | 98 ++++++- .../menu-submenu-position.client.spec.tsx | 259 ++++++++++++++++++ .../tests/menu-submenu.client.spec.tsx | 50 +++- .../session-log-export/README.i18n.yaml | 4 +- .../session-log-export/README.md | 2 +- .../session-log-export/README.zh.md | 2 +- .../src/client/HeaderAction.tsx | 2 +- .../session-log-export/src/client/index.ts | 8 +- .../tests/client-apply.client.spec.tsx | 13 + pnpm-lock.yaml | 3 - 26 files changed, 449 insertions(+), 66 deletions(-) create mode 100644 packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md index 6724e2373d..f3836b2d55 100644 --- a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md @@ -4,6 +4,6 @@ - menuitem "Fork session" - separator - menuitem "Copy Session ID" - - menuitem "Show in Finder" + - menuitem "Open in" - separator - menuitem "Archive session" diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml index 8ff580f16d..3ebafc1c0f 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 835e8e217c9ac011 zh: 2d97e450247f0c78 /session-export-requires-context-menus/change: - en: 9041761cf940965b - zh: ee5051b2c7d5f3db + en: 9ccf2bdb5f5e79f8 + zh: 2773ad83b62705a7 /session-export-requires-context-menus/migration: en: e8a0ea0a7f7e333b zh: 30d2541e92b6f6a8 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md index b2312568aa..fb52efbb85 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md @@ -9,7 +9,7 @@ English | [中文](guide.zh.md) ## Change -The browser half of `@deepseek-ai/dsh-session-log-export` requires `contextMenus` to render its Header More menu. Custom Web compositions that previously mounted session-log-export without a context-menu provider must add one; otherwise the browser plugin waits for the missing service. The default Web composition already includes the provider. Host export routes and the `/export` command are unchanged. +The browser half of `@deepseek-ai/dsh-session-log-export` requires `contextMenus` to render its Header More menu. Custom Web compositions that previously mounted session-log-export without a context-menu provider must add one; otherwise the entire Header More button disappears, including access to copy, download, and feedback actions. The default Web composition already includes the provider. Host export routes and the `/export` command are unchanged. ## Migration diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md index 47cce55be7..017ef81c48 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md @@ -9,7 +9,7 @@ description: "使用 session-log-export 的自定义 Web 组合必须提供右 ## 变更 -`@deepseek-ai/dsh-session-log-export` 的浏览器半包需要 `contextMenus` 来渲染头部更多菜单。此前挂载 session-log-export 但没有右键菜单提供者的自定义 Web 组合必须补充该提供者,否则浏览器插件会等待缺失的服务。默认 Web 组合已包含提供者。Host 导出路由及 `/export` 命令不变。 +`@deepseek-ai/dsh-session-log-export` 的浏览器半包需要 `contextMenus` 来渲染头部更多菜单。此前挂载 session-log-export 但没有右键菜单提供者的自定义 Web 组合必须补充该提供者,否则整个头部更多按钮都会消失,复制、下载和反馈操作的入口也随之消失。默认 Web 组合已包含提供者。Host 导出路由及 `/export` 命令不变。 ## 迁移 diff --git a/packages/client/ui-context-menu/src/client/types.ts b/packages/client/ui-context-menu/src/client/types.ts index d32a788fb4..d479a1e7b1 100644 --- a/packages/client/ui-context-menu/src/client/types.ts +++ b/packages/client/ui-context-menu/src/client/types.ts @@ -27,6 +27,8 @@ export type UseContextMenu = () => ContextMenuBindings /** External plugins extend this map with their own target families. */ export interface ContextMenuTargetMap { session: { readonly sessionId: SessionId; readonly displayTitle: string } + /** Header utilities, independent of Session-row and title context actions. */ + sessionHeader: { readonly sessionId: SessionId } file: { readonly sessionId: SessionId; readonly path: string; readonly line?: number; readonly paneId?: PaneId } directory: { readonly sessionId: SessionId; readonly path: string } url: { readonly sessionId: SessionId; readonly url: string } diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 437a2b672c..f4c24732e4 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -15,14 +15,14 @@ en: d152484eb41ac6b4 zh: 725b47765ee8f30b /deepseek-ai-dsh-client-ui-open-in-app/use-this-package: - en: 812131f17abf1bd7 - zh: 84481ac1622a2762 + en: 02d96b9f09937c83 + zh: f5190f9beef3cfe2 /deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect: en: e07d3a607d54cb47 zh: 9a7342f05b64db38 /deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation: - en: 522e07823c4cbb5f - zh: 042ffeb95f569cc3 + en: 22da59203ca974e6 + zh: f288bd6a193bccef /deepseek-ai-dsh-client-ui-open-in-app/further-exploration: en: 81c9d99c0a1a1dd0 zh: 446056602f245293 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 0944b9b7fa..37ee0ca200 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -27,6 +27,8 @@ Open a Session’s directory through **Open in** in its Header More menu or its Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); this row takes no config. A known Session directory and at least one installed, localized catalog application enable its Open in submenu. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) shows file controls when the Host reports a desktop through `session.canOpenWorkspacePath`; removing this row removes all its controls and menu contributions. +The Header entry uses the existing More button owned by [`dsh-session-log-export`](../../session-query/session-log-export/README.md). Without that plugin, this package contributes only to the other mounted entries, such as Session-row/title context menus and file-tree controls; it does not create a Header button. + ### What to expect The Session Header places **Open in** in a separated group after download and before feedback. Session-row More, row right-click, and title right-click place it after Copy session ID and before Archive. Even one application stays in a submenu. The file-tree and document headers retain their 24px-high, 9px-radius icon-only split buttons. Their tooltips name the default application or reveal action, and their menus retain the default marker. All application icons fall back to a native-open glyph when loading fails. The file-tree opener targets its owner-supplied `absolutePath` and shares application choice and launch occupancy with Session menus. Directory choices persist in `dsh.open-in-app.choice` only after successful launches; unavailable choices fall back to the first eligible application. A busy directory launch disables both the submenu parent and all application rows. @@ -55,7 +57,7 @@ A file menu offers “Open in sidebar” and “Open in floating panel”, which
Implementation internals — click to expand -[`session-open-with.ts`](src/client/session-open-with.ts) shares one resolver across `session` and the Header owner’s `sessionHeader` target; other contributions do not cross between those targets. The file-tree slot uses `OpenInAppAction` with its owner’s `absolutePath`; there is no independent Session-header opener slot. A page-lifetime [`controller`](src/client/controller.ts) owns availability, persisted choice, and launch POST state. Menu observers and injected component hooks read those same sources. Document-relative routes and payload types come from the Host’s browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. Adapters resolve native and transport failures to named outcomes; skipped requests announce nothing. +[`session-open-with.ts`](src/client/session-open-with.ts) shares one resolver across the shared context-menu package’s `session` and `sessionHeader` targets; other contributions do not cross between those targets. The file-tree slot uses `OpenInAppAction` with its owner’s `absolutePath`; there is no independent Session-header opener slot. A page-lifetime [`controller`](src/client/controller.ts) owns availability, persisted choice, and launch POST state. Menu observers and injected component hooks read those same sources. Document-relative routes and payload types come from the Host’s browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. Adapters resolve native and transport failures to named outcomes; skipped requests announce nothing. The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, and sizing. Its injected reporter sends outcomes to one feature-owned `shell.overlay` toast, so switching Sessions or closing the initiating preview cannot discard the result. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index a5290914b8..16277ed614 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂载到 Web 组合;本行不接受配置。会话目录已知且至少有一个已安装、已本地化的目录应用时,显示在应用中打开子菜单。Host 通过 `session.canOpenWorkspacePath` 报告桌面可用时,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览显示文件控件;删除本行会移除其全部控件与菜单贡献。 +头部入口使用 [`dsh-session-log-export`](../../session-query/session-log-export/README.zh.md) 持有的现有更多按钮。未挂载该插件时,本包仅向其他已挂载的入口提供功能,例如会话行、标题右键菜单和文件树控件;不会创建头部按钮。 + ### 预期行为 会话头部把**在应用中打开**放在下载之后、反馈之前的独立分隔组。会话行更多、行右键及标题右键菜单把它放在复制 Session ID 之后、归档之前。只有一个应用时仍保留子菜单。文件树与文档标题栏保留高 24px、圆角 9px 的纯图标分体按钮。这些按钮的悬停提示显示默认应用或定位动作,其菜单保留默认标记。所有应用图标加载失败时均使用原生打开图标。文件树按钮以所属组件传入的 `absolutePath` 为目标,并与会话菜单共享应用选择及启动占用状态。目录选择仅在启动成功后持久化到 `dsh.open-in-app.choice`;原选择不可用时回退到首个符合条件的应用。目录启动忙碌时,禁用子菜单父项和全部应用行。 @@ -55,7 +57,7 @@ kind: "package-reference"
实现内幕——点击展开 -[`session-open-with.ts`](src/client/session-open-with.ts) 为 `session` 和头部所属包声明的 `sessionHeader` 目标共用一个解析器;其他贡献不会在这两种目标间扩散。文件树插槽通过所属组件的 `absolutePath` 使用 `OpenInAppAction`;会话头部没有独立打开按钮插槽。页面生命周期的 [`controller`](src/client/controller.ts) 持有可用性、持久化偏好与启动 POST 状态。菜单观察器和组件注入 hooks 读取同一组源。文档相对路由与载荷类型来自 Host 的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。适配器将原生操作及传输失败解析为具名结果;跳过的请求不播报。 +[`session-open-with.ts`](src/client/session-open-with.ts) 为共享右键菜单包声明的 `session` 和 `sessionHeader` 目标共用一个解析器;其他贡献不会在这两种目标间扩散。文件树插槽通过所属组件的 `absolutePath` 使用 `OpenInAppAction`;会话头部没有独立打开按钮插槽。页面生命周期的 [`controller`](src/client/controller.ts) 持有可用性、持久化偏好与启动 POST 状态。菜单观察器和组件注入 hooks 读取同一组源。文档相对路由与载荷类型来自 Host 的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。适配器将原生操作及传输失败解析为具名结果;跳过的请求不播报。 目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标和尺寸。注入的回调将结果交给本功能在 `shell.overlay` 中持有的唯一 toast,因此切换会话或关闭发起操作的预览不会丢失结果。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 88bf9d219c..7f91893dc0 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -79,7 +79,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-host-open-in-app": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-log-export": "workspace:*", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:*", "@deepseek-ai/dsh-native-command": "workspace:*", diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index 966834479c..d4d3c48548 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -39,7 +39,7 @@ const PRODUCT_NAMES = { /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'menu.openWith': '在应用中打开', + 'menu.openIn': '在应用中打开', 'menu.openInSidebar': '在侧边栏打开', 'menu.openInFloat': '在浮窗中打开', 'menu.finder': '在 Finder 中显示', @@ -74,7 +74,7 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { - 'menu.openWith': 'Open in', + 'menu.openIn': 'Open in', 'menu.openInSidebar': 'Open in sidebar', 'menu.openInFloat': 'Open in floating panel', 'menu.finder': 'Show in Finder', @@ -112,7 +112,7 @@ export type OpenInAppKey = keyof typeof zh /** Japanese copy, checked against the English keys. */ export const ja = { - 'menu.openWith': 'アプリケーションで開く', + 'menu.openIn': 'アプリケーションで開く', 'menu.openInSidebar': 'サイドバーで開く', 'menu.openInFloat': 'フローティングパネルで開く', 'menu.finder': 'Finder に表示', @@ -147,7 +147,7 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { - 'menu.openWith': 'Abrir con', + 'menu.openIn': 'Abrir con', 'menu.openInSidebar': 'Abrir en la barra lateral', 'menu.openInFloat': 'Abrir en un panel flotante', 'menu.finder': 'Mostrar en Finder', @@ -181,7 +181,7 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { - 'menu.openWith': 'Ouvrir avec', + 'menu.openIn': 'Ouvrir avec', 'menu.openInSidebar': 'Ouvrir dans la barre latérale', 'menu.openInFloat': 'Ouvrir dans un panneau flottant', 'menu.finder': 'Afficher dans le Finder', @@ -215,7 +215,7 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { - 'menu.openWith': 'Öffnen mit', + 'menu.openIn': 'Öffnen mit', 'menu.openInSidebar': 'In der Seitenleiste öffnen', 'menu.openInFloat': 'In schwebendem Panel öffnen', 'menu.finder': 'Im Finder anzeigen', @@ -249,7 +249,7 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { - 'menu.openWith': 'Открыть с помощью', + 'menu.openIn': 'Открыть с помощью', 'menu.openInSidebar': 'Открыть на боковой панели', 'menu.openInFloat': 'Открыть на плавающей панели', 'menu.finder': 'Показать в Finder', @@ -283,7 +283,7 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { - 'menu.openWith': 'Abrir com', + 'menu.openIn': 'Abrir com', 'menu.openInSidebar': 'Abrir na barra lateral', 'menu.openInFloat': 'Abrir em painel flutuante', 'menu.finder': 'Mostrar no Finder', diff --git a/packages/client/ui-open-in-app/src/client/session-open-with.ts b/packages/client/ui-open-in-app/src/client/session-open-with.ts index 07f794d581..7119958e9e 100644 --- a/packages/client/ui-open-in-app/src/client/session-open-with.ts +++ b/packages/client/ui-open-in-app/src/client/session-open-with.ts @@ -2,7 +2,6 @@ import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ContextMenuTargetMap, MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client' -import type {} from '@deepseek-ai/dsh-session-log-export/client' import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared' import { APP_LABEL_KEY } from './applications.ts' import type { OpenInAppController } from './controller.ts' @@ -30,7 +29,7 @@ export function registerSessionOpenWith( if (!directory || applications.length === 0) return [] const disabled = controller.operation.getSnapshot().phase === 'busy' return [{ - id: 'openWith', label: t('menu.openWith'), icon: { key: 'open-in-app.application' }, disabled, + id: 'openWith', label: t('menu.openIn'), icon: { key: 'open-in-app.application' }, disabled, children: applications.map(({ id, name }) => ({ id: `app:${id}`, label: name, disabled, icon: { key: 'open-in-app.application', src: `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${id}` }, diff --git a/packages/client/ui-open-in-app/tsconfig.json b/packages/client/ui-open-in-app/tsconfig.json index 80b13b76e1..9e6a381554 100644 --- a/packages/client/ui-open-in-app/tsconfig.json +++ b/packages/client/ui-open-in-app/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../host/open-in-app" }, - { - "path": "../../session-query/session-log-export/tsconfig.client.json" - }, { "path": "../locale/tsconfig.client.json" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 5688a2cfe0..38ff83a289 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -30,8 +30,8 @@ en: 81e58c7b342baf3c zh: 6cd47b16124b215c /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: - en: 7edcce1f9fde5341 - zh: 6e56fddb617aef5f + en: cc054a335753c1bf + zh: b994c17124cb7a51 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: en: fd510d34404eb090 zh: d05b0890db929ae6 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index d7c4715e43..5371a39017 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -141,11 +141,11 @@ The atoms cannot read the application locale, so every piece of user-facing copy `Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. The `outline` variant preserves a transparent 0.5px layout border and paints its stroke with an inset shadow. The stroke reads ui-theme's DPR-aware `--dsh-hairline-width`, with a 0.5px fallback outside the theme. Feature classes customize its color through `--dsh-button-outline-color` and preserve the border width, transparent border color, and box-shadow. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry. -Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. Pointer presses on that control or its descendants stay inside the menu interaction, so its click handler can toggle the popup. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted. +Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. For contextual menus, external popup controls carry `data-context-menu-trigger`, so their pointer presses reach the owner's toggle handler without outside dismissal; ordinary dropdowns still dismiss on these external presses. An unmarked `returnFocus` target only receives restored focus; its subtree remains outside the popup. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted. `Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color. -Submenu parents show a right chevron and open through focus, click, or hover-capable non-touch pointer events. ArrowRight enters the child; ArrowLeft and Escape return focus to the parent. Each child portals outside the parent's scrolling area, prefers the right side, flips left when it fits, and clamps vertically with internal scrolling. When neither side fits, click or ArrowRight replaces the visible parent panel with the child and a return row; focus alone keeps the parent panel visible. `submenuBackLabel` supplies its localized text; when omitted, the return row uses the caller's parent label beside a left arrow and never selects the parent. Placement depends on available space, not the device's user agent; touch clicks open children on wide screens too. Resizing retains focused child nodes. Compatibility mouse hover events do not open or collapse children. The pointer grace permits crossing the gap between cards; sibling rows close the previous child. Grace dismissal returns focus left in the child to its parent. Removing or disabling the parent, or emptying its children, restores the root panel and cancels that submenu. Focus left in the removed child returns to its enabled parent, the first enabled root row, or the anchor; focus already moved elsewhere stays there. +Submenu parents show a right chevron and open through focus, click, or hover-capable non-touch pointer events. ArrowRight on a menu row enters its child, never activates the external popup trigger; ArrowLeft and Escape return focus to the parent. Each child portals outside the parent's scrolling area, prefers the right side, flips left when it fits, and clamps vertically with internal scrolling. When neither side fits, click or ArrowRight replaces the visible parent panel with the child and a return row; focus alone keeps the parent panel visible. `submenuBackLabel` supplies its localized text; when omitted, the return row uses the caller's parent label beside a left arrow and never selects the parent. Placement depends on available space, not the device's user agent; touch clicks open children on wide screens too. Resizing retains focused child nodes. JavaScript hover requires `(any-hover: hover)` and a non-touch pointer event, so secondary mice and pens can open children even when the primary pointer is touch. Compatibility mouse hover events do not open or collapse children. The pointer grace permits crossing the gap between cards; sibling rows close the previous child. Grace dismissal returns focus left in the child to its parent. Removing or disabling the parent, or emptying its children, restores the root panel and cancels that submenu. Focus left in the removed child returns to its enabled parent, the first enabled root row, or the anchor; focus already moved elsewhere stays there. `Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3919e14704..7021d9c86f 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -141,11 +141,11 @@ kind: "package-library" `Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。`outline` 变体保留 0.5px 透明边框用于布局,并通过内阴影绘制描边。描边宽度读取 ui-theme 随设备像素比更新的 `--dsh-hairline-width`,未安装主题时回退到 0.5px。功能样式通过 `--dsh-button-outline-color` 定制描边颜色,并保留边框宽度、透明边框颜色和 box-shadow。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。 -锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。该控件及其子元素上的指针按下仍属于菜单内部交互,让控件的点击处理器可以切换弹窗。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。 +锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。对于右键菜单,外部弹窗控件带有 `data-context-menu-trigger`,其指针按下事件不会触发外部关闭,因此拥有者的点击处理器可以切换弹窗;普通下拉菜单仍会在这些外部按下事件中关闭。未标记的 `returnFocus` 目标仅用于恢复焦点,其子树仍属于弹窗外部。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。 `Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。 -子菜单父项显示向右箭头,可通过聚焦、点击或支持悬停的非触摸指针事件展开。ArrowRight 进入子级;ArrowLeft 与 Escape 将焦点还给父项。每个子级通过 portal 脱离父级滚动区域,优先向右展开,左侧能容纳时向左翻转,并在垂直方向限制位置、内部限高滚动。两侧均放不下时,点击或 ArrowRight 用子级及返回行替换当前可见的父菜单面板;仅聚焦时仍显示父面板。`submenuBackLabel` 提供该行的本地化文案;省略时使用调用方的父项标签配合向左箭头,返回操作不会选定父项。布局取决于可用空间,不取决于设备 UA;宽屏触控设备也可通过点击展开子级。调整窗口大小时保留已聚焦子项的 DOM 节点。兼容鼠标悬停事件不会展开或收起子级。指针宽限允许跨越卡片间隙;同级其他行会关闭原子级。宽限结束关闭子级时,仍在子级中的焦点会回到父项。父项被移除、禁用或其子项清空时,恢复根面板并取消该子菜单。留在被移除子级中的焦点会回到仍可用的父项、首个可用根菜单行或锚点;已经移到其他位置的焦点保持不变。 +子菜单父项显示向右箭头,可通过聚焦、点击或支持悬停的非触摸指针事件展开。菜单行上的 ArrowRight 进入其子级,不激活外部弹窗入口;ArrowLeft 与 Escape 将焦点还给父项。每个子级通过 portal 脱离父级滚动区域,优先向右展开,左侧能容纳时向左翻转,并在垂直方向限制位置、内部限高滚动。两侧均放不下时,点击或 ArrowRight 用子级及返回行替换当前可见的父菜单面板;仅聚焦时仍显示父面板。`submenuBackLabel` 提供该行的本地化文案;省略时使用调用方的父项标签配合向左箭头,返回操作不会选定父项。布局取决于可用空间,不取决于设备 UA;宽屏触控设备也可通过点击展开子级。调整窗口大小时保留已聚焦子项的 DOM 节点。JavaScript 悬停要求 `(any-hover: hover)` 与非触摸指针事件,所以主指针为触摸时,外接鼠标或笔仍可展开子级。兼容鼠标悬停事件不会展开或收起子级。指针宽限允许跨越卡片间隙;同级其他行会关闭原子级。宽限结束关闭子级时,仍在子级中的焦点会回到父项。父项被移除、禁用或其子项清空时,恢复根面板并取消该子菜单。留在被移除子级中的焦点会回到仍可用的父项、首个可用根菜单行或锚点;已经移到其他位置的焦点保持不变。 `Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。 diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index b5ca9d4ea1..4eecb0b244 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -169,7 +169,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * @param props.contextual - cursor positioning, context dismissal, and per-divider icon alignment. * @param props.exiting - retain an inert popup during dismissal or replacement fade. * @param props.returnFocus - original control captured before asynchronous menu preparation; - * pointer presses on it or its descendants stay inside the menu interaction. + * contextual menus keep presses on external `data-context-menu-trigger` controls inside the menu interaction. * @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). @@ -490,7 +490,8 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel const onPointerDown = (e: PointerEvent) => { if (!(e.target instanceof Node) || (contextual && e.button === 2)) return // The portaled list is outside the anchor subtree; check both. - if (rootRef.current?.contains(e.target) === true || returnFocus?.()?.contains(e.target) === true) return + if (rootRef.current?.contains(e.target) === true) return + if (contextual && e.target instanceof Element && e.target.closest('[data-context-menu-trigger]') !== null) return if (listRef.current?.contains(e.target) === true || submenuRef.current?.contains(e.target) === true) return onClose() } @@ -518,7 +519,7 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel returnToParent() return } - if (e.key === 'ArrowRight' && anchored && focused instanceof HTMLButtonElement + if (e.key === 'ArrowRight' && insideList && focused instanceof HTMLButtonElement && focused.getAttribute('aria-haspopup') === 'menu') { e.preventDefault() focused.click() @@ -620,7 +621,7 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel }, [open, cancelClose, cancelSubmenuClose]) const hoverAllowed = (event: { pointerType: string }): boolean => event.pointerType !== 'touch' - && window.matchMedia('(hover: hover)').matches + && window.matchMedia('(any-hover: hover)').matches const itemColumns = iconColumns(items, contextual) const footerColumns = iconColumns(footer ?? [], contextual) @@ -679,7 +680,7 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel className={clsx(css.submenu, css.scrollable, dense && css.denseList, compact && css.compactList)} style={submenuPosition === null ? MEASURE_STYLE : { left: submenuPosition.left, top: submenuPosition.top, - maxHeight: submenuPosition.maxHeight, width: submenuPosition.width, + maxHeight: submenuPosition.maxHeight, visibility: submenuHidden ? 'hidden' : undefined, }} role="menu" onPointerEnter={cancelSubmenuClose} diff --git a/packages/client/ui-primitives/src/menu-submenu-position.ts b/packages/client/ui-primitives/src/menu-submenu-position.ts index df958d87a8..c9b4867e14 100644 --- a/packages/client/ui-primitives/src/menu-submenu-position.ts +++ b/packages/client/ui-primitives/src/menu-submenu-position.ts @@ -1,4 +1,4 @@ -/** Viewport placement for Menu's portaled child card, independent of input device. */ +/** Event-driven viewport placement and width ownership for Menu's portaled child card. */ import { useLayoutEffect, useState, type RefObject } from 'react' import { overlayTopMargin } from './overlay-top-margin.ts' @@ -13,6 +13,9 @@ interface SubmenuPosition { /** * Fit a child beside its parent, or over the parent's panel when neither side fits. * CSS cannot choose a sibling portal's placement from both measured card widths. + * This hook owns the child's inline width; callers must not also render that style. + * Scroll and viewport resize place immediately. Observer and font changes share one + * requested frame; relevant running animations track frames until they settle. * @param active - The mounted parent's row ID, or null while closed. * @param parent - Parent card, including its scroll region. * @param trigger - The parent row opening the child. @@ -30,26 +33,59 @@ export function useMenuSubmenuPosition( const [position, setPosition] = useState(null) useLayoutEffect(() => { if (active === null) { setPosition(null); return } + const parentElement = parent.current + const triggerElement = trigger.current + const childElement = child.current + /* v8 ignore next -- Menu mounts all three refs before activating this layout effect; cleanup disconnects their observers. */ + if (parentElement === null || triggerElement === null || childElement === null) return + const initialWidth = childElement.style.width + let disposed = false + let frame: number | null = null + let movement: IntersectionObserver[] = [] + let previousBoxes = '' + const motionTargets: HTMLElement[] = [parentElement, triggerElement] + // Shrinking transforms can stay wholly inside an intersection root. Track + // their active animation only; a settled menu schedules no idle frame. + const moving = () => motionTargets.some(element => typeof element.getAnimations === 'function' + && element.getAnimations().some(animation => animation.playState === 'running')) + const schedule = () => { + if (disposed || frame !== null) return + frame = requestAnimationFrame(() => { + frame = null + place() + if (!disposed && moving()) schedule() + }) + } + // A tight observer root detects position changes, including transforms and + // layout shifts that change neither the element's size nor scroll offsets. + const observeMovement = (element: Element, rect: DOMRect) => { + const observer = new IntersectionObserver((entries) => { + if (entries.some(entry => entry.boundingClientRect.left !== rect.left || entry.boundingClientRect.top !== rect.top + || entry.boundingClientRect.width !== rect.width || entry.boundingClientRect.height !== rect.height)) schedule() + }, { rootMargin: `${-rect.top}px ${rect.right - window.innerWidth}px ${rect.bottom - window.innerHeight}px ${-rect.left}px`, threshold: 1 }) + observer.observe(element) + movement.push(observer) + } const place = () => { - if (parent.current === null || trigger.current === null || child.current === null) return - const panel = parent.current.getBoundingClientRect() - const row = trigger.current.getBoundingClientRect() + if (disposed) return + const panel = parentElement.getBoundingClientRect() + const row = triggerElement.getBoundingClientRect() const margin = 12 const gap = 4 const vw = window.innerWidth const vh = window.innerHeight const topMargin = contextual ? margin : overlayTopMargin(margin, row, gap) const maxHeight = Math.max(0, vh - topMargin - margin) - // Keep natural width measurable after returning from a single-panel page. - child.current.style.width = '' - const width = child.current.offsetWidth + // Intrinsic width is refreshed only on placement invalidation, not idle frames. + if (childElement.style.width !== '') childElement.style.width = '' + const width = childElement.offsetWidth const right = panel.right + gap const left = panel.left - gap - width const page = right + width > vw - margin && left < margin const pageWidth = Math.min(Math.max(panel.width, width), vw - margin * 2) const actualWidth = page ? pageWidth : width const x = page ? panel.left : right + width <= vw - margin ? right : left - const height = Math.min(child.current.offsetHeight, maxHeight) + const height = Math.min(childElement.offsetHeight, maxHeight) const y = page ? panel.top : row.top const next: SubmenuPosition = { left: Math.max(margin, Math.min(x, vw - actualWidth - margin)), @@ -58,20 +94,56 @@ export function useMenuSubmenuPosition( ...(page ? { width: pageWidth } : {}), page, } - if (page) child.current.style.width = `${pageWidth}px` + if (page) childElement.style.width = `${pageWidth}px` setPosition(current => current?.left === next.left && current.top === next.top && current.maxHeight === next.maxHeight && current.width === next.width && current.page === next.page ? current : next) + const boxes = [panel.left, panel.top, panel.width, panel.height, row.left, row.top, row.width, row.height, vw, vh].join(',') + if (typeof IntersectionObserver !== 'undefined' && boxes !== previousBoxes) { + for (const observer of movement) observer.disconnect() + movement = [] + previousBoxes = boxes + observeMovement(parentElement, panel) + observeMovement(triggerElement, row) + } } place() - let frame: number - const track = () => { place(); frame = requestAnimationFrame(track) } - frame = requestAnimationFrame(track) + const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule) + resize?.observe(parentElement) + resize?.observe(triggerElement) + resize?.observe(childElement) + const mutation = new MutationObserver((records) => { + if (records.some(record => record.target !== childElement || record.attributeName !== 'style')) schedule() + }) + const attributes = { attributes: true, attributeFilter: ['class', 'style', 'hidden'], childList: true } + mutation.observe(parentElement, { ...attributes, subtree: true, characterData: true }) + mutation.observe(childElement, { ...attributes, subtree: true, characterData: true }) + for (let ancestor = parentElement.parentElement; ancestor !== null; ancestor = ancestor.parentElement) { + mutation.observe(ancestor, attributes) + motionTargets.push(ancestor) + } + const onMotion = (event: Event) => { + if (event.target instanceof HTMLElement && motionTargets.includes(event.target)) schedule() + } + const motionEvents = ['animationstart', 'animationend', 'animationcancel', 'transitionrun', 'transitionend', 'transitioncancel'] + for (const event of motionEvents) document.addEventListener(event, onMotion, true) + if (moving()) schedule() + const fonts = 'fonts' in document ? document.fonts : undefined + fonts?.addEventListener('loadingdone', schedule) + fonts?.addEventListener('loadingerror', schedule) window.addEventListener('resize', place) window.addEventListener('scroll', place, true) return () => { - cancelAnimationFrame(frame) + disposed = true + if (frame !== null) cancelAnimationFrame(frame) + resize?.disconnect() + mutation.disconnect() + for (const event of motionEvents) document.removeEventListener(event, onMotion, true) + for (const observer of movement) observer.disconnect() + fonts?.removeEventListener('loadingdone', schedule) + fonts?.removeEventListener('loadingerror', schedule) window.removeEventListener('resize', place) window.removeEventListener('scroll', place, true) + childElement.style.width = initialWidth } }, [active, parent, trigger, child, contextual]) return position diff --git a/packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx b/packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx new file mode 100644 index 0000000000..6f1030e72f --- /dev/null +++ b/packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx @@ -0,0 +1,259 @@ +// @vitest-environment jsdom +/** Isolated submenu placement work counts exclude Menu's separate root-position tracker. */ +import { useRef } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest' +import { useMenuSubmenuPosition } from '../src/menu-submenu-position.ts' + +let panelRect: DOMRect +let triggerRect: DOMRect +let childWidth: number +let childHeight: number +const intersections: ControlledIntersectionObserver[] = [] +const resizes: ControlledResizeObserver[] = [] + +class ControlledIntersectionObserver implements IntersectionObserver { + readonly root = null + readonly rootMargin: string + readonly scrollMargin = '0px 0px 0px 0px' + readonly thresholds = [1] + private target: Element | null = null + constructor(private readonly callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.rootMargin = options?.rootMargin ?? '' + intersections.push(this) + } + observe = vi.fn((element: Element) => { this.target = element }) + unobserve = vi.fn() + disconnect = vi.fn() + takeRecords = () => [] + emit(rect: DOMRect) { + this.callback([{ target: this.target!, boundingClientRect: rect, intersectionRect: rect, + rootBounds: rect, intersectionRatio: 1, isIntersecting: true, time: 0 }], this) + } +} + +class ControlledResizeObserver implements ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) { resizes.push(this) } + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + emit() { this.callback([], this) } +} + +function observers() { + vi.stubGlobal('IntersectionObserver', ControlledIntersectionObserver) + vi.stubGlobal('ResizeObserver', ControlledResizeObserver) +} + +async function flushPlacement() { + await act(async () => { await Promise.resolve() }) + act(() => { vi.advanceTimersToNextFrame() }) +} + +function Placement({ active = true, contextual = false }: { active?: boolean; contextual?: boolean }) { + const parent = useRef(null) + const trigger = useRef(null) + const child = useRef(null) + const position = useMenuSubmenuPosition(active ? 'parent' : null, parent, trigger, child, contextual) + return <> +
+ {active &&
Choice
} + +} + +beforeEach(() => { + vi.useFakeTimers() + panelRect = new DOMRect(100, 100, 200, 100) + triggerRect = new DOMRect(100, 100, 200, 100) + childWidth = 200 + childHeight = 100 + intersections.length = 0 + resizes.length = 0 + vi.stubGlobal('innerWidth', 390) + vi.stubGlobal('innerHeight', 768) + vi.stubGlobal('ResizeObserver', undefined) + vi.stubGlobal('IntersectionObserver', undefined) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return this.tagName === 'BUTTON' ? triggerRect : panelRect + }) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => childWidth) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => childHeight) +}) +afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +it('does no geometry reads or width writes across 100 idle animation frames', async () => { + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + const width = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get') + const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get') + const view = render() + const writes = vi.spyOn(view.getByTestId('child').style, 'width', 'set') + await flushPlacement() + rect.mockClear(); width.mockClear(); height.mockClear(); writes.mockClear() + act(() => { for (let frame = 0; frame < 100; frame++) vi.advanceTimersToNextFrame() }) + const counts = { + rect: rect.mock.calls.length, width: width.mock.calls.length, height: height.mock.calls.length, widthWrites: writes.mock.calls.length, + } + console.info('submenu idle 100 frames', counts) + expect(counts).toEqual({ rect: 0, width: 0, height: 0, widthWrites: 0 }) +}) + +it('tracks parent and row movement without idle polling and coalesces observer notifications', async () => { + observers() + vi.stubGlobal('innerWidth', 1024) + const view = render() + const card = view.getByTestId('child') + const request = vi.spyOn(window, 'requestAnimationFrame') + expect(card.style.left).toBe('304px') + intersections[0]!.emit(panelRect) + expect(request).not.toHaveBeenCalled() + const original = [...intersections] + panelRect = new DOMRect(400, 100, 200, 100) + intersections[0]!.emit(panelRect) + intersections[0]!.emit(panelRect) + expect(request).toHaveBeenCalledTimes(1) + await flushPlacement() + expect(card.style.left).toBe('604px') + expect(original.every(observer => observer.disconnect.mock.calls.length === 1)).toBe(true) + triggerRect = new DOMRect(404, 740, 192, 34) + intersections.at(-1)!.emit(triggerRect) + await flushPlacement() + expect(card.style.top).toBe('656px') + expect(resizes[0]!.observe).toHaveBeenCalledTimes(3) +}) + +it('remeasures content and font changes while retaining page width and flipping when space becomes available', async () => { + observers() + const fonts = new EventTarget() + const previousFonts = Object.getOwnPropertyDescriptor(document, 'fonts') + Object.defineProperty(document, 'fonts', { configurable: true, value: fonts }) + onTestFinished(() => { + if (previousFonts === undefined) Reflect.deleteProperty(document, 'fonts') + else Object.defineProperty(document, 'fonts', previousFonts) + }) + panelRect = new DOMRect(40, 100, 300, 100) + const view = render() + const card = view.getByTestId('child') + expect(card.style.width).toBe('300px') + childWidth = 350 + card.textContent = 'A longer localized choice' + await flushPlacement() + expect(card.style.width).toBe('350px') + expect(card.style.left).toBe('28px') + childWidth = 200 + fonts.dispatchEvent(new Event('loadingdone')) + await flushPlacement() + expect(card.style.width).toBe('300px') + childWidth = 320 + fonts.dispatchEvent(new Event('loadingerror')) + await flushPlacement() + expect(card.style.width).toBe('320px') + vi.stubGlobal('innerWidth', 1024) + act(() => { window.dispatchEvent(new Event('resize')) }) + expect(card.style.width).toBe('') + expect(card.style.left).toBe('344px') + panelRect = new DOMRect(700, 100, 300, 100) + act(() => { window.dispatchEvent(new Event('scroll')) }) + expect(card.style.left).toBe('376px') + childHeight = 1000 + resizes[0]!.emit() + await flushPlacement() + expect(card.style.maxHeight).toBe('744px') + expect(card.style.top).toBe('12px') + view.unmount() + expect(resizes[0]!.disconnect).toHaveBeenCalledOnce() + expect(intersections.every(observer => observer.disconnect.mock.calls.length > 0)).toBe(true) + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + rect.mockClear() + fonts.dispatchEvent(new Event('loadingdone')) + resizes[0]!.emit() + intersections[0]!.emit(new DOMRect()) + await flushPlacement() + expect(rect).not.toHaveBeenCalled() +}) + +it.each([true, false])('uses the contextual=%s top clearance for a tall child', (contextual) => { + const root = document.documentElement + const previous = root.style.getPropertyValue('--dsh-frame-top-clearance') + root.style.setProperty('--dsh-frame-top-clearance', '96px') + onTestFinished(() => { + if (previous === '') root.style.removeProperty('--dsh-frame-top-clearance') + else root.style.setProperty('--dsh-frame-top-clearance', previous) + }) + childHeight = 1000 + const view = render() + expect(view.getByTestId('child').style.top).toBe(contextual ? '12px' : '116px') + expect(view.getByTestId('child').style.maxHeight).toBe(contextual ? '744px' : '640px') +}) + +it('observes ancestor attributes and ignores its own width writes', async () => { + const view = render() + const card = view.getByTestId('child') + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + await flushPlacement() + rect.mockClear() + card.style.width = '200px' + await flushPlacement() + expect(rect).not.toHaveBeenCalled() + panelRect = new DOMRect(20, 150, 200, 100) + view.container.style.transform = 'translateY(50px)' + await flushPlacement() + expect(card.style.top).toBe('150px') + card.className = 'compact' + await flushPlacement() + expect(rect).toHaveBeenCalled() +}) + +it.each([true, false])('tracks a finite ancestor animation alreadyRunning=%s, then stops scheduling frames', (alreadyRunning) => { + observers() + vi.stubGlobal('innerWidth', 1024) + let running = alreadyRunning + const body = document.body + const original = Object.getOwnPropertyDescriptor(body, 'getAnimations') + Object.defineProperty(body, 'getAnimations', { configurable: true, value: () => [{ playState: running ? 'running' : 'finished' }] }) + onTestFinished(() => { + if (original === undefined) Reflect.deleteProperty(body, 'getAnimations') + else Object.defineProperty(body, 'getAnimations', original) + }) + const view = render() + const card = view.getByTestId('child') + running = true + act(() => { body.dispatchEvent(new Event('transitionrun', { bubbles: true })) }) + panelRect = new DOMRect(100, 100, 180, 100) + act(() => { vi.advanceTimersToNextFrame() }) + expect(card.style.left).toBe('284px') + panelRect = new DOMRect(100, 100, 160, 100) + act(() => { vi.advanceTimersToNextFrame() }) + expect(card.style.left).toBe('264px') + running = false + act(() => { body.dispatchEvent(new Event('transitionend', { bubbles: true })); vi.advanceTimersToNextFrame() }) + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + rect.mockClear() + act(() => { + document.dispatchEvent(new Event('animationstart')) + card.dispatchEvent(new Event('animationstart', { bubbles: true })) + for (let frame = 0; frame < 100; frame++) vi.advanceTimersToNextFrame() + }) + expect(rect).not.toHaveBeenCalled() + view.unmount() + body.dispatchEvent(new Event('animationstart', { bubbles: true })) + act(() => { vi.advanceTimersToNextFrame() }) + expect(rect).not.toHaveBeenCalled() +}) + +it('cancels pending placement and ignores already queued frames after close', () => { + observers() + const callbacks: FrameRequestCallback[] = [] + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { callbacks.push(callback); return callbacks.length }) + const cancel = vi.spyOn(window, 'cancelAnimationFrame') + const view = render() + resizes[0]!.emit() + view.rerender() + expect(cancel).toHaveBeenCalledWith(1) + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + rect.mockClear() + act(() => { callbacks[0]!(0) }) + expect(rect).not.toHaveBeenCalled() + expect(view.queryByTestId('child')).toBeNull() +}) diff --git a/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx b/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx index df4acb094e..ce3db963b0 100644 --- a/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx @@ -36,7 +36,7 @@ afterEach(() => { }) function pointer( - target: Element, type: 'pointerover' | 'pointerout' | 'pointerdown', pointerType: 'mouse' | 'touch', relatedTarget: EventTarget | null = null, + target: Element, type: 'pointerover' | 'pointerout' | 'pointerdown', pointerType: 'mouse' | 'pen' | 'touch', relatedTarget: EventTarget | null = null, ) { const event = new MouseEvent(type, { bubbles: true, relatedTarget }) Object.defineProperty(event, 'pointerType', { value: pointerType }) @@ -66,7 +66,8 @@ function ExternalTriggerFixture({ onClose }: { onClose: () => void }) { const trigger = useRef(null) const [open, setOpen] = useState(false) return <> - + trigger.current} getAnchorRect={() => trigger.current?.getBoundingClientRect() ?? null} items={entries} onClose={() => { onClose(); setOpen(false) }} /> @@ -85,6 +86,51 @@ function childMenu() { } describe('Menu submenus', () => { + it('dismisses a non-contextual menu when an external marked trigger is pressed', () => { + const close = vi.fn() + render(<> + + + ) + fireEvent.pointerDown(screen.getByRole('button', { name: 'Header more' })) + expect(close).toHaveBeenCalledOnce() + }) + + it('does not activate an external popup trigger on ArrowRight', () => { + render() + const trigger = screen.getByRole('button', { name: 'External more' }) + act(() => { trigger.focus() }) + fireEvent.click(trigger) + fireEvent.keyDown(trigger, { key: 'ArrowRight' }) + expect(screen.getByRole('menu')).toBeDefined() + expect(document.activeElement).toBe(trigger) + }) + + it('dismisses on a return-focus subtree that is not marked as a menu trigger', () => { + const close = vi.fn() + const restore = document.createElement('div') + const target = document.createElement('button') + restore.append(target) + document.body.append(restore) + try { + render( restore} items={entries} onClose={close} />) + fireEvent.pointerDown(target) + expect(close).toHaveBeenCalledOnce() + } finally { restore.remove() } + }) + + it.each(['mouse', 'pen'] as const)('accepts a secondary %s when the primary pointer cannot hover', (pointerType) => { + vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(any-hover: hover)' }))) + render() + openMenu() + const parent = screen.getByRole('menuitem', { name: 'Applications' }) + pointer(parent, 'pointerover', 'touch') + fireEvent.mouseOver(parent) + expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull() + pointer(parent, 'pointerover', pointerType) + expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined() + }) + it('lets an external return-focus trigger toggle closed through pointerdown then click', () => { const close = vi.fn() render() diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 0e613d6a4c..c3f106de81 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -45,8 +45,8 @@ en: 7cb82a6851405f95 zh: b8c525170d19dc6c /deepseek-ai-dsh-session-log-export/understand-the-implementation/design-split: - en: 29c6e2eec2f51a6f - zh: 87a067d4de78b8e8 + en: a348c97530fa75f8 + zh: 268a28d324be015a /deepseek-ai-dsh-session-log-export/understand-the-implementation/download-flow: en: bd46d2fa1273a6a4 zh: 4d3eb2cf0a9bf12b diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 3b91dde81d..ae0acadd12 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -100,7 +100,7 @@ This section explains how the package wires the export control and points at the The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` and `/api/session.markdown` Fetch routes to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream, and [`src/markdown.ts`](src/markdown.ts) renders the Markdown transcript. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download and Markdown copy controllers and UI, and observes `command/executed` so only the submitting browser starts a download. -The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control. Its captured `sessionHeader` target is declaration-merged by this package and remains independent of Session-row and title context actions. Export and optional feedback contributions observe their controller, availability, and locale sources; the button observes the shared active target for expanded state. The Session-scoped download dialog remains mounted beside the trigger, while menu focus and dismissal belong to the shared overlay. +The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control. The shared context-menu package declares its captured `sessionHeader` target, independent of Session-row and title context actions; this package owns the Header trigger. Export and optional feedback contributions observe their controller, availability, and locale sources; the button observes the shared active target for expanded state. The Session-scoped download dialog remains mounted beside the trigger, while menu focus and dismissal belong to the shared overlay. ### Download flow diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index d8182eca6d..a7726c9500 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -100,7 +100,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 本包分为两部分。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` 与 `/api/session.markdown` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流,[`src/markdown.ts`](src/markdown.ts) 渲染 Markdown 记录。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载与 Markdown 复制控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。 -Header 的更多入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。本包通过声明合并定义被捕获的 `sessionHeader` 目标,独立于会话行及标题右键操作。导出及可选反馈贡献观察各自的控制器、可用性与语言源;按钮通过共享活动目标显示展开状态。会话范围的下载弹窗保持挂载在按钮旁,菜单焦点和关闭由共享浮层管理。 +Header 的更多入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。共享右键菜单包声明被捕获的 `sessionHeader` 目标,独立于会话行及标题右键操作;本包持有 Header 入口。导出及可选反馈贡献观察各自的控制器、可用性与语言源;按钮通过共享活动目标显示展开状态。会话范围的下载弹窗保持挂载在按钮旁,菜单焦点和关闭由共享浮层管理。 ### 下载流程 diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx index 2404a2b18c..0d3f81a5d1 100644 --- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx +++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx @@ -17,7 +17,7 @@ export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDial headerMenu: ObservableSnapshot } /** - * Toggle the shared menu for the captured Header Session. + * Toggle the shared menu by Header Session identity, regardless of anchor movement. * @param sessionId - Session displayed by this Header. * @param point - trigger's viewport anchor. */ diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index f914a5427c..bb59479367 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -23,13 +23,6 @@ declare module '@deepseek-ai/cordis' { } } -declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { - interface ContextMenuTargetMap { - /** Header utilities, independent of Session-row and title context actions. */ - sessionHeader: { readonly sessionId: SessionId } - } -} - declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { 'session-log-download': SessionLogDownloadKey @@ -113,6 +106,7 @@ export function apply(ctx: ClientContext): void { dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, openMenu: (sessionId, point) => { const active = ctx.contextMenus.active.getSnapshot() + // A second button activation dismisses its menu even if layout moved the anchor. if (active?.kind === 'sessionHeader' && active.value.sessionId === sessionId) ctx.contextMenus.close() else ctx.contextMenus.open({ kind: 'sessionHeader', value: { sessionId } }, point) }, diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index 58f0f1d333..73a338caf8 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -96,6 +96,19 @@ describe('session-log-download browser plugin', () => { expect(b.active.getSnapshot()).toEqual({ kind: 'sessionHeader', value: { sessionId: 'another' } }) }) + it('dismisses the same Header menu after its button anchor moves more than four pixels', async () => { + const b = await bench() + const face = injectedOf(b.slots.entries('conversation.session.header.utilities')[0]) as SessionLogDownloadHeaderInjected + face.openMenu(SID, { x: 20, y: 30 }) + const moved = { x: 120, y: 80 } + face.openMenu(SID, moved) + expect(b.close).toHaveBeenCalledOnce() + expect(b.open).toHaveBeenCalledOnce() + expect(b.active.getSnapshot()).toBeNull() + face.openMenu(SID, moved) + expect(b.open).toHaveBeenLastCalledWith({ kind: 'sessionHeader', value: { sessionId: SID } }, moved) + }) + it('provides one controller and removes its Header and overlay contributions on disposal', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 }))) const b = await bench() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1544e576a8..32d3c7ce0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4944,9 +4944,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session - '@deepseek-ai/dsh-session-log-export': - specifier: workspace:* - version: link:../../session-query/session-log-export '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:* version: link:../../util/workspace-path From cce6dca9870a317cb9aebcf574152e9c2af27042 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sat, 31 Oct 2026 02:36:23 +0800 Subject: [PATCH 4/9] refactor(client): share menu positioning and remove duplicate tracking --- apps/web/tests/open-in-app-menus.e2e.ts | 78 +++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + .../client/ui-primitives/src/Menu.module.css | 6 +- packages/client/ui-primitives/src/Menu.tsx | 16 +- .../src/menu-submenu-position.ts | 177 ++---------- .../ui-primitives/src/useAnchoredPosition.ts | 96 +++++-- .../menu-submenu-position.client.spec.tsx | 253 ++++-------------- .../tests/menu-submenu.client.spec.tsx | 4 +- .../use-anchored-position.client.spec.tsx | 15 +- 11 files changed, 261 insertions(+), 392 deletions(-) diff --git a/apps/web/tests/open-in-app-menus.e2e.ts b/apps/web/tests/open-in-app-menus.e2e.ts index 177cd31b7e..be50e5d914 100644 --- a/apps/web/tests/open-in-app-menus.e2e.ts +++ b/apps/web/tests/open-in-app-menus.e2e.ts @@ -1,7 +1,7 @@ /** Session menu opening over built plugins; only native application discovery and launch are simulated. */ import { mkdir, readFile } from 'node:fs/promises' import { join } from 'node:path' -import { chromium, type Browser, type BrowserContext, type Page } from 'playwright' +import { chromium, webkit, type Browser, type BrowserContext, type Page } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { launchWebScaffold, seedSession, type WebScaffold } from './scaffold.ts' @@ -149,6 +149,82 @@ describe('Open in Session menus', () => { expect(await page.getByRole('menuitem', { name: 'Show in Finder', exact: true }).count()).toBe(0) }) + it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( + 'tracks real geometry and ignores unrelated portals in $name', async ({ engine }) => { + const isolated = await engine.launch() + onTestFinished(() => isolated.close()) + const context = await isolated.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' }) + await nativeRoutes(context, ['vscode', 'cursor', 'finder', 'windowsterminal'], async () => 200) + const page = await context.newPage() + await openSeed(page, scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'More actions', exact: true }).click() + const parent = page.getByRole('menuitem', { name: 'Open in', exact: true }) + await page.mouse.move(20, 750) + await parent.focus() + await page.keyboard.press('ArrowRight') + const root = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }) }) + const child = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Windows Terminal', exact: true }) }) + await child.waitFor() + const rootElement = await root.elementHandle() + const childElement = await child.elementHandle() + const parentElement = await parent.elementHandle() + if (rootElement === null || childElement === null || parentElement === null) throw new Error('Both menu panels and the parent row must be mounted') + const alignment = async () => { + const [outer, nested, row] = await Promise.all([rootElement.boundingBox(), childElement.boundingBox(), parentElement.boundingBox()]) + return outer === null || nested === null || row === null ? Infinity : Math.max( + Math.abs(outer.x - nested.x - nested.width - 4), Math.abs(nested.y - row.y), + ) + } + // offsetWidth is integer-valued while browser rectangles retain fractional pixels. + await expect.poll(alignment).toBeLessThanOrEqual(1) + + // Browser layout changes are real; no observer callback or rectangle is mocked. + await rootElement.evaluate((node) => { node.style.transform = 'translate(-80px, 20px)' }) + await expect.poll(alignment).toBeLessThanOrEqual(1) + await parent.evaluate((node) => { node.style.marginTop = '24px' }) + await expect.poll(alignment).toBeLessThanOrEqual(1) + const before = await child.boundingBox() + await page.getByRole('menuitem', { name: 'Windows Terminal', exact: true }).getByText('Windows Terminal', { exact: true }) + .evaluate((node) => { node.style.fontSize = '30px' }) + await expect.poll(async () => (await child.boundingBox())?.width ?? 0).toBeGreaterThan(before?.width ?? 0) + await expect.poll(alignment).toBeLessThanOrEqual(1) + + await rootElement.evaluate((node) => { + node.style.maxHeight = '130px' + const viewport = node.querySelector(':scope > [role="presentation"]') + if (viewport === null || viewport.scrollHeight <= viewport.clientHeight) throw new Error('The parent must scroll') + viewport.scrollTop = viewport.scrollHeight + }) + await expect.poll(alignment).toBeLessThanOrEqual(1) + await page.setViewportSize({ width: 390, height: 844 }) + await page.getByRole('menuitem', { name: 'Back', exact: true }).waitFor() + await expectMenusInside(page) + expect(await childElement.evaluate(node => node.isConnected)).toBe(true) + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) + const writes = await childElement.evaluate(async (node) => { + let count = 0 + const observer = new MutationObserver((records) => { count += records.length }) + observer.observe(node, { attributes: true, attributeFilter: ['style'] }) + const unrelated = document.createElement('div') + unrelated.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;pointer-events:none' + try { + document.body.append(unrelated) + await new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + }) + return count + } finally { observer.disconnect(); unrelated.remove() } + }) + expect(writes).toBe(0) + await page.getByRole('menuitem', { name: 'Back', exact: true }).click() + await parent.waitFor() + await page.keyboard.press('Escape') + await expect.poll(() => page.getByRole('menu').count()).toBe(0) + }, + ) + it.each([{ catalog: [] }, { catalog: ['unnameable'] }, { catalog: ['finder'] }])('keeps empty and single-application catalogs consistent: $catalog', async ({ catalog }) => { const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' }) onTestFinished(() => context.close()) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 7fe0f6ec49..96620eacdd 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -30,8 +30,8 @@ en: 81e58c7b342baf3c zh: 6cd47b16124b215c /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: - en: 4316aa56d28746af - zh: 90c88ef7a37a2e33 + en: 26392da74a7af2f4 + zh: 64f2eb649abc5292 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: en: fd510d34404eb090 zh: d05b0890db929ae6 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index ff82e823d2..4c61522cf6 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -148,6 +148,8 @@ Portalled menus close when their anchor becomes inert or hidden; contextual menu Submenu parents show a right chevron and open through focus, click, or hover-capable non-touch pointer events. ArrowRight on a menu row enters its child, never activates the external popup trigger; ArrowLeft and Escape return focus to the parent. Each child portals outside the parent's scrolling area, prefers the right side, flips left when it fits, and uses the root menu's visual-viewport limits and WebKit coordinate normalization with internal scrolling. When neither side fits, click or ArrowRight replaces the visible parent panel with the child and a return row; focus alone keeps the parent panel visible. `submenuBackLabel` supplies its localized text; when omitted, the return row uses the caller's parent label beside a left arrow and never selects the parent. Placement depends on available space, not the device's user agent; touch clicks open children on wide screens too. Resizing retains focused child nodes. JavaScript hover requires `(any-hover: hover)` and a non-touch pointer event, so secondary mice and pens can open children even when the primary pointer is touch. Compatibility mouse hover events do not open or collapse children. The pointer grace permits crossing the gap between cards; sibling rows close the previous child. Grace dismissal returns focus left in the child to its parent. Removing or disabling the parent, or emptying its children, restores the root panel and cancels that submenu. Focus left in the removed child returns to its enabled parent, the first enabled root row, or the anchor; focus already moved elsewhere stays there. +Parent menus and child cards use the positioning core behind `useAnchoredPosition`, with per-card scroll/resize subscriptions, size observation, and frame-based movement tracking. The child placement function only chooses the preferred coordinates, side or page mode, and page width. The core owns `--dsh-anchored-max-width`, `--dsh-anchored-max-height`, and the optional `--dsh-anchored-width`; committed menu-content changes invalidate its natural-width measurement. Children have no independent ancestor, intersection, animation, or font observers. Internal font changes that leave the measured boxes and entry identity unchanged are picked up on the next placement invalidation, rather than through a dedicated font subscription. + `Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur. `MenuGroup` renders a `role="group"` section named by its localized heading, with an instance-owned heading id. It shares heading typography, spacing, and sticky positioning between custom menus and listboxes. Headings are transparent at rest; only `data-stuck` enables the theme's 94%-opaque group-header fill in either palette. Outside `data-platform="darwin"`, headings use `--dsw-radius-md` corners; the enclosing menu keeps its translucent material. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 779f5d3f03..b3d2bd2356 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -148,6 +148,8 @@ kind: "package-library" 子菜单父项显示向右箭头,可通过聚焦、点击或支持悬停的非触摸指针事件展开。菜单行上的 ArrowRight 进入其子级,不激活外部弹窗入口;ArrowLeft 与 Escape 将焦点还给父项。每个子级通过 portal 脱离父级滚动区域,优先向右展开,左侧能容纳时向左翻转,并采用根菜单相同的 visual viewport 限制与 WebKit 坐标归一化规则,在内部限高滚动。两侧均放不下时,点击或 ArrowRight 用子级及返回行替换当前可见的父菜单面板;仅聚焦时仍显示父面板。`submenuBackLabel` 提供该行的本地化文案;省略时使用调用方的父项标签配合向左箭头,返回操作不会选定父项。布局取决于可用空间,不取决于设备 UA;宽屏触控设备也可通过点击展开子级。调整窗口大小时保留已聚焦子项的 DOM 节点。JavaScript 悬停要求 `(any-hover: hover)` 与非触摸指针事件,所以主指针为触摸时,外接鼠标或笔仍可展开子级。兼容鼠标悬停事件不会展开或收起子级。指针宽限允许跨越卡片间隙;同级其他行会关闭原子级。宽限结束关闭子级时,仍在子级中的焦点会回到父项。父项被移除、禁用或其子项清空时,恢复根面板并取消该子菜单。留在被移除子级中的焦点会回到仍可用的父项、首个可用根菜单行或锚点;已经移到其他位置的焦点保持不变。 +父菜单与子卡片复用 `useAnchoredPosition` 背后的定位核心,各卡片分别注册滚动/窗口变化订阅、尺寸观察与逐帧移动跟踪。子菜单放置函数只选择期望坐标、侧开或单面板模式及页宽。核心持有 `--dsh-anchored-max-width`、`--dsh-anchored-max-height` 和可选的 `--dsh-anchored-width`;菜单内容提交变更时会重新测量自然宽度。子菜单没有独立的祖先、交叉、动画或字体观察器。若内部字体变化未改变已测量的边界和条目身份,则在下一次定位更新时反映,而不通过专用字体订阅触发。 + `Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。 `MenuGroup` 渲染以本地化标题命名的 `role="group"` 区段,标题 id 由各实例独立持有。自定义菜单与列表框共用其标题字体、间距和吸顶定位。标题原位透明;只有 `data-stuck` 才启用主题在浅/深色模式下的 94% 不透明分组标题填充。`data-platform="darwin"` 以外的标题使用 `--dsw-radius-md` 圆角;外围菜单保留半透明材质。 diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 6bc6319e5d..7f908287d9 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -296,8 +296,10 @@ .submenu { position: fixed; z-index: 1101; - min-width: 163px; - max-width: min(360px, calc(100vw - 24px)); + width: var(--dsh-anchored-width, max-content); + min-width: min(163px, var(--dsh-anchored-max-width, calc(100vw - 24px))); + max-width: min(360px, var(--dsh-anchored-max-width, calc(100vw - 24px))); + max-height: var(--dsh-anchored-max-height, calc(100dvh - 24px)); } /* Retain the parent's geometry and rows for placement and focus restoration. */ diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index fb89e418ab..1928bee27a 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -3,8 +3,8 @@ import type { CSSProperties, ReactNode, SyntheticEvent } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCheckOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular } from './icons/index.tsx' -import { useMenuSubmenuPosition } from './menu-submenu-position.ts' -import { useAnchoredPosition } from './useAnchoredPosition.ts' +import { placeMenuSubmenu } from './menu-submenu-position.ts' +import { useAnchoredPosition, usePositionedOverlay } from './useAnchoredPosition.ts' import { usePointerGrace } from './pointer-grace.ts' import { isBehindModal } from './useModalLayer.ts' import { observeComposition } from './keyboard-composition.ts' @@ -327,9 +327,12 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel first?.focus() } const returningFromSubmenu = useRef(false) - const submenuPosition = useMenuSubmenuPosition( - open && exiting === undefined ? openSubmenuId : null, listRef, submenuTrigger, submenuRef, contextual, - ) + const submenuPosition = usePositionedOverlay({ + open: open && exiting === undefined && openSubmenuId !== null, + anchorRef: submenuTrigger, parentRef: listRef, panelRef: submenuRef, + placement: placeMenuSubmenu, gap: 4, margin: 12, frameClearance: !contextual, + layoutKey: liveSubmenu, + }) const submenuPage = openSubmenuId !== null && openSubmenu?.activated === true && submenuPosition?.page === true const submenuHidden = submenuPosition === null || (submenuPosition.page && !submenuPage) const submenuState = useRef({ id: openSubmenuId, page: submenuPage }) @@ -632,8 +635,7 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel , - trigger: RefObject, - child: RefObject, - contextual: boolean, -): SubmenuPosition | null { - const [position, setPosition] = useState(null) - useLayoutEffect(() => { - if (active === null) { setPosition(null); return } - const parentElement = parent.current - const triggerElement = trigger.current - const childElement = child.current - /* v8 ignore next -- Menu mounts all three refs before activating this layout effect; cleanup disconnects their observers. */ - if (parentElement === null || triggerElement === null || childElement === null) return - const initialWidth = childElement.style.width - const viewport = window.visualViewport - // Match useAnchoredPosition: WebKit client rects start at the visual origin, - // while fixed CSS coordinates and viewport bounds use the layout origin. - const visualOrigin = typeof window.CSS !== 'undefined' && window.CSS.supports('-webkit-backdrop-filter', 'none') - let disposed = false - let frame: number | null = null - let movement: IntersectionObserver[] = [] - let previousBoxes = '' - const motionTargets: HTMLElement[] = [parentElement, triggerElement] - // Shrinking transforms can stay wholly inside an intersection root. Track - // their active animation only; a settled menu schedules no idle frame. - const moving = () => motionTargets.some(element => typeof element.getAnimations === 'function' - && element.getAnimations().some(animation => animation.playState === 'running')) - const schedule = () => { - if (disposed || frame !== null) return - frame = requestAnimationFrame(() => { - frame = null - place() - if (!disposed && moving()) schedule() - }) - } - // A tight observer root detects position changes, including transforms and - // layout shifts that change neither the element's size nor scroll offsets. - const observeMovement = (element: Element, rect: DOMRect) => { - const observer = new IntersectionObserver((entries) => { - if (entries.some(entry => entry.boundingClientRect.left !== rect.left || entry.boundingClientRect.top !== rect.top - || entry.boundingClientRect.width !== rect.width || entry.boundingClientRect.height !== rect.height)) schedule() - }, { rootMargin: `${-rect.top}px ${rect.right - window.innerWidth}px ${rect.bottom - window.innerHeight}px ${-rect.left}px`, threshold: 1 }) - observer.observe(element) - movement.push(observer) - } - const place = () => { - if (disposed) return - const panel = parentElement.getBoundingClientRect() - const row = triggerElement.getBoundingClientRect() - const margin = 12 - const gap = 4 - const viewportLeft = viewport?.offsetLeft ?? 0 - const viewportTop = viewport?.offsetTop ?? 0 - const vw = viewport?.width ?? (document.documentElement.clientWidth || window.innerWidth) - const vh = viewport?.height ?? (document.documentElement.clientHeight || window.innerHeight) - const offsetX = visualOrigin ? viewportLeft : 0 - const offsetY = visualOrigin ? viewportTop : 0 - const topMargin = contextual ? margin : overlayTopMargin(margin, row, gap) - const maxHeight = Math.max(0, vh - topMargin - margin) - // Intrinsic width is refreshed only on placement invalidation, not idle frames. - if (childElement.style.width !== '') childElement.style.width = '' - const width = childElement.offsetWidth - const right = offsetX + panel.right + gap - const left = offsetX + panel.left - gap - width - const page = right + width > viewportLeft + vw - margin && left < viewportLeft + margin - const pageWidth = Math.min(Math.max(panel.width, width), vw - margin * 2) - const actualWidth = page ? pageWidth : width - const x = page ? offsetX + panel.left : right + width <= viewportLeft + vw - margin ? right : left - const height = Math.min(childElement.offsetHeight, maxHeight) - const y = offsetY + (page ? panel.top : row.top) - const next: SubmenuPosition = { - left: Math.max(viewportLeft + margin, Math.min(x, viewportLeft + vw - actualWidth - margin)), - top: Math.max(viewportTop + topMargin, Math.min(y, viewportTop + vh - height - margin)), - maxHeight, - ...(page ? { width: pageWidth } : {}), - page, - } - if (page) childElement.style.width = `${pageWidth}px` - setPosition(current => current?.left === next.left && current.top === next.top - && current.maxHeight === next.maxHeight && current.width === next.width && current.page === next.page ? current : next) - const boxes = [ - panel.left, panel.top, panel.width, panel.height, row.left, row.top, row.width, row.height, vw, vh, viewportLeft, viewportTop, - ].join(',') - if (typeof IntersectionObserver !== 'undefined' && boxes !== previousBoxes) { - for (const observer of movement) observer.disconnect() - movement = [] - previousBoxes = boxes - observeMovement(parentElement, panel) - observeMovement(triggerElement, row) - } - } - place() - const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule) - resize?.observe(parentElement) - resize?.observe(triggerElement) - resize?.observe(childElement) - const mutation = new MutationObserver((records) => { - if (records.some(record => record.target !== childElement || record.attributeName !== 'style')) schedule() - }) - const attributes = { attributes: true, attributeFilter: ['class', 'style', 'hidden'], childList: true } - mutation.observe(parentElement, { ...attributes, subtree: true, characterData: true }) - mutation.observe(childElement, { ...attributes, subtree: true, characterData: true }) - for (let ancestor = parentElement.parentElement; ancestor !== null; ancestor = ancestor.parentElement) { - mutation.observe(ancestor, attributes) - motionTargets.push(ancestor) - } - const onMotion = (event: Event) => { - if (event.target instanceof HTMLElement && motionTargets.includes(event.target)) schedule() - } - const motionEvents = ['animationstart', 'animationend', 'animationcancel', 'transitionrun', 'transitionend', 'transitioncancel'] - for (const event of motionEvents) document.addEventListener(event, onMotion, true) - if (moving()) schedule() - const fonts = 'fonts' in document ? document.fonts : undefined - fonts?.addEventListener('loadingdone', schedule) - fonts?.addEventListener('loadingerror', schedule) - window.addEventListener('resize', place) - window.addEventListener('scroll', place, true) - viewport?.addEventListener('resize', place) - viewport?.addEventListener('scroll', place) - return () => { - disposed = true - if (frame !== null) cancelAnimationFrame(frame) - resize?.disconnect() - mutation.disconnect() - for (const event of motionEvents) document.removeEventListener(event, onMotion, true) - for (const observer of movement) observer.disconnect() - fonts?.removeEventListener('loadingdone', schedule) - fonts?.removeEventListener('loadingerror', schedule) - window.removeEventListener('resize', place) - window.removeEventListener('scroll', place, true) - viewport?.removeEventListener('resize', place) - viewport?.removeEventListener('scroll', place) - childElement.style.width = initialWidth - } - }, [active, parent, trigger, child, contextual]) - return position +export function placeMenuSubmenu(geometry: OverlayGeometry): OverlayPlacement { + const { anchor, parent, width, gap, bounds } = geometry + const right = parent.right + gap + const left = parent.left - gap - width + const page = right + width > bounds.right && left < bounds.left + return { + left: page ? parent.left : right + width <= bounds.right ? right : left, + top: page ? parent.top : anchor.top, + ...(page ? { width: Math.min(Math.max(parent.width, width), Math.max(0, bounds.right - bounds.left)) } : {}), + page, + } } diff --git a/packages/client/ui-primitives/src/useAnchoredPosition.ts b/packages/client/ui-primitives/src/useAnchoredPosition.ts index 61a891e5a9..32f7d9edf2 100644 --- a/packages/client/ui-primitives/src/useAnchoredPosition.ts +++ b/packages/client/ui-primitives/src/useAnchoredPosition.ts @@ -36,9 +36,48 @@ export interface AnchoredPositionOptions { * @returns Fixed left/top coordinates, or null while closed or awaiting an anchor; the first measurement completes before paint. */ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProperties | null { - const { open, anchorRef, panelRef, side = 'bottom', align = 'start', gap, margin, + return usePositionedOverlay(options)?.style ?? null +} + +/** Layout-origin geometry supplied to the package's submenu placement strategy. */ +export interface OverlayGeometry { + anchor: DOMRect + parent: DOMRect + width: number + gap: number + bounds: { left: number; right: number } +} + +/** Preferred coordinates and optional explicit width, before viewport clamping. */ +export interface OverlayPlacement { + left: number + top: number + width?: number + page: boolean +} + +/** Fixed coordinates and navigation mode from the same placement measurement. */ +interface PositionedOverlay { + style: CSSProperties + page: boolean +} + +/** + * Package-internal placement core shared by anchored overlays and Menu children. + * Owns size variables, coordinate conversion, observers, and movement tracking. + * A custom placement consumes natural width and may set `--dsh-anchored-width`; + * CSS must consume it with a max-content fallback. Managed styles restore on cleanup. + * @param options - Anchored inputs plus an optional parent card and pure placement strategy. + * @returns Coordinates and page mode together, or null while no anchor is available. + */ +export function usePositionedOverlay(options: Omit & { + parentRef?: RefObject + placement?: (geometry: OverlayGeometry) => OverlayPlacement + layoutKey?: unknown +}): PositionedOverlay | null { + const { open, anchorRef, panelRef, parentRef, placement, side = 'bottom', align = 'start', gap, margin, getAnchorRect, layoutKey, minWidthFromAnchor = false, frameClearance = true } = options - const [position, setPosition] = useState(null) + const [position, setPosition] = useState(null) useLayoutEffect(() => { if (!open) { setPosition(null) @@ -52,14 +91,18 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper const viewport = win.visualViewport // WebKit client rects use the visual origin; viewport-fixed CSS uses the layout origin. const visualOrigin = typeof win.CSS !== 'undefined' && win.CSS.supports('-webkit-backdrop-filter', 'none') - const properties = ['--dsh-anchored-max-width', '--dsh-anchored-max-height', ...minWidthFromAnchor ? ['min-width'] : []] + const parent = parentRef?.current + const properties = ['--dsh-anchored-max-width', '--dsh-anchored-max-height', + ...placement === undefined ? [] : ['--dsh-anchored-width'], ...minWidthFromAnchor ? ['min-width'] : []] const previous = properties.map(name => [name, panel.style.getPropertyValue(name), panel.style.getPropertyPriority(name)] as const) let active = true - let lastRect: Pick | null = null + let lastBoxes = '' const readRect = () => getAnchorRect === undefined ? anchor.getBoundingClientRect() : getAnchorRect() - const place = (rect: DOMRect | null): void => { - lastRect = rect === null ? null : { x: rect.x, y: rect.y, width: rect.width, height: rect.height } - let next: CSSProperties | null = null + const boxes = (rect: DOMRect | null, parentRect: DOMRect | undefined) => + [rect?.x, rect?.y, rect?.width, rect?.height, parentRect?.x, parentRect?.y, parentRect?.width, parentRect?.height].join(',') + const place = (rect: DOMRect | null, parentRect: DOMRect | undefined): void => { + lastBoxes = boxes(rect, parentRect) + let next: PositionedOverlay | null = null if (rect !== null) { const x = viewport?.offsetLeft ?? 0 const y = viewport?.offsetTop ?? 0 @@ -70,22 +113,35 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper panel.style.setProperty('--dsh-anchored-max-width', `${maxWidth}px`) panel.style.setProperty('--dsh-anchored-max-height', `${Math.max(0, height - topMargin - margin)}px`) if (minWidthFromAnchor) panel.style.minWidth = `${Math.min(rect.width, maxWidth)}px` + if (placement !== undefined) panel.style.setProperty('--dsh-anchored-width', 'max-content') const panelWidth = panel.offsetWidth const panelHeight = panel.offsetHeight - const offsetX = visualOrigin ? x : 0 - const offsetY = visualOrigin ? y : 0 - const left = offsetX + (side === 'right' ? rect.right + gap : align === 'end' ? rect.right - panelWidth : rect.left) - const top = offsetY + (side === 'right' ? rect.top : side === 'top' ? rect.top - gap - panelHeight : rect.bottom + gap) + const normalize = (box: DOMRect) => new DOMRect( + box.x + (visualOrigin ? x : 0), box.y + (visualOrigin ? y : 0), box.width, box.height, + ) + const normalized = normalize(rect) + const preferred: OverlayPlacement = placement === undefined ? { + left: side === 'right' ? normalized.right + gap : align === 'end' ? normalized.right - panelWidth : normalized.left, + top: side === 'right' ? normalized.top : side === 'top' ? normalized.top - gap - panelHeight : normalized.bottom + gap, + page: false, + } : placement({ anchor: normalized, parent: normalize(parentRect ?? rect), width: panelWidth, gap, + bounds: { left: x + margin, right: x + width - margin } }) + if (preferred.width !== undefined) panel.style.setProperty('--dsh-anchored-width', `${preferred.width}px`) + const finalHeight = preferred.width === undefined ? panelHeight : panel.offsetHeight next = { - left: Math.max(x + margin, Math.min(left, x + width - margin - panelWidth)), - top: Math.max(y + topMargin, Math.min(top, y + height - margin - panelHeight)), + style: { + left: Math.max(x + margin, Math.min(preferred.left, x + width - margin - (preferred.width ?? panelWidth))), + top: Math.max(y + topMargin, Math.min(preferred.top, y + height - margin - finalHeight)), + }, + page: preferred.page, } } - setPosition(current => current?.left === next?.left && current?.top === next?.top ? current : next) + setPosition(current => current?.style.left === next?.style.left && current?.style.top === next?.style.top + && current?.page === next?.page ? current : next) } const update = (): void => { if (!active) return - place(readRect()) + place(readRect(), parent?.getBoundingClientRect()) } update() win.addEventListener('scroll', update, true) @@ -95,13 +151,14 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update) observer?.observe(anchor) observer?.observe(panel) + if (parent != null) observer?.observe(parent) // Transforms and surrounding layout can move an anchor without resizing either element. let frame = 0 const track = (): void => { if (!active) return const rect = readRect() - if (rect?.x !== lastRect?.x || rect?.y !== lastRect?.y - || rect?.width !== lastRect?.width || rect?.height !== lastRect?.height) place(rect) + const parentRect = parent?.getBoundingClientRect() + if (boxes(rect, parentRect) !== lastBoxes) place(rect, parentRect) frame = win.requestAnimationFrame(track) } frame = win.requestAnimationFrame(track) @@ -115,6 +172,9 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper viewport?.removeEventListener('resize', update) for (const [name, value, priority] of previous) panel.style.setProperty(name, value, priority) } - }, [open, anchorRef, panelRef, side, align, gap, margin, getAnchorRect, layoutKey, minWidthFromAnchor, frameClearance]) + }, [ + open, anchorRef, panelRef, parentRef, placement, side, align, gap, margin, + getAnchorRect, layoutKey, minWidthFromAnchor, frameClearance, + ]) return position } diff --git a/packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx b/packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx index c3ee879b10..1e9d997137 100644 --- a/packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx @@ -1,80 +1,47 @@ // @vitest-environment jsdom -/** Isolated submenu placement work counts exclude Menu's separate root-position tracker. */ +/** Submenu geometry and shared overlay lifecycle; browser layout is verified in the Web lane. */ import { useRef } from 'react' import { act, cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest' -import { useMenuSubmenuPosition } from '../src/menu-submenu-position.ts' +import { placeMenuSubmenu } from '../src/menu-submenu-position.ts' +import { usePositionedOverlay } from '../src/useAnchoredPosition.ts' let panelRect: DOMRect let triggerRect: DOMRect let childWidth: number let childHeight: number -const intersections: ControlledIntersectionObserver[] = [] const resizes: ControlledResizeObserver[] = [] -class ControlledIntersectionObserver implements IntersectionObserver { - readonly root = null - readonly rootMargin: string - readonly scrollMargin = '0px 0px 0px 0px' - readonly thresholds = [1] - private target: Element | null = null - constructor(private readonly callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { - this.rootMargin = options?.rootMargin ?? '' - intersections.push(this) - } - observe = vi.fn((element: Element) => { this.target = element }) - unobserve = vi.fn() - disconnect = vi.fn() - takeRecords = () => [] - emit(rect: DOMRect) { - this.callback([{ target: this.target!, boundingClientRect: rect, intersectionRect: rect, - rootBounds: rect, intersectionRatio: 1, isIntersecting: true, time: 0 }], this) - } -} - class ControlledResizeObserver implements ResizeObserver { constructor(private readonly callback: ResizeObserverCallback) { resizes.push(this) } - observe = vi.fn() + observe = vi.fn() unobserve = vi.fn() disconnect = vi.fn() emit() { this.callback([], this) } } -function observers() { - vi.stubGlobal('IntersectionObserver', ControlledIntersectionObserver) - vi.stubGlobal('ResizeObserver', ControlledResizeObserver) -} - -async function flushPlacement() { - await act(async () => { await Promise.resolve() }) - act(() => { vi.advanceTimersToNextFrame() }) -} - -function Placement({ active = true, contextual = false }: { active?: boolean; contextual?: boolean }) { +function Placement({ active = true, contextual = false, revision = 0 }: { active?: boolean; contextual?: boolean; revision?: number }) { const parent = useRef(null) const trigger = useRef(null) const child = useRef(null) - const position = useMenuSubmenuPosition(active ? 'parent' : null, parent, trigger, child, contextual) + const position = usePositionedOverlay({ open: active, parentRef: parent, anchorRef: trigger, panelRef: child, + gap: 4, margin: 12, frameClearance: !contextual, placement: placeMenuSubmenu, layoutKey: revision }) return <>
- {active &&
Choice
} + {active &&
Choice
} } beforeEach(() => { vi.useFakeTimers() panelRect = new DOMRect(100, 100, 200, 100) - triggerRect = new DOMRect(100, 100, 200, 100) + triggerRect = new DOMRect(104, 140, 192, 34) childWidth = 200 childHeight = 100 - intersections.length = 0 resizes.length = 0 vi.stubGlobal('innerWidth', 390) vi.stubGlobal('innerHeight', 768) - vi.stubGlobal('ResizeObserver', undefined) - vi.stubGlobal('IntersectionObserver', undefined) + vi.stubGlobal('ResizeObserver', ControlledResizeObserver) vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { return this.tagName === 'BUTTON' ? triggerRect : panelRect }) @@ -83,25 +50,19 @@ beforeEach(() => { }) afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) -it('does no geometry reads or width writes across 100 idle animation frames', async () => { - const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') - const width = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get') - const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get') - const view = render() - const writes = vi.spyOn(view.getByTestId('child').style, 'width', 'set') - await flushPlacement() - rect.mockClear(); width.mockClear(); height.mockClear(); writes.mockClear() - act(() => { for (let frame = 0; frame < 100; frame++) vi.advanceTimersToNextFrame() }) - const counts = { - rect: rect.mock.calls.length, width: width.mock.calls.length, height: height.mock.calls.length, widthWrites: writes.mock.calls.length, - } - console.info('submenu idle 100 frames', counts) - expect(counts).toEqual({ rect: 0, width: 0, height: 0, widthWrites: 0 }) +it.each([ + { parentX: 100, right: 1012, width: 200, left: 304, top: 140, page: false }, + { parentX: 780, right: 1012, width: 200, left: 576, top: 140, page: false }, + { parentX: 100, right: 378, width: 200, left: 100, top: 100, page: true }, + { parentX: 100, right: 378, width: 500, left: 100, top: 100, page: true }, + { parentX: 12, right: 416, width: 200, left: 216, top: 140, page: false }, +])('places a child at $left/$top with page=$page', ({ parentX, right, width, left, top, page }) => { + expect(placeMenuSubmenu({ parent: new DOMRect(parentX, 100, 200, 100), anchor: triggerRect, + width, gap: 4, bounds: { left: 12, right } })).toEqual({ left, top, page, + ...(page ? { width: Math.min(Math.max(200, width), right - 12) } : {}) }) }) it.each([true, false])('fits the visual viewport in WebKit=%s coordinates and follows its events', (webkit) => { - vi.stubGlobal('innerWidth', 1000) - vi.stubGlobal('innerHeight', 900) vi.stubGlobal('CSS', { supports: () => webkit }) const viewport = Object.assign(new EventTarget(), { width: 500, height: 400, offsetLeft: 40, offsetTop: 60 }) vi.stubGlobal('visualViewport', viewport) @@ -110,108 +71,57 @@ it.each([true, false])('fits the visual viewport in WebKit=%s coordinates and fo triggerRect = webkit ? new DOMRect(104, 240, 192, 34) : new DOMRect(144, 300, 192, 34) const view = render() const card = view.getByTestId('child') - expect(card.style.width).toBe('200px') + expect(card.style.getPropertyValue('--dsh-anchored-width')).toBe('200px') expect(card.style.left).toBe('140px') expect(card.style.top).toBe('260px') - expect(card.style.maxHeight).toBe('376px') + expect(card.style.getPropertyValue('--dsh-anchored-max-height')).toBe('376px') act(() => { viewport.width = 900; viewport.dispatchEvent(new Event('resize')) }) - expect(card.style.width).toBe('') + expect(card.style.getPropertyValue('--dsh-anchored-width')).toBe('max-content') expect(card.style.left).toBe('344px') expect(card.style.top).toBe('300px') childHeight = 500 act(() => { viewport.height = 180; viewport.offsetTop = 120; viewport.dispatchEvent(new Event('scroll')) }) - expect(card.style.maxHeight).toBe('156px') + expect(card.style.getPropertyValue('--dsh-anchored-max-height')).toBe('156px') expect(card.style.top).toBe('132px') view.unmount() expect(remove.mock.calls.map(([type]) => type)).toEqual(expect.arrayContaining(['resize', 'scroll'])) }) -it('uses document client dimensions when there is no visual viewport', () => { +it('uses document dimensions and remeasures natural width on committed content changes', () => { vi.stubGlobal('visualViewport', undefined) - vi.stubGlobal('innerWidth', 1000) - vi.stubGlobal('innerHeight', 1000) vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(390) vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(600) - const view = render() - expect(view.getByTestId('child').style.width).toBe('200px') - expect(view.getByTestId('child').style.maxHeight).toBe('576px') -}) - -it('tracks parent and row movement without idle polling and coalesces observer notifications', async () => { - observers() - vi.stubGlobal('innerWidth', 1024) - const view = render() - const card = view.getByTestId('child') - const request = vi.spyOn(window, 'requestAnimationFrame') - expect(card.style.left).toBe('304px') - intersections[0]!.emit(panelRect) - expect(request).not.toHaveBeenCalled() - const original = [...intersections] - panelRect = new DOMRect(400, 100, 200, 100) - intersections[0]!.emit(panelRect) - intersections[0]!.emit(panelRect) - expect(request).toHaveBeenCalledTimes(1) - await flushPlacement() - expect(card.style.left).toBe('604px') - expect(original.every(observer => observer.disconnect.mock.calls.length === 1)).toBe(true) - triggerRect = new DOMRect(404, 740, 192, 34) - intersections.at(-1)!.emit(triggerRect) - await flushPlacement() - expect(card.style.top).toBe('656px') - expect(resizes[0]!.observe).toHaveBeenCalledTimes(3) -}) - -it('remeasures content and font changes while retaining page width and flipping when space becomes available', async () => { - observers() - const fonts = new EventTarget() - const previousFonts = Object.getOwnPropertyDescriptor(document, 'fonts') - Object.defineProperty(document, 'fonts', { configurable: true, value: fonts }) - onTestFinished(() => { - if (previousFonts === undefined) Reflect.deleteProperty(document, 'fonts') - else Object.defineProperty(document, 'fonts', previousFonts) - }) panelRect = new DOMRect(40, 100, 300, 100) const view = render() const card = view.getByTestId('child') - expect(card.style.width).toBe('300px') + expect(card.style.getPropertyValue('--dsh-anchored-width')).toBe('300px') + expect(card.style.getPropertyValue('--dsh-anchored-max-height')).toBe('576px') childWidth = 350 - card.textContent = 'A longer localized choice' - await flushPlacement() - expect(card.style.width).toBe('350px') + view.rerender() + expect(card.style.getPropertyValue('--dsh-anchored-width')).toBe('350px') expect(card.style.left).toBe('28px') childWidth = 200 - fonts.dispatchEvent(new Event('loadingdone')) - await flushPlacement() - expect(card.style.width).toBe('300px') - childWidth = 320 - fonts.dispatchEvent(new Event('loadingerror')) - await flushPlacement() - expect(card.style.width).toBe('320px') - vi.stubGlobal('innerWidth', 1024) - act(() => { window.dispatchEvent(new Event('resize')) }) - expect(card.style.width).toBe('') - expect(card.style.left).toBe('344px') - panelRect = new DOMRect(700, 100, 300, 100) - act(() => { window.dispatchEvent(new Event('scroll')) }) - expect(card.style.left).toBe('376px') - childHeight = 1000 - resizes[0]!.emit() - await flushPlacement() - expect(card.style.maxHeight).toBe('744px') - expect(card.style.top).toBe('12px') - view.unmount() - expect(resizes[0]!.disconnect).toHaveBeenCalledOnce() - expect(intersections.every(observer => observer.disconnect.mock.calls.length > 0)).toBe(true) - const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') - rect.mockClear() - fonts.dispatchEvent(new Event('loadingdone')) - resizes[0]!.emit() - intersections[0]!.emit(new DOMRect()) - await flushPlacement() - expect(rect).not.toHaveBeenCalled() + view.rerender() + expect(card.style.getPropertyValue('--dsh-anchored-width')).toBe('300px') }) -it.each([true, false])('uses the contextual=%s top clearance for a tall child', (contextual) => { +it('follows parent and row movement through the shared animation-frame tracker', () => { + vi.stubGlobal('innerWidth', 1024) + const view = render() + const card = view.getByTestId('child') + panelRect = new DOMRect(400, 100, 200, 100) + act(() => { vi.advanceTimersToNextFrame() }) + expect(card.style.left).toBe('604px') + triggerRect = new DOMRect(404, 740, 192, 34) + act(() => { vi.advanceTimersToNextFrame() }) + expect(card.style.top).toBe('656px') + childHeight = 1000 + act(() => { resizes[0]!.emit() }) + expect(card.style.top).toBe('12px') + expect(resizes[0]!.observe.mock.calls.map(([node]) => node)).toEqual([view.getByRole('button'), card, view.getByTestId('parent')]) +}) + +it.each([true, false])('uses contextual=%s top clearance for a tall child', (contextual) => { const root = document.documentElement const previous = root.style.getPropertyValue('--dsh-frame-top-clearance') root.style.setProperty('--dsh-frame-top-clearance', '96px') @@ -220,78 +130,25 @@ it.each([true, false])('uses the contextual=%s top clearance for a tall child', else root.style.setProperty('--dsh-frame-top-clearance', previous) }) childHeight = 1000 + triggerRect = new DOMRect(100, 100, 200, 100) const view = render() expect(view.getByTestId('child').style.top).toBe(contextual ? '12px' : '116px') - expect(view.getByTestId('child').style.maxHeight).toBe(contextual ? '744px' : '640px') + expect(view.getByTestId('child').style.getPropertyValue('--dsh-anchored-max-height')).toBe(contextual ? '744px' : '640px') }) -it('observes ancestor attributes and ignores its own width writes', async () => { - const view = render() - const card = view.getByTestId('child') - const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') - await flushPlacement() - rect.mockClear() - card.style.width = '200px' - await flushPlacement() - expect(rect).not.toHaveBeenCalled() - panelRect = new DOMRect(20, 150, 200, 100) - view.container.style.transform = 'translateY(50px)' - await flushPlacement() - expect(card.style.top).toBe('150px') - card.className = 'compact' - await flushPlacement() - expect(rect).toHaveBeenCalled() -}) - -it.each([true, false])('tracks a finite ancestor animation alreadyRunning=%s, then stops scheduling frames', (alreadyRunning) => { - observers() - vi.stubGlobal('innerWidth', 1024) - let running = alreadyRunning - const body = document.body - const original = Object.getOwnPropertyDescriptor(body, 'getAnimations') - Object.defineProperty(body, 'getAnimations', { configurable: true, value: () => [{ playState: running ? 'running' : 'finished' }] }) - onTestFinished(() => { - if (original === undefined) Reflect.deleteProperty(body, 'getAnimations') - else Object.defineProperty(body, 'getAnimations', original) - }) - const view = render() - const card = view.getByTestId('child') - running = true - act(() => { body.dispatchEvent(new Event('transitionrun', { bubbles: true })) }) - panelRect = new DOMRect(100, 100, 180, 100) - act(() => { vi.advanceTimersToNextFrame() }) - expect(card.style.left).toBe('284px') - panelRect = new DOMRect(100, 100, 160, 100) - act(() => { vi.advanceTimersToNextFrame() }) - expect(card.style.left).toBe('264px') - running = false - act(() => { body.dispatchEvent(new Event('transitionend', { bubbles: true })); vi.advanceTimersToNextFrame() }) - const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') - rect.mockClear() - act(() => { - document.dispatchEvent(new Event('animationstart')) - card.dispatchEvent(new Event('animationstart', { bubbles: true })) - for (let frame = 0; frame < 100; frame++) vi.advanceTimersToNextFrame() - }) - expect(rect).not.toHaveBeenCalled() - view.unmount() - body.dispatchEvent(new Event('animationstart', { bubbles: true })) - act(() => { vi.advanceTimersToNextFrame() }) - expect(rect).not.toHaveBeenCalled() -}) - -it('cancels pending placement and ignores already queued frames after close', () => { - observers() +it('restores size variables and ignores queued observer and frame callbacks after close', () => { const callbacks: FrameRequestCallback[] = [] vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { callbacks.push(callback); return callbacks.length }) const cancel = vi.spyOn(window, 'cancelAnimationFrame') const view = render() - resizes[0]!.emit() + const card = view.getByTestId('child') view.rerender() expect(cancel).toHaveBeenCalledWith(1) + expect(resizes[0]!.disconnect).toHaveBeenCalledOnce() + for (const name of ['width', 'max-width', 'max-height']) expect(card.style.getPropertyValue(`--dsh-anchored-${name}`)).toBe('') const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') rect.mockClear() - act(() => { callbacks[0]!(0) }) + act(() => { callbacks[0]!(0); resizes[0]!.emit(); window.dispatchEvent(new Event('scroll')) }) expect(rect).not.toHaveBeenCalled() expect(view.queryByTestId('child')).toBeNull() }) diff --git a/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx b/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx index ce3db963b0..dbdbe7fd8f 100644 --- a/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx @@ -245,7 +245,7 @@ describe('Menu submenus', () => { expect(menu.parentElement).toBe(document.body) expect(menu.style.left).toBe('576px') expect(menu.style.top).toBe('12px') - expect(menu.style.maxHeight).toBe('744px') + expect(menu.style.getPropertyValue('--dsh-anchored-max-height')).toBe('744px') expect(screen.getByRole('menuitem', { name: 'Applications' }).closest('[role="menu"]')?.contains(menu)).toBe(false) }) @@ -260,7 +260,7 @@ describe('Menu submenus', () => { const menu = childMenu() expect(screen.getAllByRole('menu')).toHaveLength(1) expect(menu.style.left).toBe('170px') - expect(menu.style.width).toBe('200px') + expect(menu.style.getPropertyValue('--dsh-anchored-width')).toBe('200px') expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' })) fireEvent.click(screen.getByRole('menuitem', { name: '返回上级' })) expect(select).not.toHaveBeenCalled() diff --git a/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx b/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx index df02d5a43a..45f5089306 100644 --- a/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx +++ b/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx @@ -3,7 +3,7 @@ import { useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render, renderHook } from '@testing-library/react' -import { useAnchoredPosition } from '../src/useAnchoredPosition.ts' +import { useAnchoredPosition, usePositionedOverlay, type OverlayGeometry } from '../src/useAnchoredPosition.ts' let frames: FrameRequestCallback[] beforeEach(() => { @@ -39,6 +39,19 @@ function show(props: Parameters[0]) { } describe('useAnchoredPosition', () => { + it('uses the anchor as the reference for a placement without a separate parent', () => { + const anchor = document.createElement('button') + const panel = document.createElement('div') + const placement = vi.fn((geometry: OverlayGeometry) => ({ + left: geometry.anchor.left, top: geometry.anchor.bottom + geometry.gap, page: false, + })) + const { result } = renderHook(() => usePositionedOverlay({ + open: true, anchorRef: { current: anchor }, panelRef: { current: panel }, gap: 4, margin: 12, placement, + })) + expect(placement.mock.calls[0]![0].parent).toEqual(placement.mock.calls[0]![0].anchor) + expect(result.current).toEqual({ style: { left: 100, top: 324 }, page: false }) + }) + it.each(['anchor', 'panel'] as const)('does not measure or subscribe without the %s element', (missing) => { const anchor = document.createElement('button') const measure = vi.spyOn(anchor, 'getBoundingClientRect') From 13a8cc11c7b283d5935279e9a3cb4b22afc0c77c Mon Sep 17 00:00:00 2001 From: zhc Date: Tue, 3 Nov 2026 17:27:32 +0800 Subject: [PATCH 5/9] refactor(client): move menu target kinds to their owners Address review on context-menu target ownership and MenuRow typing. - `ui-workspace` declares the `workspace` target and `ui-conversation` declares `sessionHeader`; `ui-context-menu` no longer depends on `@deepseek-ai/dsh-workspace`. - Remove `buttonTargetKey`. A shared button now toggles only the popup it opened, tracked by its instance id, so the menu service needs no per-kind identity rule. - Name the optional-or-undefined props helper in `MenuRow`, document its package-internal props, and replace the `MenuDecoration` component with a typed render helper. --- .../client/ui-context-menu/README.i18n.yaml | 4 +- packages/client/ui-context-menu/README.md | 2 +- packages/client/ui-context-menu/README.zh.md | 2 +- packages/client/ui-context-menu/package.json | 1 - .../ui-context-menu/src/client/Button.tsx | 26 +++++-- .../ui-context-menu/src/client/index.ts | 68 ++++++++++-------- .../ui-context-menu/src/client/target-key.ts | 17 +---- .../ui-context-menu/src/client/types.ts | 9 +-- .../tests/button.client.spec.tsx | 32 +++++---- .../tests/plugin.client.spec.ts | 4 +- .../tests/registry.client.spec.ts | 14 ++-- .../tests/target-key.client.spec.ts | 16 +---- packages/client/ui-context-menu/tsconfig.json | 3 - .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/slots.ts | 7 ++ packages/client/ui-primitives/src/Menu.tsx | 4 +- packages/client/ui-primitives/src/MenuRow.tsx | 27 ++++--- .../client/ui-primitives/src/SubmenuPanel.tsx | 4 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../ui-workspace/src/client/contract/slots.ts | 7 ++ .../src/client/slot-catalog.ts | 70 +++++++++---------- .../session-log-export/package.json | 2 + .../session-log-export/src/client/index.ts | 1 + .../session-log-export/tsconfig.client.json | 1 + pnpm-lock.yaml | 6 +- 29 files changed, 180 insertions(+), 163 deletions(-) diff --git a/packages/client/ui-context-menu/README.i18n.yaml b/packages/client/ui-context-menu/README.i18n.yaml index 94d7a1530e..4c2006bb68 100644 --- a/packages/client/ui-context-menu/README.i18n.yaml +++ b/packages/client/ui-context-menu/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 725b47765ee8f30b /deepseek-ai-dsh-client-ui-context-menu/use-this-package: - en: a5763926943b34ca - zh: 3f34ff22971bbecb + en: 122f4cbfda90c063 + zh: f88536b7641e445b /deepseek-ai-dsh-client-ui-context-menu/use-this-package/register-actions: en: 11bea09005de2678 zh: d56e25d2d93022aa diff --git a/packages/client/ui-context-menu/README.md b/packages/client/ui-context-menu/README.md index a9c44bf797..49a558e69b 100644 --- a/packages/client/ui-context-menu/README.md +++ b/packages/client/ui-context-menu/README.md @@ -29,7 +29,7 @@ The Web application bundle mounts this Client plugin. External Client plugins de The plugin injects `useContextMenu` into every Slot scope through `ctx.slots.provideRoot`. Call `const { getTriggerProps } = useContextMenu()` once in the component and spread `getTriggerProps(target, handlers?)` onto each trigger. The factory is an ordinary function and can run in loops. It handles right-click, Shift+F10, the ContextMenu key, viewport coordinates, and focus without scrolling. Existing handlers run first; `preventDefault()` keeps their ownership and prevents duplicate handling by composed or nested bindings. Repeated, composing, and unrelated key events remain untouched. Pass `undefined` to retain native behavior, or a target resolver to read business state when the gesture begins. The resolved object is captured before focus changes. Plain children receive event props or a factory through props or an existing delegate; no additional React Context is needed. Special editors and canvases can call `ctx.contextMenus.open()` directly. -Use `renderFactorySlot('contextMenu.button', { target, label, className?, size? })` for an ellipsis trigger. `label` is localized by the caller; `size: 'sm'` selects the compact toolbar button, while omission retains caller-styled row geometry. The factory owns focus, pointer and click isolation, the bottom-edge anchor, expanded state, and identity-based toggling even after the button moves. Session, Session-header, and Workspace buttons use kind plus entity ID, so display-title or Workspace-path updates do not change their identity; other targets use canonical JSON equality. It hides only when the target kind has no registered contribution, without resolving actions early. The stable `ctx.contextMenus.registeredKinds` observable publishes registration and withdrawal immediately, including providers activated after the button mounts. A registered provider returning no actions still makes its button available. +Use `renderFactorySlot('contextMenu.button', { target, label, className?, size? })` for an ellipsis trigger. `label` is localized by the caller; `size: 'sm'` selects the compact toolbar button, while omission retains caller-styled row geometry. The factory owns focus, pointer and click isolation, the bottom-edge anchor, and expanded state. Each rendered button toggles only the popup it opened: activating it again closes that popup even after the button moves or its target's display fields change, while another button for an equal target opens its own popup. Repeated right-click and keyboard gestures keep the 4-pixel rule. Target families need no identity rule here. It hides only when the target kind has no registered contribution, without resolving actions early. The stable `ctx.contextMenus.registeredKinds` observable publishes registration and withdrawal immediately, including providers activated after the button mounts. A registered provider returning no actions still makes its button available. ### Register actions diff --git a/packages/client/ui-context-menu/README.zh.md b/packages/client/ui-context-menu/README.zh.md index b96db11224..6296443b82 100644 --- a/packages/client/ui-context-menu/README.zh.md +++ b/packages/client/ui-context-menu/README.zh.md @@ -29,7 +29,7 @@ Web 应用 bundle 挂载此客户端插件。外部客户端插件在 `dsh.clien 插件通过 `ctx.slots.provideRoot` 向每个 Slot 作用域注入 `useContextMenu`。组件调用一次 `const { getTriggerProps } = useContextMenu()`,再将 `getTriggerProps(target, handlers?)` 展开到各个触发元素上。该工厂是普通函数,可以在循环中调用;它统一处理右键、Shift+F10、ContextMenu 键、视口坐标及无滚动聚焦。已有处理器先运行,`preventDefault()` 保留其处理权,并防止组合或嵌套绑定重复处理。重复、输入法组合及无关按键保持原状。传入 `undefined` 保留原生行为,也可传入目标解析函数,在手势开始时读取业务状态。解析后的对象会在焦点变化前捕获。普通子组件通过 props 或现有 delegate 接收事件 props 或工厂,无需额外 React Context。特殊编辑器和 Canvas 可直接调用 `ctx.contextMenus.open()`。 -省略号触发器使用 `renderFactorySlot('contextMenu.button', { target, label, className?, size? })`。`label` 由调用方本地化;`size: 'sm'` 选择紧凑工具栏按钮,省略时保留调用方设置的行按钮尺寸。工厂统一管理焦点、指针和点击隔离、下边缘锚点、展开状态,以及按钮移动后仍按目标身份切换的行为。会话、会话 Header 和工作区按钮使用类别加实体 ID,因此展示标题或工作区路径更新不会改变身份;其他目标使用规范化 JSON 比较。仅当目标类别没有注册贡献时隐藏按钮,不提前解析操作。稳定的 `ctx.contextMenus.registeredKinds` observable 即时发布注册和注销,包括按钮挂载后才激活的提供方。已注册提供方即使返回空操作,按钮仍可用。 +省略号触发器使用 `renderFactorySlot('contextMenu.button', { target, label, className?, size? })`。`label` 由调用方本地化;`size: 'sm'` 选择紧凑工具栏按钮,省略时保留调用方设置的行按钮尺寸。工厂统一管理焦点、指针和点击隔离、下边缘锚点与展开状态。每个已渲染的按钮只切换自己打开的弹出菜单:按钮移动或目标展示字段变化后,再次激活仍会关闭该菜单;另一个目标相同的按钮则打开自己的菜单。重复的右键和键盘手势仍沿用 4 像素规则。目标类别无需在此声明身份规则。仅当目标类别没有注册贡献时隐藏按钮,不提前解析操作。稳定的 `ctx.contextMenus.registeredKinds` observable 即时发布注册和注销,包括按钮挂载后才激活的提供方。已注册提供方即使返回空操作,按钮仍可用。 ### 注册操作 diff --git a/packages/client/ui-context-menu/package.json b/packages/client/ui-context-menu/package.json index e37610aa21..6aacc8717f 100644 --- a/packages/client/ui-context-menu/package.json +++ b/packages/client/ui-context-menu/package.json @@ -58,7 +58,6 @@ "react-dom": "^18.2.0", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/dsh-client-ui-dockkit": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, diff --git a/packages/client/ui-context-menu/src/client/Button.tsx b/packages/client/ui-context-menu/src/client/Button.tsx index b00341d4af..468122b889 100644 --- a/packages/client/ui-context-menu/src/client/Button.tsx +++ b/packages/client/ui-context-menu/src/client/Button.tsx @@ -1,14 +1,25 @@ /** Shared ellipsis control for explicit context-menu targets. */ import { Button, IconEllipsisOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' +import { useId } from 'react' import type { MouseEvent, PointerEvent } from 'react' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { ContextMenus, ContextMenuTarget, MenuPoint } from './types.ts' -import { buttonTargetKey } from './target-key.ts' -/** Menu facts and the identity-based button action, bound by the factory registration. */ +/** Menu facts and the per-button toggle, bound by the factory registration. */ export interface ContextMenuButtonInjected { - hooks: { active: ContextMenus['active']; registeredKinds: ContextMenus['registeredKinds'] } - toggle: (target: ContextMenuTarget, point: MenuPoint) => void + hooks: { + /** Instance id of the shared button that opened the current popup; null for gestures and after dismissal. */ + opener: ObservableSnapshot + registeredKinds: ContextMenus['registeredKinds'] + } + /** + * Close the popup this button opened, or open a new one for its target. + * @param button - instance id of the activated button. + * @param target - captured object. + * @param point - viewport anchor below the button. + */ + toggle: (button: string, target: ContextMenuTarget, point: MenuPoint) => void } /** @@ -16,8 +27,9 @@ export interface ContextMenuButtonInjected { * @param props - Localized owner data and framework-bound menu facts. * @returns An available target's menu trigger. */ -export function ContextMenuButton({ target, label, className, size, useActive, useRegisteredKinds, toggle }: FactoryComponentPropsOf<'contextMenu.button'>) { - const expanded = useActive(active => active !== null && buttonTargetKey(active) === buttonTargetKey(target)) +export function ContextMenuButton({ target, label, className, size, useOpener, useRegisteredKinds, toggle }: FactoryComponentPropsOf<'contextMenu.button'>) { + const id = useId() + const expanded = useOpener(opener => opener === id) const available = useRegisteredKinds(kinds => kinds.includes(target.kind)) if (!available) return null const attributes = { @@ -31,7 +43,7 @@ export function ContextMenuButton({ target, label, className, size, useActive, u event.stopPropagation() event.currentTarget.focus({ preventScroll: true }) const rect = event.currentTarget.getBoundingClientRect() - toggle(target, { x: rect.left, y: rect.bottom }) + toggle(id, target, { x: rect.left, y: rect.bottom }) }, } return diff --git a/packages/client/ui-context-menu/src/client/index.ts b/packages/client/ui-context-menu/src/client/index.ts index 824f879e44..b4244c4153 100644 --- a/packages/client/ui-context-menu/src/client/index.ts +++ b/packages/client/ui-context-menu/src/client/index.ts @@ -6,7 +6,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import z from '@deepseek-ai/schemastery' import { MENU_TRANSITION_MS } from '@deepseek-ai/dsh-client-ui-primitives' -import { buttonTargetKey, menuTargetKey } from './target-key.ts' +import { menuTargetKey } from './target-key.ts' import { MenuRegistry } from './registry.ts' import { createMenuStore } from './store.ts' import { dictionaries } from './locales.ts' @@ -59,6 +59,8 @@ export function apply(ctx: Context, config: Config = Config({})): void { const instance = handle.create() const store: typeof handle = { ...handle, create: () => instance } const targetSource = createSnapshotStore(null) + // Instance id of the shared button whose activation opened the current popup. + const opener = createSnapshotStore(null) let sequence = 0 let active: { resolution: MenuResolution; unsubscribe: () => void; target: ContextMenuTarget; point: MenuPoint } | undefined let closeTimer: ReturnType | undefined @@ -83,7 +85,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { const cancelOpening = (): void => { clearTimeout(openingTimer); finishOpening?.(); finishOpening = undefined active?.unsubscribe(); active?.resolution.dispose(); active = undefined - targetSource.set(null) + targetSource.set(null); opener.set(null) } const dismiss = (replacing: boolean): void => { cancelOpening() @@ -96,44 +98,48 @@ export function apply(ctx: Context, config: Config = Config({})): void { const contextMenus: ContextMenus = { active: targetSource, registeredKinds: registry.registeredKinds, register: input => registry.register(input), resolve: target => registry.resolve(target), notify, close, - open: (target, point) => { + open: (target, point, options) => { const previous = active - if (previous !== undefined + // A repeated gesture closes; shared buttons toggle by their own instance instead. + if (options === undefined && previous !== undefined && Math.hypot(previous.point.x - point.x, previous.point.y - point.y) <= 4 && menuTargetKey(previous.target) === menuTargetKey(target)) { close(); return } - const focused = document.activeElement - if (!(focused instanceof Element) || focused.closest('[data-context-menu]') === null) { - returnFocus = focused instanceof HTMLElement ? focused : null - } - dismiss(true) - const resolution = registry.resolve(target) - targetSource.set(target) - let displayed = false - const publish = (): void => { if (displayed) instance.actions.items(resolution.source.getSnapshot()) } - const occurrence = { resolution, unsubscribe: resolution.source.subscribe(publish), target, point } - active = occurrence - const delay = new Promise((resolve) => { - finishOpening = resolve; openingTimer = setTimeout(resolve, MENU_TRANSITION_MS.prepare) - }) - void Promise.all([resolution.ready, delay]).then(() => { - if (active !== occurrence) return - clearTimeout(closeTimer) - instance.actions.open(target, point) - displayed = true - publish() - }) + show(target, point) + if (options !== undefined) opener.set(options.button) }, } + function show(target: ContextMenuTarget, point: MenuPoint): void { + const focused = document.activeElement + if (!(focused instanceof Element) || focused.closest('[data-context-menu]') === null) { + returnFocus = focused instanceof HTMLElement ? focused : null + } + dismiss(true) + const resolution = registry.resolve(target) + targetSource.set(target) + let displayed = false + const publish = (): void => { if (displayed) instance.actions.items(resolution.source.getSnapshot()) } + const occurrence = { resolution, unsubscribe: resolution.source.subscribe(publish), target, point } + active = occurrence + const delay = new Promise((resolve) => { + finishOpening = resolve; openingTimer = setTimeout(resolve, MENU_TRANSITION_MS.prepare) + }) + void Promise.all([resolution.ready, delay]).then(() => { + if (active !== occurrence) return + clearTimeout(closeTimer) + instance.actions.open(target, point) + displayed = true + publish() + }) + } ctx.provide('contextMenus', contextMenus) ctx.slots.registerFactory({ name: 'contextMenu.button', scope: 'root', inject: () => ({ - hooks: { active: targetSource, registeredKinds: registry.registeredKinds }, - toggle: (target: ContextMenuTarget, point: MenuPoint) => { + hooks: { opener, registeredKinds: registry.registeredKinds }, + toggle: (button: string, target: ContextMenuTarget, point: MenuPoint) => { if (disposed) return - const current = targetSource.getSnapshot() - if (current !== null && buttonTargetKey(current) === buttonTargetKey(target)) close() - else contextMenus.open(target, point) + if (opener.getSnapshot() === button) { close(); return } + contextMenus.open(target, point, { button }) }, }), }, ContextMenuButton) @@ -170,7 +176,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { inject: () => ({ hooks: { catalog: ctx.shortcuts.catalog }, close, returnFocus: () => returnFocus, select: (id: string) => { const selected = active?.resolution active?.unsubscribe(); active = undefined - targetSource.set(null); dismiss(false) + targetSource.set(null); opener.set(null); dismiss(false) selected?.select(id); selected?.dispose() } }), }, MenuOverlay)) } diff --git a/packages/client/ui-context-menu/src/client/target-key.ts b/packages/client/ui-context-menu/src/client/target-key.ts index 708c0ed971..27d982388d 100644 --- a/packages/client/ui-context-menu/src/client/target-key.ts +++ b/packages/client/ui-context-menu/src/client/target-key.ts @@ -1,4 +1,4 @@ -/** Target equality for pointer gestures and entity-owned menu buttons. */ +/** Target equality for repeated pointer and keyboard gestures. */ import type { ContextMenuTarget } from './types.ts' /** @@ -12,18 +12,3 @@ export function menuTargetKey(target: ContextMenuTarget): string { return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) }) } - -/** - * Identify entity buttons independently of mutable display metadata. - * @param target - Captured menu target. - * @returns Kind and entity ID for Session and Workspace buttons; canonical JSON for other target families. - */ -export function buttonTargetKey(target: ContextMenuTarget): string { - switch (target.kind) { - case 'session': - case 'sessionHeader': return JSON.stringify([target.kind, target.value.sessionId]) - case 'workspace': return JSON.stringify([target.kind, target.value.workspaceId]) - // Extension targets retain canonical JSON equality unless they use a built-in entity kind. - default: return menuTargetKey(target) - } -} diff --git a/packages/client/ui-context-menu/src/client/types.ts b/packages/client/ui-context-menu/src/client/types.ts index d195c841de..f21dc165ff 100644 --- a/packages/client/ui-context-menu/src/client/types.ts +++ b/packages/client/ui-context-menu/src/client/types.ts @@ -2,7 +2,6 @@ import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { PaneId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' import type { HTMLAttributes } from 'react' @@ -28,9 +27,6 @@ export type UseContextMenu = () => ContextMenuBindings /** External plugins extend this map with their own target families. */ export interface ContextMenuTargetMap { session: { readonly sessionId: SessionId; readonly displayTitle: string } - workspace: { readonly workspaceId: WorkspaceId; readonly path: string; readonly displayTitle: string } - /** Header utilities, independent of Session-row and title context actions. */ - sessionHeader: { readonly sessionId: SessionId } file: { readonly sessionId: SessionId; readonly path: string; readonly line?: number; readonly paneId?: PaneId } directory: { readonly sessionId: SessionId; readonly path: string } url: { readonly sessionId: SessionId; readonly url: string } @@ -131,11 +127,12 @@ export interface ContextMenus { */ register(contribution: MenuContribution): () => void /** - * Open the single shared popup. + * Open the single shared popup; a repeated gesture on the same target within 4 CSS pixels closes it instead. * @param target - captured object. * @param point - viewport position. + * @param options - set by the shared `contextMenu.button`: its instance id replaces the repeated-gesture check. */ - open(target: ContextMenuTarget, point: MenuPoint): void + open(target: ContextMenuTarget, point: MenuPoint, options?: { readonly button: string }): void /** * Resolve an embedded menu without opening the shared popup. * @param target - captured object. diff --git a/packages/client/ui-context-menu/tests/button.client.spec.tsx b/packages/client/ui-context-menu/tests/button.client.spec.tsx index e9e848f7bf..01c4a5d7b5 100644 --- a/packages/client/ui-context-menu/tests/button.client.spec.tsx +++ b/packages/client/ui-context-menu/tests/button.client.spec.tsx @@ -6,13 +6,19 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SessionId } from '@deepseek-ai/dsh-session/types' import type { Context } from '@deepseek-ai/cordis' -import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { useState } from 'react' import { apply, inject } from '../src/client/index.ts' import type { ContextMenuTarget, MenuAction } from '../src/client/types.ts' import { isContextMenuTrigger } from '../src/client/external-trigger.ts' -const target = (id: string): ContextMenuTarget => ({ kind: 'sessionHeader', value: { sessionId: SessionId(id) } }) +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { + header: { readonly sessionId: SessionId } + entity: { readonly id: string; readonly title: string } + } +} + +const target = (id: string): ContextMenuTarget => ({ kind: 'header', value: { sessionId: SessionId(id) } }) afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() }) it('shows late registered kinds without preparing actions and withdraws them with the contributing fiber', async () => { @@ -37,7 +43,7 @@ it('shows late registered kinds without preparing actions and withdraws them wit expect(view.getByRole('button', { name: 'Row menu' })).toBeTruthy() expect(view.queryByRole('button', { name: 'Header menu' })).toBeNull() const contribution = await runtime.mount({ inject: ['contextMenus'], apply: (ctx: Context) => { ctx.effect(() => ctx.contextMenus.register({ - id: 'header', target: 'sessionHeader', order: 0, resolve, + id: 'header', target: 'header', order: 0, resolve, })) } }) expect(view.getByRole('button', { name: 'Header menu' }).getAttribute('aria-haspopup')).toBe('menu') expect(resolve).not.toHaveBeenCalled() @@ -72,7 +78,7 @@ it('toggles moved buttons by target identity, replaces other targets, and preser }) await runtime.mount({ inject, apply }) await runtime.mount({ inject: ['contextMenus'], apply: (ctx: Context) => { ctx.effect(() => ctx.contextMenus.register({ - id: 'header', target: 'sessionHeader', order: 0, resolve: () => [{ id: 'open', label: 'Open' }], + id: 'header', target: 'header', order: 0, resolve: () => [{ id: 'open', label: 'Open' }], })) } }) const view = runtime.renderRoot() const first = view.getByRole('button', { name: 'First menu' }) @@ -88,9 +94,10 @@ it('toggles moved buttons by target identity, replaces other targets, and preser expect(clicked).not.toHaveBeenCalled() expect(pressed).not.toHaveBeenCalled() expect(focus).toHaveBeenCalledWith({ preventScroll: true }) - expect(open).toHaveBeenLastCalledWith(target('one'), { x: 20, y: 58 }) + expect(open.mock.lastCall?.slice(0, 2)).toEqual([target('one'), { x: 20, y: 58 }]) + expect(typeof open.mock.lastCall?.[2]?.button).toBe('string') expect(first.getAttribute('aria-expanded')).toBe('true') - expect(same.getAttribute('aria-expanded')).toBe('true') + expect(same.getAttribute('aria-expanded')).toBe('false') expect(second.getAttribute('aria-expanded')).toBe('false') await act(() => vi.advanceTimersByTimeAsync(40)) expect(view.getByRole('menuitem', { name: 'Open' })).toBeTruthy() @@ -103,8 +110,11 @@ it('toggles moved buttons by target identity, replaces other targets, and preser expect(open).toHaveBeenCalledTimes(1) fireEvent.click(first) fireEvent.click(same) + expect(runtime.ctx.contextMenus.active.getSnapshot()).toEqual(target('one')) + expect(first.getAttribute('aria-expanded')).toBe('false') + expect(same.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(same) expect(runtime.ctx.contextMenus.active.getSnapshot()).toBeNull() - expect(open).toHaveBeenCalledTimes(2) fireEvent.click(first) fireEvent.pointerDown(second) fireEvent.click(second) @@ -125,14 +135,12 @@ it('toggles moved buttons by target identity, replaces other targets, and preser expect(runtime.ctx.contextMenus.active.getSnapshot()).toBeNull() }) -it.each(['session', 'workspace'] as const)('keeps an open %s button expanded after its owner updates display metadata', async (kind) => { +it('keeps an open button expanded after its owner updates display metadata in the target', async () => { const runtime = await SlotTestRuntime.create() onTestFinished(() => runtime.dispose()) runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) runtime.ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) - const entity = (title: string): ContextMenuTarget => kind === 'session' - ? { kind, value: { sessionId: SessionId('same'), displayTitle: title } } - : { kind, value: { workspaceId: 'same' as WorkspaceId, displayTitle: title, path: `/${title}` } } + const entity = (title: string): ContextMenuTarget => ({ kind: 'entity', value: { id: 'same', title } }) let update!: (target: ContextMenuTarget) => void await runtime.root.declare({}, ({ renderFactorySlot }) => { const [current, setCurrent] = useState(entity('before')) @@ -141,7 +149,7 @@ it.each(['session', 'workspace'] as const)('keeps an open %s button expanded aft }) await runtime.mount({ inject, apply }) await runtime.mount({ inject: ['contextMenus'], apply: (ctx: Context) => { ctx.effect(() => ctx.contextMenus.register({ - id: 'entity', target: kind, order: 0, resolve: () => [], + id: 'entity', target: 'entity', order: 0, resolve: () => [], })) } }) const view = runtime.renderRoot(), button = view.getByRole('button', { name: 'Entity menu' }) fireEvent.click(button) diff --git a/packages/client/ui-context-menu/tests/plugin.client.spec.ts b/packages/client/ui-context-menu/tests/plugin.client.spec.ts index c41f007450..4d2ab784ba 100644 --- a/packages/client/ui-context-menu/tests/plugin.client.spec.ts +++ b/packages/client/ui-context-menu/tests/plugin.client.spec.ts @@ -201,6 +201,6 @@ it('leaves unbound keyboard gestures native', async () => { document.dispatchEvent(event) expect(event.defaultPrevented).toBe(false) } finally { await fiber.dispose() } - button.toggle({ kind: 'pluginDocument', value: { id: 'retired' } }, { x: 1, y: 2 }) - expect(button.hooks.active.getSnapshot()).toBeNull() + button.toggle('retired-button', { kind: 'pluginDocument', value: { id: 'retired' } }, { x: 1, y: 2 }) + expect(button.hooks.opener.getSnapshot()).toBeNull() }) diff --git a/packages/client/ui-context-menu/tests/registry.client.spec.ts b/packages/client/ui-context-menu/tests/registry.client.spec.ts index 063034f330..20f77e808b 100644 --- a/packages/client/ui-context-menu/tests/registry.client.spec.ts +++ b/packages/client/ui-context-menu/tests/registry.client.spec.ts @@ -4,7 +4,7 @@ import { MenuRegistry } from '../src/client/registry.ts' import type { MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client' declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { - interface ContextMenuTargetMap { extension: { readonly id: string } } + interface ContextMenuTargetMap { extension: { readonly id: string }; registryHeader: { readonly id: string } } } const target = { kind: 'extension' as const, value: { id: 'clicked' } } @@ -22,22 +22,22 @@ describe('menu contribution lifecycle', () => { const changed = vi.fn() const detach = source.subscribe(changed) expect(source.getSnapshot()).toEqual([]) - const first = registry.register({ id: 'first', target: 'sessionHeader', order: 0, resolve }) - expect(source.getSnapshot()).toEqual(['sessionHeader']) + const first = registry.register({ id: 'first', target: 'registryHeader', order: 0, resolve }) + expect(source.getSnapshot()).toEqual(['registryHeader']) const snapshot = source.getSnapshot() - const second = registry.register({ id: 'second', target: 'sessionHeader', order: 0, resolve }) + const second = registry.register({ id: 'second', target: 'registryHeader', order: 0, resolve }) expect(source.getSnapshot()).toBe(snapshot) first(); first() expect(source.getSnapshot()).toBe(snapshot) const other = registry.register({ id: 'other', target: 'session', order: 0, resolve }) - expect(source.getSnapshot()).toEqual(['session', 'sessionHeader']) + expect(source.getSnapshot()).toEqual(['registryHeader', 'session']) second() expect(source.getSnapshot()).toEqual(['session']) other() expect(source.getSnapshot()).toEqual([]) - registry.register({ id: 'replacement', target: 'sessionHeader', order: 0, resolve }) + registry.register({ id: 'replacement', target: 'registryHeader', order: 0, resolve }) expect(registry.registeredKinds).toBe(source) - expect(source.getSnapshot()).toEqual(['sessionHeader']) + expect(source.getSnapshot()).toEqual(['registryHeader']) registry.dispose() expect(source.getSnapshot()).toEqual([]) expect(changed).toHaveBeenCalledTimes(6) diff --git a/packages/client/ui-context-menu/tests/target-key.client.spec.ts b/packages/client/ui-context-menu/tests/target-key.client.spec.ts index fbd8857031..0f6830f205 100644 --- a/packages/client/ui-context-menu/tests/target-key.client.spec.ts +++ b/packages/client/ui-context-menu/tests/target-key.client.spec.ts @@ -1,7 +1,5 @@ import { expect, it } from 'vitest' -import { buttonTargetKey, menuTargetKey } from '../src/client/target-key.ts' -import { SessionId } from '@deepseek-ai/dsh-session/types' -import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import { menuTargetKey } from '../src/client/target-key.ts' import type { ContextMenuTarget } from '../src/client/types.ts' declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { @@ -12,17 +10,5 @@ it('compares JSON targets independently of object key insertion order while pres const first: ContextMenuTarget = { kind: 'nested', value: { id: 'a', data: { a: 1, b: ['x', null] } } } const second: ContextMenuTarget = { value: { data: { b: ['x', null], a: 1 }, id: 'a' }, kind: 'nested' } expect(menuTargetKey(first)).toBe(menuTargetKey(second)) - expect(buttonTargetKey(first)).toBe(menuTargetKey(second)) expect(menuTargetKey(first)).not.toBe(menuTargetKey({ kind: 'nested', value: { id: 'a', data: { a: 1, b: [null, 'x'] } } })) }) - -it('keeps entity button identity through display and path changes without changing gesture equality', () => { - const sessionId = SessionId('one'), workspaceId = 'one' as WorkspaceId - const before: ContextMenuTarget = { kind: 'session', value: { sessionId, displayTitle: 'Before' } } - const after: ContextMenuTarget = { kind: 'session', value: { sessionId, displayTitle: 'After' } } - expect(buttonTargetKey(before)).toBe(buttonTargetKey(after)) - expect(menuTargetKey(before)).not.toBe(menuTargetKey(after)) - expect(buttonTargetKey(before)).not.toBe(buttonTargetKey({ kind: 'sessionHeader', value: { sessionId } })) - expect(buttonTargetKey({ kind: 'workspace', value: { workspaceId, path: '/before', displayTitle: 'Before' } })) - .toBe(buttonTargetKey({ kind: 'workspace', value: { workspaceId, path: '/after', displayTitle: 'After' } })) -}) diff --git a/packages/client/ui-context-menu/tsconfig.json b/packages/client/ui-context-menu/tsconfig.json index fc66089c0b..e12f61a4c8 100644 --- a/packages/client/ui-context-menu/tsconfig.json +++ b/packages/client/ui-context-menu/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../core/session" }, - { - "path": "../../workspace/workspace" - }, { "path": "../locale/tsconfig.client.json" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 5c5cd92aac..5438d033ab 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: cc4dafd1aae02468 zh: be6b67724b7ac4a3 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 83b618bc01845b98 - zh: 434d6c08e7c3ed51 + en: baac821d6f58a370 + zh: b4efe8146e2958b0 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: 59cd63057dfb6eeb zh: b2e2c86e810b7bb8 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 1087ce9f7e..db63e0cca5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -102,7 +102,7 @@ The package occupies the root-scoped `main` key `conversation`. Its `main.conver A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps the blank-Session 40px row on every platform, so the Hero input card sits at the same height before and after a Workspace is bound; on macOS desktop that row is also the window drag row. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. When the title and actions do not fit, the title area clips them and fades its trailing edge before the utilities, which stay fully visible and clickable; overlays in the title and actions slots portal to stay outside that clip. -The Session Header owns its trailing More button and renders the shared `contextMenu.button` factory after utility contributions. Its `sessionHeader` target is independent of Session-row context actions. The button remains available without the export plugin while another Header-menu provider is registered; it hides when none are registered. Providers determine their own items and availability when the menu opens. [Context menus](../ui-context-menu/README.md) own button toggling and accessibility; [Session log export](../../session-query/session-log-export/README.md) owns export actions and global download dialogs. +The Session Header owns its trailing More button and renders the shared `contextMenu.button` factory after utility contributions. This package declares the `sessionHeader` context-menu target `{ sessionId }`, independent of Session-row context actions; Header-menu providers import its client types. The button remains available without the export plugin while another Header-menu provider is registered; it hides when none are registered. Providers determine their own items and availability when the menu opens. [Context menus](../ui-context-menu/README.md) own button toggling and accessibility; [Session log export](../../session-query/session-log-export/README.md) owns export actions and global download dialogs. View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f9db8f055e..0d107cb8c5 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -102,7 +102,7 @@ root 作用域的 `conversation.session.header.views.actions` list 有注册项 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在所有平台都保留与 blank Session 相同的 40px 行,因此绑定 Workspace 前后 Hero 输入卡片的高度位置不变;在 macOS 桌面上,这一行同时是窗口拖拽行。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。标题与 actions 放不下时,标题区域会裁剪它们,并在 utilities 之前渐隐其末端;utilities 始终完整可见且可点击;标题与 actions slot 中的浮层通过 portal 渲染,不受该裁剪影响。 -Session Header 持有末尾的更多按钮,在 utility 贡献之后渲染共享 `contextMenu.button` factory。其 `sessionHeader` 目标独立于会话行右键操作。只要仍注册有其他 Header 菜单提供者,未挂载导出插件时按钮也可用;没有任何提供者时隐藏。各提供者在菜单打开时决定自己的条目及可用性。[右键菜单](../ui-context-menu/README.zh.md)负责按钮开关与可访问性;[会话日志导出](../../session-query/session-log-export/README.zh.md)负责导出操作与全局下载弹窗。 +Session Header 持有末尾的更多按钮,在 utility 贡献之后渲染共享 `contextMenu.button` factory。本包声明 `sessionHeader` 右键菜单目标 `{ sessionId }`,独立于会话行右键操作;Header 菜单提供者导入本包的 client 类型。只要仍注册有其他 Header 菜单提供者,未挂载导出插件时按钮也可用;没有任何提供者时隐藏。各提供者在菜单打开时决定自己的条目及可用性。[右键菜单](../ui-context-menu/README.zh.md)负责按钮开关与可访问性;[会话日志导出](../../session-query/session-log-export/README.zh.md)负责导出操作与全局下载弹窗。 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 79ef4ab85d..a2d255b3fc 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -25,6 +25,13 @@ import type { ConversationSnapshot } from './snapshot.ts' import type { ViewTab } from './views.ts' import type { UserTextDocumentInput, UserTextEvaluator, UserTextId, UserTextNode } from './user-text.ts' +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { + /** Session Header More menu, independent of Session-row and title context actions. */ + sessionHeader: { readonly sessionId: SessionId } + } +} + /** Browser-owned draft attachment that has not crossed the durable Host boundary. */ export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index af2db09abc..7fdf13db51 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -4,7 +4,7 @@ import { createPortal } from 'react-dom' import clsx from 'clsx' import { useAnchoredPosition } from './useAnchoredPosition.ts' import { useMenuSubmenu, menuHoverAllowed } from './useMenuSubmenu.ts' -import { MenuRow, MenuDecoration, menuIconColumns } from './MenuRow.tsx' +import { MenuRow, menuIconColumns, renderMenuDecoration } from './MenuRow.tsx' import { SubmenuPanel } from './SubmenuPanel.tsx' import { usePointerGrace } from './pointer-grace.ts' import { isBehindModal } from './useModalLayer.ts' @@ -443,7 +443,7 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel const itemColumns = menuIconColumns(items, contextual) const footerColumns = menuIconColumns(footer ?? [], contextual) const renderEntry = (entry: MenuEntry, columns: ReadonlySet) => { - if ('type' in entry) return + if ('type' in entry) return renderMenuDecoration(entry) const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 return = { [P in K]?: T[P] | undefined } + +/** Package-internal row props: the `MenuItemButton` row fields plus data- and child-row presentation. */ type MenuRowProps = Omit, 'children'> & Pick - & { [K in 'icon' | 'shortcut' | 'current' | 'disabledReason' | 'danger' | 'separatorBefore']?: MenuItemButtonProps[K] | undefined } + & OptionalProps & { + /** Whether this data row is the menu's current selection. */ selected?: boolean + /** Selection marker: a trailing check, or the row fill. */ selection?: 'check' | 'fill' + /** Keep an empty icon column so grouped rows stay aligned. */ reserveIcon?: boolean + /** Hide the icon from assistive technology; component rows keep it exposed. */ decorativeIcon?: boolean + /** Pointer handlers on the row wrapper, used by submenu parents. */ wrapperProps?: HTMLAttributes | undefined } @@ -70,11 +79,11 @@ export function menuIconColumns(entries: readonly MenuEntry[], contextual: boole return aligned } -/** Render a non-interactive entry without joining the keyboard walk. - * @param props - heading or separator data. - * @returns the corresponding menu decoration. +/** Render a separator or group heading outside the keyboard walk. + * @param entry - non-interactive menu entry. + * @returns the corresponding element. */ -export function MenuDecoration({ entry }: { entry: Extract }): ReactNode { - return entry.type === 'separator' ?
- :
{entry.text}
+export function renderMenuDecoration(entry: MenuSeparator | MenuLabel): ReactNode { + return entry.type === 'separator' ?
+ :
{entry.text}
} diff --git a/packages/client/ui-primitives/src/SubmenuPanel.tsx b/packages/client/ui-primitives/src/SubmenuPanel.tsx index f9363b6c84..cfc1dfd79c 100644 --- a/packages/client/ui-primitives/src/SubmenuPanel.tsx +++ b/packages/client/ui-primitives/src/SubmenuPanel.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { menuHoverAllowed, type useMenuSubmenu } from './useMenuSubmenu.ts' -import { MenuDecoration, menuIconColumns, MenuRow } from './MenuRow.tsx' +import { menuIconColumns, MenuRow, renderMenuDecoration } from './MenuRow.tsx' import { IconChevronLeftOutlineRegular } from './icons/index.tsx' import { MenuSurface } from './MenuSurface.tsx' import css from './Menu.module.css' @@ -38,7 +38,7 @@ export function SubmenuPanel({ entry, submenu, compact, dense, contextual, backL
}
- {entry.submenu.map(sub => 'type' in sub ? + {entry.submenu.map(sub => 'type' in sub ? renderMenuDecoration(sub) : { onSelect?.(sub.id) }}>{sub.label})}
diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 104ad6d417..1677fc7b38 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -33,8 +33,8 @@ en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 /deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers: - en: cb2dea9fe7e3b060 - zh: f8d65c3920ff2cff + en: 668ee06286b227b6 + zh: aafdb03cde1b93c9 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: en: 8f170900a4096408 zh: 85332e80adf39403 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 9e4eb25d25..ce79bd8287 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -77,7 +77,7 @@ Session rows render the runtime's live `pendingInteraction` classification: appr Grouped and flat Session rows show a clock mark when the Session has active scheduled tasks. It is an occupant of the row's `sidebar.session.row.leading` seat, so it renders only on a row whose primary status is idle and never beside the row's own status dot; an archived row keeps that cell blank, and a search result has no leading seat and shows no mark. It is not a button, has no tab stop, and a press on its area does not open the row. The mark's own read and the meaning of its active-task condition belong to [ui-schedule](../ui-schedule/README.md). -Workspace headers share one `workspace` context-menu target between right-click and the ellipsis. Rename and Delete use the existing confirmation dialogs and recheck Workspace existence and access before opening; native directory reveal is contributed by [ui-open-in-app](../ui-open-in-app/README.md). The menu remains available for native and plugin actions when mutation permissions are absent. Opening it changes neither expansion nor Session selection. +Workspace headers share one `workspace` context-menu target, declared by this package, between right-click and the ellipsis. Rename and Delete use the existing confirmation dialogs and recheck Workspace existence and access before opening; native directory reveal is contributed by [ui-open-in-app](../ui-open-in-app/README.md). The menu remains available for native and plugin actions when mutation permissions are absent. Opening it changes neither expansion nor Session selection. Session rows share their existing action contributions between right-click and the ellipsis trigger through [ui-context-menu](../ui-context-menu/README.md). The captured Session stays the target without changing selection; `sidebar.workspaces.session.menu.item` remains the component extension position. The shipped groups are Pin, Rename, Fork; Copy session ID and the optional native directory reveal; then Archive, with separators before Copy session ID and Archive. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 0aba426006..11d48ba4dd 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -77,7 +77,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** 当成组的与平铺的 Session 行所对应的 Session 存在活动定时任务时,该行显示时钟标记。它是行首 `sidebar.session.row.leading` seat 的占用方,因此仅在该行主状态为 idle 时渲染,绝不与行自身的状态点同时出现;归档行该格同样留空,搜索结果没有该 seat,也不显示标记。它不是按钮,没有 Tab stop,点击其所在区域不会打开整行。标记自身的读取及其「活动」条件的确切含义由 [ui-schedule](../ui-schedule/README.zh.md) 负责。 -工作区标题的右键和省略号共用 `workspace` 右键操作对象。重命名和删除沿用现有确认对话框,打开前再次检查工作区是否存在及操作权限;本机目录显示由 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供。没有修改权限时仍可使用本机和插件菜单操作。打开菜单不改变展开状态或选中的会话。 +工作区标题的右键和省略号共用本包声明的 `workspace` 右键操作对象。重命名和删除沿用现有确认对话框,打开前再次检查工作区是否存在及操作权限;本机目录显示由 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供。没有修改权限时仍可使用本机和插件菜单操作。打开菜单不改变展开状态或选中的会话。 会话行通过 [ui-context-menu](../ui-context-menu/README.zh.md) 在右键与省略号按钮间共享现有操作贡献。操作对象固定为被点击的会话,不改变选中状态;`sidebar.workspaces.session.menu.item` 仍是组件扩展位置。内置分组依次为置顶、重命名、分叉;复制 Session ID 和可选的本机目录显示;归档,复制 Session ID 与归档之前各有一条分割线。 diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 4f3450e3ff..55a98e1cc8 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -53,6 +53,13 @@ import type { StartSessionOptions } from '../navigation.ts' import type {} from './toast.ts' import type { createWorkspaceViewStore } from '../stores.ts' +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { + /** Registered Workspace header; path and title belong to the clicked row. */ + workspace: { readonly workspaceId: WorkspaceId; readonly path: string; readonly displayTitle: string } + } +} + /** One request for the existing Workspace rename or delete dialog. */ export type WorkspaceActionRequest = { readonly action: 'rename' | 'delete' 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 4e3572792a..3bc4942539 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -811,7 +811,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:283', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:290', }, { key: 'conversation.composer.bar', @@ -854,7 +854,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:327', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:334', }, { key: 'conversation.composer.dock', @@ -915,7 +915,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:306', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:313', }, { key: 'conversation.header', @@ -954,7 +954,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header\', () => ctx.slots.register(\n { name: \'conversation.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:230', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:237', }, { key: 'conversation.header.leading', @@ -986,7 +986,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header.leading\', () => ctx.slots.register(\n { name: \'conversation.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:259', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:266', }, { key: 'conversation.hero.agentPreset', @@ -1027,7 +1027,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:289', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:296', }, { key: 'conversation.hero.brand.mark', @@ -1059,7 +1059,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:287', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:294', }, { key: 'conversation.hero.options', @@ -1119,7 +1119,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.options\', () => ctx.slots.register(\n { name: \'conversation.hero.options\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:291', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:298', }, { key: 'conversation.hero.workspace', @@ -1155,7 +1155,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:285', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:292', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -1190,7 +1190,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:130', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:137', }, { key: 'conversation.input.activity', @@ -1235,7 +1235,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.activity\', () => ctx.slots.register(\n { name: \'conversation.input.activity\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:322', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:329', }, { key: 'conversation.input.attachments', @@ -1280,7 +1280,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:332', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:339', }, { key: 'conversation.input.dock', @@ -1349,7 +1349,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:296', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:303', }, { key: 'conversation.input.left', @@ -1407,7 +1407,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:311', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:318', }, { key: 'conversation.input.model', @@ -1451,7 +1451,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:353', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:360', }, { key: 'conversation.input.model.label', @@ -1617,7 +1617,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:301', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:308', }, { key: 'conversation.input.permission', @@ -1661,7 +1661,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:346', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:353', }, { key: 'conversation.input.plan', @@ -1704,7 +1704,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:341', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:348', }, { key: 'conversation.input.right', @@ -1762,7 +1762,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:316', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:323', }, { key: 'conversation.plan-review.actions', @@ -1865,7 +1865,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:224', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:231', }, { key: 'conversation.session.header', @@ -1906,7 +1906,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:232', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:239', }, { key: 'conversation.session.header.actions', @@ -1970,7 +1970,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:247', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:254', }, { key: 'conversation.session.header.actions.subagent.footer', @@ -2073,7 +2073,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:277', }, { key: 'conversation.session.header.lineage', @@ -2118,7 +2118,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:241', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:248', }, { key: 'conversation.session.header.utilities', @@ -2182,7 +2182,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:253', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:260', }, { key: 'conversation.session.header.views.actions', @@ -2237,7 +2237,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.views.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.views.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:283', }, { key: 'conversation.trajectory.images', @@ -2341,7 +2341,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.user.text.node\', () => ctx.slots.register(\n { name: \'conversation.user.text.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:221', }, { key: 'conversation.view', @@ -2407,7 +2407,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:285', }, { key: 'deliverables.file.actions', @@ -2616,7 +2616,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:222', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:229', }, { key: 'plugins.add.actions', @@ -4986,7 +4986,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.session.row.hover\', () => ctx.slots.register(\n { name: \'sidebar.session.row.hover\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:156', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:163', }, { key: 'sidebar.session.row.leading', @@ -5042,7 +5042,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.session.row.leading\', () => ctx.slots.register(\n { name: \'sidebar.session.row.leading\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:142', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:149', }, { key: 'sidebar.session.row.trailing', @@ -5097,7 +5097,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.session.row.trailing\', () => ctx.slots.register(\n { name: \'sidebar.session.row.trailing\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:151', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:158', }, { key: 'sidebar.settings', @@ -5232,7 +5232,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:132', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:139', }, { key: 'sidebar.workspaces.session.menu.item', @@ -5285,7 +5285,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n const copyLabel = \'Copy session title\' // Localize in the contributing package.\n ctx.slots.inject(\'sidebar.workspaces.session.menu.item\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.session.menu.item\', id: \'copy-session-title\', order: 500 },\n ({ displayTitle, useMenuOpenState }) => {\n const [, setMenuOpen] = useMenuOpenState()\n return React.createElement(\n \'button\',\n {\n type: \'button\', role: \'menuitem\',\n onClick: () => { setMenuOpen(false); void navigator.clipboard.writeText(displayTitle) },\n },\n copyLabel,\n )\n },\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:186', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:193', }, { key: 'sidebar.workspaces.session.row.action', @@ -5341,7 +5341,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.session.row.action\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.session.row.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:205', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:212', }, { key: 'tool.call.images', diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index e787bbc254..e3fe1398a5 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-commands": "workspace:*", "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-layout": "workspace:*", "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", @@ -76,6 +77,7 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-client-ui-context-menu", + "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer" ], diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index 5efe93cee9..796541d707 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -8,6 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-message-feedback/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { IconCopyOutlineRegular, IconDownloadOutlineRegular, IconPaperPlaneOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { SessionLogDownloadController } from './controller.ts' import { SessionMarkdownCopyController } from './copy-controller.ts' diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json index e028916650..f4d62b9943 100644 --- a/packages/session-query/session-log-export/tsconfig.client.json +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -20,6 +20,7 @@ { "path": "../../client/store" }, { "path": "../../client/ui-commands" }, { "path": "../../client/ui-context-menu" }, + { "path": "../../client/ui-conversation/tsconfig.client.json" }, { "path": "../../client/ui-layout" }, { "path": "../../client/ui-message-feedback" }, { "path": "../../client/ui-primitives" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 922f371c21..a1b958c8cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4156,9 +4156,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session - '@deepseek-ai/dsh-workspace': - specifier: workspace:* - version: link:../../workspace/workspace '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -14283,6 +14280,9 @@ importers: '@deepseek-ai/dsh-client-ui-context-menu': specifier: workspace:* version: link:../../client/ui-context-menu + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:* + version: link:../../client/ui-conversation '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:* version: link:../../client/ui-layout From 001bc22978a330874b57fb233efbdcb864e21a09 Mon Sep 17 00:00:00 2001 From: winewill Date: Wed, 4 Nov 2026 14:34:45 +0800 Subject: [PATCH 6/9] feat(desktop): configure staged rollout during publication --- .github/workflows/desktop-publish.yml | 7 +++++++ apps/desktop/publishing/ci/README.i18n.yaml | 4 ++-- apps/desktop/publishing/ci/README.md | 2 ++ apps/desktop/publishing/ci/README.zh.md | 2 ++ .../scripts/desktop-publication-summary.ts | 6 ++++++ apps/desktop/scripts/desktop-publish-record.ts | 12 +++++++++--- apps/desktop/scripts/desktop-upload-plan.ts | 3 +++ apps/desktop/scripts/publish-desktop-ci.ts | 14 ++++++++++---- .../desktop-publication-preflight.spec.ts | 18 +++++++++++++++++- .../tests/desktop-publish-record.spec.ts | 6 ++++-- apps/desktop/tests/desktop-upload-plan.spec.ts | 7 +++++-- .../tests/expected/publication-mac-arm64.md | 1 + .../tests/expected/publication-win-x64.md | 1 + 13 files changed, 69 insertions(+), 14 deletions(-) diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index ff756d1b95..d2e5d2bd3e 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -9,6 +9,11 @@ on: description: '打包运行 ID:填写成功的 Package Desktop run ID;发布环境和 release ID 沿用原包' type: string required: true + staging_percentage: + description: '更新灰度比例:0–100 整数;留空全量,作用于原包所属渠道' + type: string + required: false + default: '' upload_website: description: '同时上传官网安装包并刷新缓存(仅 production);取消后仅执行版本与自动更新发布' type: boolean @@ -66,6 +71,7 @@ jobs: website-destination: ${{ steps.check.outputs.website-destination }} label: ${{ steps.check.outputs.label }} env: + DESKTOP_STAGING_PERCENTAGE: ${{ inputs.staging_percentage }} DESKTOP_PACKAGE_SELECTION: ${{ needs.route.outputs.selection }} DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} @@ -111,6 +117,7 @@ jobs: cancel-in-progress: false timeout-minutes: 90 env: + DESKTOP_STAGING_PERCENTAGE: ${{ inputs.staging_percentage }} DESKTOP_APPROVED_PACKAGE: ${{ needs.prepare.outputs.approval }} DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} DSH_TELEMETRY_DISABLED: '1' diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index 6764ae324f..f470ee5943 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -24,8 +24,8 @@ en: 0f43f2e193e95043 zh: 1c1efb062c06f7b8 /desktop-ci-publication/package-and-publish: - en: 3f58698708671202 - zh: be0ba215a67168d6 + en: e62e9d93a50e6489 + zh: 2491c37e40f4c496 /desktop-ci-publication/recovery-and-records: en: 266afba2adeeadef zh: 063114103585dae8 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 2232ea39c7..c1e405405e 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -68,6 +68,8 @@ Environment approval gates this workflow, not arbitrary code running under the s ## Package and publish +The publication form accepts an optional `staging_percentage` integer from 0 to 100. It applies to the retained package’s channel (nightly or stable), appears in the approval summary, and is included in the published YAML and audit record without modifying retained artifacts. Leave it blank for the packaged default (normally full rollout). Completed publications cannot change this percentage through a retry; same-version feed conflicts remain errors. + For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. An archive-only rerun retains the original build attempt in artifact names, so this early check can miss it; local manifest qualification still rejects debugging builds before publication. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes. Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A stable-channel package requires a stable version, so test/stable needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 99d245bf1a..71868b6a9d 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -68,6 +68,8 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 打包与发布 +发布表单支持可选的 `staging_percentage`,取值为 0 到 100 的整数。该值作用于保留包所属的 nightly 或 stable 渠道,显示在审批摘要中,并写入发布的 YAML 和审计记录,不修改保留产物。留空沿用包内默认值(通常为全量)。已完成的发布不能通过重试修改比例;同版本清单内容冲突仍会报错。 + 发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。仅重跑归档时,artifact 名称保留原构建 attempt,早期检查可能未识别该包;本地清单资格校验仍会在发布前拒绝调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。 选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。stable 包要求稳定版本,因此 test/stable 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 diff --git a/apps/desktop/scripts/desktop-publication-summary.ts b/apps/desktop/scripts/desktop-publication-summary.ts index 49d438aaf2..a9e86575fd 100644 --- a/apps/desktop/scripts/desktop-publication-summary.ts +++ b/apps/desktop/scripts/desktop-publication-summary.ts @@ -1,4 +1,6 @@ /** Render the verified publication plan for GitHub's native deployment reviewers. */ +import { load } from 'js-yaml' +import { z } from 'zod' import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' import { desktopWebsiteInstallerUrl } from './desktop-auto-update-environment.mjs' @@ -48,6 +50,10 @@ export async function desktopPublicationSummary( row('发布环境', code(plan.environment)), row('版本号', code(plan.version)), row('升级通道', code(source.channel)), + ...feeds.map((feed) => { + const metadata = z.object({ stagingPercentage: z.number().optional() }).parse(load(feed.contents!)) + return row('更新灰度比例', metadata.stagingPercentage === undefined ? '全量(未设置)' : `${metadata.stagingPercentage}%`) + }), row('平台', code(plan.target)), ...(plan.environment === 'test' ? [row('测试 release ID', `${code(source.testReleaseId!)}
测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。`)] : []), row('下载地址预览', `${installers.map(file => link(url(file.key))).join('
')}
发布后的目标地址,审批前不保证可下载。`), diff --git a/apps/desktop/scripts/desktop-publish-record.ts b/apps/desktop/scripts/desktop-publish-record.ts index 77ac3ca698..1441781a5f 100644 --- a/apps/desktop/scripts/desktop-publish-record.ts +++ b/apps/desktop/scripts/desktop-publish-record.ts @@ -40,7 +40,7 @@ export interface DesktopPublicationRecord { */ export async function createDesktopPublicationRecord( root: string, - identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string }, + identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string; stagingPercentage?: number }, ): Promise { await mkdir(root, { recursive: true, mode: 0o700 }) const staging = await mkdtemp(join(root, '.initializing-')) @@ -78,6 +78,7 @@ export async function wasDesktopAttemptPublished(root: string, expected: { runId: string attempt: string destination: string + stagingPercentage?: number }): Promise { let entries try { entries = await readdir(root, { withFileTypes: true }) } catch (error) { @@ -93,7 +94,7 @@ export async function wasDesktopAttemptPublished(root: string, expected: { throw error } const parsed = z.object({ operation: z.string(), repository: z.string(), - runId: z.string(), attempt: z.string(), destination: z.string() }).parse(operation) + runId: z.string(), attempt: z.string(), destination: z.string(), stagingPercentage: z.number().optional() }).parse(operation) if (parsed.operation !== 'publish' || parsed.repository !== expected.repository || parsed.runId !== expected.runId || parsed.attempt !== expected.attempt || parsed.destination !== expected.destination) continue let events @@ -103,7 +104,12 @@ export async function wasDesktopAttemptPublished(root: string, expected: { } // Only newline-terminated entries were completely appended before an interruption. for (const line of events.split('\n').slice(0, -1)) { - if (z.object({ type: z.string() }).parse(JSON.parse(line)).type === 'feeds-complete') return true + if (z.object({ type: z.string() }).parse(JSON.parse(line)).type === 'feeds-complete') { + if (parsed.stagingPercentage !== expected.stagingPercentage) { + throw new Error('desktop publish: staging percentage differs from the completed publication') + } + return true + } } } return false diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index e6a8d4c7f0..f07f3e5912 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -61,6 +61,8 @@ export interface DesktopUploadPlan { export interface DesktopUploadPlanOptions { /** Publish only the installer at its fixed download URL, replacing the previous object. */ readonly latest?: boolean + /** Percentage of installations eligible for this publication; absent preserves packaged metadata. */ + readonly stagingPercentage?: number readonly environment?: NodeJS.ProcessEnv readonly repositoryRoot?: string readonly appRoot?: string @@ -272,6 +274,7 @@ export async function createDesktopUploadPlan( const payloadUrl = `${update.origin}/${binaryPrefix}/${updaterInfo.filename}` const published = { ...metadata, + ...(options.stagingPercentage === undefined ? {} : { stagingPercentage: options.stagingPercentage }), files: [{ ...object(metadata.files[0], `${metadataFilename}.files[0]`), url: payloadUrl }], ...(metadata.path === undefined ? {} : { path: payloadUrl }), } diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index f881e0d48a..e3d14abbff 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -28,6 +28,11 @@ const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z * @returns Validated local publication context; no network operation is performed. */ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySignatures = verifyRetainedDesktopSignatures) { + const percentage = env.DESKTOP_STAGING_PERCENTAGE?.trim() || '' + if (percentage !== '' && (!/^\d+$/u.test(percentage) || Number(percentage) > 100)) { + throw new Error('desktop publish: staging percentage must be an integer from 0 to 100') + } + const stagingPercentage = percentage === '' ? undefined : Number(percentage) const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}')) if (route.runnerName !== env.RUNNER_NAME) throw new Error('desktop publish: task was dispatched to a different machine') const root = desktopReleasesRoot(env) @@ -48,12 +53,13 @@ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySi DOWNLOAD_TEST_RELEASE_ID: source.testReleaseId ?? '', DOWNLOAD_TEST_COS_BUCKET: config.bucket } : { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: config.bucket } const plan = await createDesktopUploadPlan(source.target, { artifactsRoot: directory, + stagingPercentage, packagedProductVersion: source.productVersion, environment: { ...environment, DSH_DESKTOP_UPDATE_CHANNEL: source.channel } }) if (plan.version !== source.version || plan.commit !== source.commit || plan.dirty !== false) throw new Error('desktop publish: completion record differs from retained source or has uncommitted changes') await verifySignatures(directory, manifest, config) const approval = createHash('sha256').update(JSON.stringify({ manifest, plan })).digest('hex') if (env.DESKTOP_APPROVED_PACKAGE && env.DESKTOP_APPROVED_PACKAGE !== approval) throw new Error('desktop publish: package or destination changed after approval') - return { route, manifest, config, plan, root, approval } + return { route, manifest, config, plan, root, approval, stagingPercentage } } if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { @@ -62,7 +68,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const mode = process.argv[2] if (process.argv.length !== 3 || (mode !== '--check' && mode !== '--publish')) throw new Error('Usage: publish-desktop-ci.ts --check|--publish') const env = process.env - const { route, manifest, config, plan, root, approval } = await prepareDesktopPublication(env) + const { route, manifest, config, plan, root, approval, stagingPercentage } = await prepareDesktopPublication(env) if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required') if (env.DESKTOP_UPLOAD_WEBSITE === 'true' && route.preset === 'production' && !/^zone-[a-z0-9]+$/u.test(env.DOWNLOAD_PROD_EO_ZONE_ID || '')) { @@ -84,11 +90,11 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi phase = 'channel lock and publication journal acquisition' await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { const published = await wasDesktopAttemptPublished(recordsRoot, - { repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, destination }) + { repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, destination, stagingPercentage }) const journal = await createDesktopPublicationRecord(recordsRoot, { repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, actor: env.GITHUB_TRIGGERING_ACTOR || env.GITHUB_ACTOR || 'unknown', - destination, operation: 'publish', reason: 'Actions publication', + destination, stagingPercentage, operation: 'publish', reason: 'Actions publication', }) if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `record=${journal.directory}\n`) let status: 'complete' | 'failed' | 'published-tag-pending' = 'failed' diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts index 04402925a5..dd6e2e7f41 100644 --- a/apps/desktop/tests/desktop-publication-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -1,3 +1,4 @@ +import { load } from 'js-yaml' import { createHash } from 'node:crypto' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -61,10 +62,25 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu expect(summary).not.toContain('example.com//') expect(summary).not.toContain(root) expect(summary).not.toContain('absent-credentials') + for (const percentage of ['0', '10', '100']) { + const staged = await prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: percentage }, signature) + const stagedFeed = staged.plan.artifacts.find(file => file.channelMetadata)! + expect(load(stagedFeed.contents!)).toMatchObject({ stagingPercentage: Number(percentage) }) + expect(staged.approval).not.toBe(prepared.approval) + expect(await desktopPublicationSummary(staged.manifest.source, staged.plan, staged.approval, false)).toContain(`${percentage}%`) + await expect(prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: percentage, + DESKTOP_APPROVED_PACKAGE: prepared.approval }, signature)).rejects.toThrow(/changed after approval/u) + } + for (const percentage of ['-1', '101', '1.5', 'NaN', 'Infinity', '1e1']) { + await expect(prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: percentage }, signature)) + .rejects.toThrow(/integer from 0 to 100/u) + } + const blank = await prepareDesktopPublication({ ...env, DESKTOP_STAGING_PERCENTAGE: ' ' }, signature) + expect(blank.approval).toBe(prepared.approval) await expect(prepareDesktopPublication({ ...env, DESKTOP_APPROVED_PACKAGE: 'wrong' }, signature)).rejects.toThrow(/changed after approval/u) await expect(prepareDesktopPublication({ ...env, RUNNER_NAME: 'other-machine' }, signature)).rejects.toThrow(/different machine/u) await writeFile(join(retained, `${base}.${extension}`), 'changed') await expect(prepareDesktopPublication(env, signature)).rejects.toThrow(/changed file/u) - expect(signatureChecks).toBe(2) + } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/apps/desktop/tests/desktop-publish-record.spec.ts b/apps/desktop/tests/desktop-publish-record.spec.ts index 87646edbe1..287af3e4a2 100644 --- a/apps/desktop/tests/desktop-publish-record.spec.ts +++ b/apps/desktop/tests/desktop-publish-record.spec.ts @@ -52,10 +52,10 @@ it('persists intent, before/after bytes and terminal state outside the checkout' } finally { await rm(root, { recursive: true, force: true }) } }) -it('recognizes feed completion even when the later tag failed', async () => { +it.each([undefined, 10])('recognizes feed completion with percentage %s even when the later tag failed', async (stagingPercentage) => { const root = await mkdtemp(join(tmpdir(), 'desktop-recovery-')) try { - const identity = { repository: 'owner/repo', runId: '12', attempt: '2', destination: 'bucket/mac' } + const identity = { repository: 'owner/repo', runId: '12', attempt: '2', destination: 'bucket/mac', stagingPercentage } const journal = await createDesktopPublicationRecord(root, { ...identity, actor: 'operator', operation: 'publish', reason: 'publish' }) expect(await wasDesktopAttemptPublished(root, identity)).toBe(false) await journal.record({ type: 'feeds-complete' }) @@ -63,6 +63,8 @@ it('recognizes feed completion even when the later tag failed', async () => { await journal.finish('published-tag-pending') expect(await wasDesktopAttemptPublished(root, identity)).toBe(true) expect(await wasDesktopAttemptPublished(root, { ...identity, attempt: '1' })).toBe(false) + await expect(wasDesktopAttemptPublished(root, { ...identity, stagingPercentage: 20 })) + .rejects.toThrow(/differs from the completed publication/u) } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index 18f3fa18fa..b6f81d69df 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -178,11 +178,14 @@ describe('desktop upload plan', () => { }) it.each(['test', 'production'] as const)('isolates stable-channel packages from nightly in %s', async (environment) => { - const nightly = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'nightly')) - const stable = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'stable')) + const nightly = await createDesktopUploadPlan('win-x64', { ...await fixture('win-x64', '1.2.3', environment, 'nightly'), stagingPercentage: 10 }) + const stable = await createDesktopUploadPlan('win-x64', { ...await fixture('win-x64', '1.2.3', environment, 'stable'), stagingPercentage: 10 }) expect(stable.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['stable.yml']) expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml']) expect(stable.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true) + for (const plan of [nightly, stable]) { + expect(plan.artifacts.find(file => file.channelMetadata)!.contents).toContain('stagingPercentage: 10') + } const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'stable') await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/stable requires a stable version/u) }) diff --git a/apps/desktop/tests/expected/publication-mac-arm64.md b/apps/desktop/tests/expected/publication-mac-arm64.md index 7b376d9d65..f975154887 100644 --- a/apps/desktop/tests/expected/publication-mac-arm64.md +++ b/apps/desktop/tests/expected/publication-mac-arm64.md @@ -8,6 +8,7 @@ | 发布环境 | test | | 版本号 | 1.0.0 | | 升级通道 | nightly | +| 更新灰度比例 | 全量(未设置) | | 平台 | mac-arm64 | | 测试 release ID | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。 | | 下载地址预览 | https://example.com/dsh-desk/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bin/mac-arm64/deepseek-harness-1.0.0-mac-arm64.dmg
发布后的目标地址,审批前不保证可下载。 | diff --git a/apps/desktop/tests/expected/publication-win-x64.md b/apps/desktop/tests/expected/publication-win-x64.md index aa7569c0a2..65684cf866 100644 --- a/apps/desktop/tests/expected/publication-win-x64.md +++ b/apps/desktop/tests/expected/publication-win-x64.md @@ -8,6 +8,7 @@ | 发布环境 | test | | 版本号 | 1.0.0 | | 升级通道 | nightly | +| 更新灰度比例 | 全量(未设置) | | 平台 | win-x64 | | 测试 release ID | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。 | | 下载地址预览 | https://example.com/dsh-desk/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bin/win-x64/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | From e1cd73e4de77bcd0204abace491da80326893b34 Mon Sep 17 00:00:00 2001 From: winewill Date: Wed, 4 Nov 2026 14:35:43 +0800 Subject: [PATCH 7/9] fix(desktop): type omitted rollout percentage explicitly --- apps/desktop/scripts/desktop-publish-record.ts | 4 ++-- apps/desktop/scripts/desktop-upload-plan.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/scripts/desktop-publish-record.ts b/apps/desktop/scripts/desktop-publish-record.ts index 1441781a5f..7ef28cac0c 100644 --- a/apps/desktop/scripts/desktop-publish-record.ts +++ b/apps/desktop/scripts/desktop-publish-record.ts @@ -40,7 +40,7 @@ export interface DesktopPublicationRecord { */ export async function createDesktopPublicationRecord( root: string, - identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string; stagingPercentage?: number }, + identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string; stagingPercentage?: number | undefined }, ): Promise { await mkdir(root, { recursive: true, mode: 0o700 }) const staging = await mkdtemp(join(root, '.initializing-')) @@ -78,7 +78,7 @@ export async function wasDesktopAttemptPublished(root: string, expected: { runId: string attempt: string destination: string - stagingPercentage?: number + stagingPercentage?: number | undefined }): Promise { let entries try { entries = await readdir(root, { withFileTypes: true }) } catch (error) { diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index f07f3e5912..6a9cfd1183 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -62,7 +62,7 @@ export interface DesktopUploadPlanOptions { /** Publish only the installer at its fixed download URL, replacing the previous object. */ readonly latest?: boolean /** Percentage of installations eligible for this publication; absent preserves packaged metadata. */ - readonly stagingPercentage?: number + readonly stagingPercentage?: number | undefined readonly environment?: NodeJS.ProcessEnv readonly repositoryRoot?: string readonly appRoot?: string From 6958fd4e3a6e2faa9fb0dd524047f98e86c18a52 Mon Sep 17 00:00:00 2001 From: winewill Date: Wed, 4 Nov 2026 14:38:16 +0800 Subject: [PATCH 8/9] fix(desktop): default publication rollout to 100 percent --- .github/workflows/desktop-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index d2e5d2bd3e..32efedf65c 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -10,10 +10,10 @@ on: type: string required: true staging_percentage: - description: '更新灰度比例:0–100 整数;留空全量,作用于原包所属渠道' + description: '更新灰度比例:0–100 整数,默认 100;留空沿用包内值,作用于原包所属渠道' type: string required: false - default: '' + default: '100' upload_website: description: '同时上传官网安装包并刷新缓存(仅 production);取消后仅执行版本与自动更新发布' type: boolean From c05d36d6f37aac100c4ef19ad4051d566f1b1854 Mon Sep 17 00:00:00 2001 From: winewill Date: Wed, 4 Nov 2026 15:08:59 +0800 Subject: [PATCH 9/9] test(desktop): update production rollout approval snapshots --- apps/desktop/tests/expected/publication-production-website.md | 1 + apps/desktop/tests/expected/publication-production.md | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/desktop/tests/expected/publication-production-website.md b/apps/desktop/tests/expected/publication-production-website.md index 464650c653..4ba276895c 100644 --- a/apps/desktop/tests/expected/publication-production-website.md +++ b/apps/desktop/tests/expected/publication-production-website.md @@ -8,6 +8,7 @@ | 发布环境 | production | | 版本号 | 1.0.0 | | 升级通道 | stable | +| 更新灰度比例 | 全量(未设置) | | 平台 | win-x64 | | 下载地址预览 | https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | | 升级 YAML | https://download.example.com/dsh-desk/feeds/win-x64/stable.yml
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 | diff --git a/apps/desktop/tests/expected/publication-production.md b/apps/desktop/tests/expected/publication-production.md index a3b93ccb87..716e499d5e 100644 --- a/apps/desktop/tests/expected/publication-production.md +++ b/apps/desktop/tests/expected/publication-production.md @@ -8,6 +8,7 @@ | 发布环境 | production | | 版本号 | 1.0.0 | | 升级通道 | stable | +| 更新灰度比例 | 全量(未设置) | | 平台 | win-x64 | | 下载地址预览 | https://download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe
发布后的目标地址,审批前不保证可下载。 | | 升级 YAML | https://download.example.com/dsh-desk/feeds/win-x64/stable.yml
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 |