feat(client): move native app opening into Session menus

This commit is contained in:
Yif
2026-10-29 20:03:57 +08:00
parent fdd9f9f6d8
commit b8ede96fd8
49 changed files with 1412 additions and 544 deletions
+2 -1
View File
@@ -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)
@@ -2,5 +2,4 @@ Pin session
Rename
Fork session
Copy Session ID
Show in folder
Archive session
@@ -0,0 +1,5 @@
- menu:
- menuitem "Copy Session as Markdown"
- menuitem "Download session log"
- separator
- menuitem "Feedback"
+164
View File
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" rx="3" fill="gray"/></svg>'
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<void> {
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<void> {
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<number>,
): Promise<void> {
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<number>()
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)
}
})
})
+6
View File
@@ -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'])
+1
View File
@@ -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",
@@ -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
@@ -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.
@@ -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 与下载会话日志可用,并确认下载仍显示会话范围的弹窗。
+3 -3
View File
@@ -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:
@@ -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<ReturnType<typeof createMenuStore>>
& PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace<MenuOverlayInjected>
& PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace<MenuOverlayInjected> & 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 <>
<Menu key={occurrence} open={target !== null} anchor={null} portal autoFocus contextual returnFocus={returnFocus}
submenuBackLabel={t('back')}
exiting={closing ? replacing ? 'replace' : 'dismiss' : undefined}
getAnchorRect={() => new DOMRect(point.x, point.y, 0, 0)}
items={menuEntries(items.filter(item => !item.afterContent), catalog, renderSlot)}
@@ -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 },
@@ -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<string, { failed: string }>
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<string, { back: string; failed: string }>
@@ -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(<>
<button onClick={() => { store.actions.open({ kind: 'session', value: { sessionId: SessionId('clicked'), displayTitle: 'Clicked' } }, { x: 20, y: 30 }) }}>More</button>
<MenuOverlay useCatalog={select => select([])} useStore={useStore} actions={store.actions} close={close} select={run}
<MenuOverlay t={key => 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 <MenuItemButton onSelect={() => {
@@ -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(<MenuOverlay useCatalog={select => select([])} useStore={bindSnapshotSelector(store)} actions={store.actions}
const view = render(<MenuOverlay t={key => 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(<MenuOverlay useStore={bindSnapshotSelector(store)} actions={store.actions}
const view = render(<MenuOverlay t={key => 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')
+14 -14
View File
@@ -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
+10 -10
View File
@@ -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-
<a id="use-this-package"></a>
## 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
<details>
<summary>Implementation internals — click to expand</summary>
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 [`
<a id="model-experience"></a>
## 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
+10 -10
View File
@@ -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"
<a id="use-this-package"></a>
## 使用本包
把本插件与 [`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"
<details>
<summary>实现内幕——点击展开</summary>
两个目录插槽均通过显式的 `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"
<a id="model-experience"></a>
## 模型体验
无。分体按钮是浏览器 chrome;这里没有任何东西进入模型请求。
无。这些打开控件与菜单是浏览器界面;不会进入模型请求。
#### KV Cache 影响
@@ -1,6 +1,6 @@
{
"meta": {
"title": "外部应用打开界面",
"description": "在会话和文档预览中提供「打开方式」入口。"
"description": "在应用中打开会话目录或预览文件。"
}
}
+2 -1
View File
@@ -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:*",
@@ -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<readonly string[] | null>
@@ -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<PropsRuntime<'contextMenu.icon'>, 'icon'>) {
return icon.src ? <img src={icon.src} width={14} height={14} alt="" /> : <IconRightUpOutlineRegular />
const [failedSource, setFailedSource] = useState<string>()
return icon.src && icon.src !== failedSource
? <img src={icon.src} width={14} height={14} alt="" draggable={false} onError={() => { setFailedSource(icon.src) }} />
: <IconRightUpOutlineRegular />
}
@@ -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<Response>
/**
* 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)
}
@@ -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<OpenInAppActionProps, 'absolutePath'>,
) {
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,
@@ -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<OpenInAppKey, string> = {
'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',
@@ -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`)
}
}
@@ -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')
}
@@ -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<OpenInAppActionInjected> = ctx.slots.entries('conversation.session.header.utilities')[0]!.inject!()
const face: Partial<OpenInAppActionInjected> = 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<Pick<
PropsRuntime<'conversation.session.header.utilities'>, 'sessionId' | 'useSessions'
>>
const id = 'main' as SessionId
const props = { sessionId: id, useSessions: <T>(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<OpenInAppActionInjected> | 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<OpenInAppActionInjected> | undefined = entry?.inject?.()
if (injected?.hooks === undefined || injected.launch === undefined) throw new Error('expected the injected launch action')
const { hooks } = injected
@@ -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<SessionListState>({ 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<Response>()
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)
})
@@ -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<SessionListState>({ 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' }) })
}
})
@@ -23,6 +23,9 @@
{
"path": "../../host/open-in-app"
},
{
"path": "../../session-query/session-log-export/tsconfig.client.json"
},
{
"path": "../locale/tsconfig.client.json"
},
@@ -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
+4 -2
View File
@@ -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.
+4 -2
View File
@@ -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` 圆角;外围菜单保留半透明材质。
@@ -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'],
+183 -53
View File
@@ -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<string | null>(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<HTMLDivElement>(null)
const submenuTrigger = useRef<HTMLButtonElement | null>(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<HTMLButtonElement>('button[role="menuitem"]:not(:disabled):not([data-menu-back])')
?? child?.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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<CSSProperties | null>(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<HTMLButtonElement>('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<HTMLButtonElement>('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
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={hasSub ? () => { setOpenSubmenuId(entry.id) } : undefined}
onMouseLeave={() => { setOpenSubmenuId(null) }}
onPointerEnter={hasSub ? (event) => {
if (!hoverAllowed(event) || entry.disabled) return
const trigger = event.currentTarget.querySelector<HTMLButtonElement>('button')
if (trigger !== null) openChild(entry.id, trigger)
} : undefined}
onPointerLeave={hasSub ? (event) => {
if (hoverAllowed(event) && !submenuState.current.page) armSubmenuClose()
} : undefined}
>
<button
type="button"
@@ -526,11 +627,13 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
disabled={entry.disabled}
aria-keyshortcuts={entry.shortcut?.aria}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onFocus={hasSub ? () => { setOpenSubmenuId(entry.id) } : undefined}
onClick={() => {
aria-expanded={hasSub ? subOpen && !submenuHidden : undefined}
onFocus={hasSub ? (event) => {
if (!returningFromSubmenu.current) openChild(entry.id, event.currentTarget)
} : undefined}
onClick={(event) => {
if (hasSub) {
setOpenSubmenuId(entry.id)
openChild(entry.id, event.currentTarget, true)
return
}
onSelect?.(entry.id)
@@ -541,27 +644,53 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
{entry.shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={entry.shortcut.keys} className={css.shortcutKeys} /></span>}
{/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */}
{selected && selection === 'check' && <IconCheckOutlineRegular className={css.check} />}
{hasSub && <IconChevronRightOutlineRegular aria-hidden="true" className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<MenuSurface compact={compact} className={clsx(css.submenu, compact && css.compactList)} role="menu">
{entry.submenu.map(sub => isSeparator(sub)
? <div key={sub.id} className={css.separator} role="separator" />
: isLabel(sub) ? <div key={sub.id} className={css.label}>{sub.text}</div> : (
<button
key={sub.id}
type="button"
role="menuitem"
className={css.item}
disabled={sub.disabled}
aria-keyshortcuts={sub.shortcut?.aria}
onClick={() => { onSelect?.(sub.id); refocusAfterSelection() }}
>
{(sub.icon !== undefined || subColumns.has(sub)) && <span aria-hidden="true" className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
{sub.shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={sub.shortcut.keys} className={css.shortcutKeys} /></span>}
</button>
))}
</MenuSurface>
{subOpen && entry.submenu !== undefined && exiting === undefined && createPortal(
<MenuSurface ref={submenuRef} compact={compact}
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,
visibility: submenuHidden ? 'hidden' : undefined,
}} role="menu"
onPointerEnter={cancelSubmenuClose}
onFocus={(event) => { 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 && <>
<button type="button" role="menuitem" data-menu-back="" className={css.item} onClick={returnToParent}>
<IconChevronLeftOutlineRegular aria-hidden="true" className={css.check} />
<span className={css.itemLabel}>{submenuBackLabel ?? entry.label}</span>
</button>
<div className={css.separator} role="separator" />
</>}
<div className={css.viewport} role="presentation">
{entry.submenu.map(sub => isSeparator(sub)
? <div key={sub.id} className={css.separator} role="separator" />
: isLabel(sub) ? <div key={sub.id} className={css.label}>{sub.text}</div> : (
<button
key={sub.id}
type="button"
role="menuitem"
className={clsx(css.item, sub.danger && css.danger)}
disabled={sub.disabled}
aria-keyshortcuts={sub.shortcut?.aria}
onClick={() => { onSelect?.(sub.id) }}
>
{(sub.icon !== undefined || subColumns.has(sub)) && <span aria-hidden="true" className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
{sub.shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={sub.shortcut.keys} className={css.shortcutKeys} /></span>}
</button>
))}
</div>
</MenuSurface>, document.body,
)}
</div>
)
@@ -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 && (
<MenuSurface compact={compact} id={contextual ? menuId : undefined}
ref={listRef}
className={clsx(css.list, listClassName, dense && css.denseList, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
role={exiting === undefined ? 'menu' : undefined}
aria-hidden={exiting === undefined ? undefined : true}
className={clsx(css.list, listClassName, dense && css.denseList, compact && css.compactList, css.scrollable, submenuPage && css.parentPage, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={submenuPage ? { ...(portal ? fixedPos ?? MEASURE_STYLE : {}), visibility: 'hidden' } : portal ? fixedPos ?? MEASURE_STYLE : undefined}
role={exiting === undefined && !submenuPage ? 'menu' : undefined}
aria-hidden={exiting === undefined && !submenuPage ? undefined : true}
data-context-menu={contextual ? '' : undefined}
phase={contextual ? exiting ?? 'enter' : undefined}
// React portals bubble synthetic events through the REACT tree: without
@@ -605,7 +735,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
onContextMenu={contextual ? (event) => {
event.preventDefault(); event.stopPropagation(); onClose()
} : undefined}
onMouseOver={collapseSubmenuFrom}
onPointerOver={(event) => { if (hoverAllowed(event)) collapseSubmenuFrom(event) }}
onFocus={collapseSubmenuFrom}
>
<div className={css.viewport} role="presentation">
@@ -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}
@@ -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<HTMLDivElement>,
trigger: RefObject<HTMLButtonElement>,
child: RefObject<HTMLDivElement>,
contextual: boolean,
): SubmenuPosition | null {
const [position, setPosition] = useState<SubmenuPosition | null>(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
}
@@ -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(
<Menu open anchor={<span>trigger</span>} 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/)
})
})
@@ -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 <Menu open={open} submenuBackLabel={backLabel}
anchor={<button onClick={() =>{ setOpen(true) }}>More</button>}
items={entries} onClose={() =>{ setOpen(false) }}
onSelect={(id) => { onSelect(id); setOpen(false) }}>
<MenuItemButton onSelect={() =>{ setOpen(false) }}>Plugin action</MenuItemButton>
</Menu>
}
function ExternalTriggerFixture({ onClose }: { onClose: () => void }) {
const trigger = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
return <>
<button ref={trigger} onClick={() => { setOpen(value => !value) }}><span>External more</span></button>
<Menu open={open} anchor={null} portal contextual returnFocus={() => 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<HTMLElement>('[role="menu"]')!
}
describe('Menu submenus', () => {
it('lets an external return-focus trigger toggle closed through pointerdown then click', () => {
const close = vi.fn()
render(<ExternalTriggerFixture onClose={close} />)
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(<Menu {...props} items={entries} />)
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(<Menu {...props} items={changed} />)
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(<Menu {...props} items={entries} />)
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(<Menu {...props} footer={[entries[0]!]} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
view.rerender(<Menu {...props} />)
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: <button>Outside control</button>, onClose: vi.fn() }
const view = render(<Menu {...props} items={entries} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
const outside = screen.getByRole('button', { name: 'Outside control' })
act(() => { outside.focus() })
view.rerender(<Menu {...props} items={[entries[1]!]} />)
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: <button>More</button>, onClose: vi.fn() }
const view = render(<Menu {...props} items={[entries[0]!]} />)
fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
view.rerender(<Menu {...props} items={[]} />)
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(<Fixture />)
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(<Fixture onSelect={select} />)
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(<Fixture />)
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(<Fixture onSelect={select} backLabel="返回上级" />)
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(<Menu open autoFocus anchor={null} submenuBackLabel="Go back" items={entries} onClose={close} />)
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(<Menu open anchor={null} items={entries} onClose={() => {}} />)
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(<Fixture />)
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(<Fixture />)
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(<Fixture />)
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(<Fixture />)
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(<Fixture />)
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(<Fixture />)
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(<Menu open anchor={null} submenuBackLabel="Go back" onClose={() => {}}
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(<Menu open anchor={null} items={[{ ...entries[0]!, disabled: true }]} onClose={close} />)
pointer(screen.getByRole('menuitem'), 'pointerover', 'mouse')
fireEvent.click(screen.getByRole('menuitem'))
expect(screen.getAllByRole('menu')).toHaveLength(1)
fireEvent.pointerDown(document.body)
expect(close).toHaveBeenCalledOnce()
})
})
@@ -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
@@ -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
@@ -35,7 +35,7 @@ kind: "package-reference"
剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=<id>` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 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` 目标,独立于会话行及标题右键操作。导出及可选反馈贡献观察各自的控制器、可用性与语言源;按钮通过共享活动目标显示展开状态。会话范围的下载弹窗保持挂载在按钮旁,菜单焦点和关闭由共享浮层管理。
### 下载流程
@@ -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",
@@ -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<SessionMarkdownCopyState> }
/** Session download controls and observable menu/copy state for the Header trigger. */
export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDialogInjected {
hooks: SessionLogDownloadDialogInjected['hooks'] & {
sessionMarkdownCopy: ObservableSnapshot<SessionMarkdownCopyState>
headerMenu: ObservableSnapshot<ContextMenuTarget | null>
}
/**
* 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<void>
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<boolean> }
/**
* 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<SessionLogDownloadHeaderInjected>
/**
* 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 (
<>
<Menu
open={open}
align="end"
dense
onClose={() => { setOpen(false) }}
items={[
{ id: 'copy', label: t('menu.copyMarkdown'), icon: <IconCopyOutlineRegular />, disabled: copying },
{ id: 'download', label: t('menu.download'), icon: <IconDownloadOutlineRegular />, disabled: busy },
...feedbackAvailable ? [{ id: 'feedback', label: t('menu.feedback'), icon: <IconPaperPlaneOutlineRegular /> }] : [],
]}
onSelect={(id) => {
setOpen(false)
if (id === 'feedback') openFeedback(sessionId)
else if (id === 'copy') void copyMarkdown(sessionId)
else void request(sessionId)
<Button
size="sm"
className={css.moreButton}
data-context-menu-trigger
aria-label={t('header.more')}
aria-haspopup="menu"
aria-expanded={open}
aria-busy={busy || copying}
onClick={(event) => {
const button = event.currentTarget
button.focus()
const rect = button.getBoundingClientRect()
openMenu(sessionId, { x: rect.left, y: rect.bottom })
}}
anchor={(
<Button
size="sm"
className={css.moreButton}
aria-label={t('header.more')}
aria-haspopup="menu"
aria-expanded={open}
aria-busy={busy || copying}
onClick={() => { setOpen(value => !value) }}
>
<IconEllipsisOutlineRegular />
</Button>
)}
/>
>
<IconEllipsisOutlineRegular />
</Button>
<SessionLogDownloadDialog {...props} />
</>
)
@@ -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))
}
@@ -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<ContextMenuTarget | null>(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<Response>(), copy = Promise.withResolvers<Response>()
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)
@@ -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<string, string> = 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<State>(store: ObservableSnapshot<State>) {
return function useSnapshot<T>(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<Response>()
const controller = new SessionLogDownloadController(() => gate.promise, vi.fn())
const copyController = new SessionMarkdownCopyController(async () => new Response('markdown'))
const active = createSnapshotStore<ContextMenuTarget | null>(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(<SessionLogDownloadHeaderAction {...props} />)
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<Response>()
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(<SessionLogDownloadHeaderAction {...b.props} sessionId={'another' as SessionId} />)
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<void>
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<Response>((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(<SessionLogDownloadHeaderAction {...rerendered} />)
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)
})
})
@@ -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" },
+6
View File
@@ -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
+1
View File
@@ -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",