diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fae9e657fd..6a072e615d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -749,7 +749,7 @@ async function main(): Promise { // Keep app.name stable: Electron derives its default userData directory from it. const darwin = process.platform === 'darwin' const platformMenus = (): MenuItemConstructorOptions[] => darwin - ? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }] + ? [shortcuts.fileMenu(currentDesktopLocale().messages), { role: 'editMenu' }, { role: 'windowMenu' }] : [{ role: 'editMenu' }] const hideCommands: MenuItemConstructorOptions[] = darwin ? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication }, diff --git a/apps/web/tests/expected/shortcuts-panels/macos-browser.expected.md b/apps/web/tests/expected/shortcuts-panels/macos-browser.expected.md new file mode 100644 index 0000000000..40c2378f41 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/macos-browser.expected.md @@ -0,0 +1,18 @@ +- tablist: + - tab "Browser Close": + - text: Browser + - button "Close" + - tab "Browser Close" [selected]: + - text: Browser + - button "Close" + - button "New tab" + - button "Split" + - button "Fullscreen" + - button "Collapse right sidebar" +- button "Back" [disabled] +- button "Forward" [disabled] +- button "Reload" [disabled] +- textbox "Enter an HTTP(S) address" +- button "Open in system browser" [disabled] +- button "Disable sandbox restrictions" +- text: Enter an HTTP(S) address to start browsing diff --git a/apps/web/tests/expected/shortcuts-panels/macos-guide-close.expected.md b/apps/web/tests/expected/shortcuts-panels/macos-guide-close.expected.md new file mode 100644 index 0000000000..c255700433 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/macos-guide-close.expected.md @@ -0,0 +1,9 @@ +- tablist: + - tab "Start" [selected] + - button "Split" + - button "Exit fullscreen" + - button "Collapse right sidebar" +- button "Workspace files Browse files in this session's workspace" +- button "New terminal Run commands in the Session workspace" +- button "Choose shell" +- button "Browser Browse web pages" diff --git a/apps/web/tests/expected/shortcuts-panels/macos-page-actions.expected.md b/apps/web/tests/expected/shortcuts-panels/macos-page-actions.expected.md new file mode 100644 index 0000000000..d5ffdb1900 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/macos-page-actions.expected.md @@ -0,0 +1,23 @@ +- tablist: + - tab "Files Close" [selected]: + - text: Files + - button "Close" + - button "New tab" + - button "Split" [disabled] +- text: {{cwd}}/workspace +- button "Reload" +- list: + - listitem: + - button "t6-refresh.txt" +- tablist: + - tab "Files Close": + - text: Files + - button "Close" + - tab "bash Close" [selected]: + - text: bash + - button "Close" + - button "New tab" + - button "Split" [disabled] + - button "Fullscreen" + - button "Collapse right sidebar" +- textbox "Terminal" diff --git a/apps/web/tests/expected/shortcuts-panels/macos-tab-menu.expected.md b/apps/web/tests/expected/shortcuts-panels/macos-tab-menu.expected.md new file mode 100644 index 0000000000..c8ab6426ab --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/macos-tab-menu.expected.md @@ -0,0 +1,2 @@ +- menu: + - menuitem "Close" diff --git a/apps/web/tests/expected/shortcuts-panels/macos.expected.md b/apps/web/tests/expected/shortcuts-panels/macos.expected.md new file mode 100644 index 0000000000..6cafc71387 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/macos.expected.md @@ -0,0 +1,25 @@ +- tablist: + - tab "Files Close" [selected]: + - text: Files + - button "Close" + - button "New tab" + - button "Split" [disabled] +- text: {{cwd}}/workspace +- button "Reload" +- list: + - listitem: Empty directory +- tablist: + - tab "Files Close": + - text: Files + - button "Close" + - tab "bash Close": + - text: bash + - button "Close" + - tab "bash Close" [selected]: + - text: bash + - button "Close" + - button "New tab" + - button "Split" [disabled] + - button "Exit fullscreen" + - button "Collapse right sidebar" +- textbox "Terminal" diff --git a/apps/web/tests/expected/shortcuts-panels/windows-labels-browser.expected.md b/apps/web/tests/expected/shortcuts-panels/windows-labels-browser.expected.md new file mode 100644 index 0000000000..40c2378f41 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/windows-labels-browser.expected.md @@ -0,0 +1,18 @@ +- tablist: + - tab "Browser Close": + - text: Browser + - button "Close" + - tab "Browser Close" [selected]: + - text: Browser + - button "Close" + - button "New tab" + - button "Split" + - button "Fullscreen" + - button "Collapse right sidebar" +- button "Back" [disabled] +- button "Forward" [disabled] +- button "Reload" [disabled] +- textbox "Enter an HTTP(S) address" +- button "Open in system browser" [disabled] +- button "Disable sandbox restrictions" +- text: Enter an HTTP(S) address to start browsing diff --git a/apps/web/tests/expected/shortcuts-panels/windows-labels-guide-close.expected.md b/apps/web/tests/expected/shortcuts-panels/windows-labels-guide-close.expected.md new file mode 100644 index 0000000000..c255700433 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/windows-labels-guide-close.expected.md @@ -0,0 +1,9 @@ +- tablist: + - tab "Start" [selected] + - button "Split" + - button "Exit fullscreen" + - button "Collapse right sidebar" +- button "Workspace files Browse files in this session's workspace" +- button "New terminal Run commands in the Session workspace" +- button "Choose shell" +- button "Browser Browse web pages" diff --git a/apps/web/tests/expected/shortcuts-panels/windows-labels-page-actions.expected.md b/apps/web/tests/expected/shortcuts-panels/windows-labels-page-actions.expected.md new file mode 100644 index 0000000000..d5ffdb1900 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/windows-labels-page-actions.expected.md @@ -0,0 +1,23 @@ +- tablist: + - tab "Files Close" [selected]: + - text: Files + - button "Close" + - button "New tab" + - button "Split" [disabled] +- text: {{cwd}}/workspace +- button "Reload" +- list: + - listitem: + - button "t6-refresh.txt" +- tablist: + - tab "Files Close": + - text: Files + - button "Close" + - tab "bash Close" [selected]: + - text: bash + - button "Close" + - button "New tab" + - button "Split" [disabled] + - button "Fullscreen" + - button "Collapse right sidebar" +- textbox "Terminal" diff --git a/apps/web/tests/expected/shortcuts-panels/windows-labels-tab-menu.expected.md b/apps/web/tests/expected/shortcuts-panels/windows-labels-tab-menu.expected.md new file mode 100644 index 0000000000..c8ab6426ab --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/windows-labels-tab-menu.expected.md @@ -0,0 +1,2 @@ +- menu: + - menuitem "Close" diff --git a/apps/web/tests/expected/shortcuts-panels/windows-labels.expected.md b/apps/web/tests/expected/shortcuts-panels/windows-labels.expected.md new file mode 100644 index 0000000000..6cafc71387 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/windows-labels.expected.md @@ -0,0 +1,25 @@ +- tablist: + - tab "Files Close" [selected]: + - text: Files + - button "Close" + - button "New tab" + - button "Split" [disabled] +- text: {{cwd}}/workspace +- button "Reload" +- list: + - listitem: Empty directory +- tablist: + - tab "Files Close": + - text: Files + - button "Close" + - tab "bash Close": + - text: bash + - button "Close" + - tab "bash Close" [selected]: + - text: bash + - button "Close" + - button "New tab" + - button "Split" [disabled] + - button "Exit fullscreen" + - button "Collapse right sidebar" +- textbox "Terminal" diff --git a/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md index 8da0cf9a42..95ac47d222 100644 --- a/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md @@ -16,6 +16,35 @@ - button "Remove shortcut for Toggle left sidebar" - button "Edit shortcut for Toggle left sidebar" - button "Toggle left sidebar ⌥ ⌘ B": ⌥ ⌘ B + - listitem: + - text: Toggle right sidebar + - button "Remove shortcut for Toggle right sidebar" + - button "Edit shortcut for Toggle right sidebar" + - button "Toggle right sidebar ⇧ ⌘ B": ⇧ ⌘ B + - listitem: + - text: Split + - button "Remove shortcut for Split" + - button "Edit shortcut for Split" + - button "Split ⌘ \\": ⌘ \ + - listitem: + - text: Toggle panel fullscreen + - button "Remove shortcut for Toggle panel fullscreen" + - button "Edit shortcut for Toggle panel fullscreen" + - button "Toggle panel fullscreen ⌥ ⌘ Enter": ⌥ ⌘ Enter + - listitem: + - text: Close current page or window + - button "Remove shortcut for Close current page or window" + - button "Edit shortcut for Close current page or window" + - button "Close current page or window ⌥ ⌘ W": ⌥ ⌘ W + - listitem: + - text: Refresh current page + - button "Edit shortcut for Refresh current page" + - button "Refresh current page No shortcut": No shortcut + - listitem: + - text: Browser + - button "Remove shortcut for Browser" + - button "Edit shortcut for Browser" + - button "Browser ⌥ ⌘ T": ⌥ ⌘ T - listitem: - text: Open settings - button "Remove shortcut for Open settings" @@ -51,6 +80,16 @@ - button "Remove shortcut for Archive session" - button "Edit shortcut for Archive session" - button "Archive session ⌥ ⌘ A": ⌥ ⌘ A + - listitem: + - text: Workspace files + - button "Remove shortcut for Workspace files" + - button "Edit shortcut for Workspace files" + - button "Workspace files ⌥ ⌘ P": ⌥ ⌘ P + - listitem: + - text: New terminal + - button "Remove shortcut for New terminal" + - button "Edit shortcut for New terminal" + - 'button "New terminal ⌃ `"': "⌃ `" - region "Message input": - heading "Message input" [level=3] - list: diff --git a/apps/web/tests/expected/shortcuts-workspace/windows-defaults.expected.md b/apps/web/tests/expected/shortcuts-workspace/windows-defaults.expected.md index cfbcdf269d..f4fe6ebcf9 100644 --- a/apps/web/tests/expected/shortcuts-workspace/windows-defaults.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/windows-defaults.expected.md @@ -16,6 +16,36 @@ - button "Remove shortcut for Toggle left sidebar" - button "Edit shortcut for Toggle left sidebar" - button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B + - listitem: + - text: Toggle right sidebar + - button "Remove shortcut for Toggle right sidebar" + - button "Edit shortcut for Toggle right sidebar" + - button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B + - listitem: + - text: Split + - button "Remove shortcut for Split" + - button "Edit shortcut for Split" + - button "Split Ctrl + \\": Ctrl + \ + - listitem: + - text: Toggle panel fullscreen + - button "Remove shortcut for Toggle panel fullscreen" + - button "Edit shortcut for Toggle panel fullscreen" + - button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter + - listitem: + - text: Close current page or window + - button "Remove shortcut for Close current page or window" + - button "Edit shortcut for Close current page or window" + - button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W + - listitem: + - text: Refresh current page + - button "Remove shortcut for Refresh current page" + - button "Edit shortcut for Refresh current page" + - button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R + - listitem: + - text: Browser + - button "Remove shortcut for Browser" + - button "Edit shortcut for Browser" + - button "Browser Ctrl + Alt + T": Ctrl + Alt + T - listitem: - text: Open settings - button "Remove shortcut for Open settings" @@ -51,6 +81,16 @@ - button "Remove shortcut for Archive session" - button "Edit shortcut for Archive session" - button "Archive session Ctrl + Alt + A": Ctrl + Alt + A + - listitem: + - text: Workspace files + - button "Remove shortcut for Workspace files" + - button "Edit shortcut for Workspace files" + - button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P + - listitem: + - text: New terminal + - button "Remove shortcut for New terminal" + - button "Edit shortcut for New terminal" + - 'button "New terminal Ctrl + `"': "Ctrl + `" - region "Message input": - heading "Message input" [level=3] - list: diff --git a/apps/web/tests/expected/shortcuts/en-US.expected.md b/apps/web/tests/expected/shortcuts/en-US.expected.md index cfbcdf269d..f4fe6ebcf9 100644 --- a/apps/web/tests/expected/shortcuts/en-US.expected.md +++ b/apps/web/tests/expected/shortcuts/en-US.expected.md @@ -16,6 +16,36 @@ - button "Remove shortcut for Toggle left sidebar" - button "Edit shortcut for Toggle left sidebar" - button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B + - listitem: + - text: Toggle right sidebar + - button "Remove shortcut for Toggle right sidebar" + - button "Edit shortcut for Toggle right sidebar" + - button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B + - listitem: + - text: Split + - button "Remove shortcut for Split" + - button "Edit shortcut for Split" + - button "Split Ctrl + \\": Ctrl + \ + - listitem: + - text: Toggle panel fullscreen + - button "Remove shortcut for Toggle panel fullscreen" + - button "Edit shortcut for Toggle panel fullscreen" + - button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter + - listitem: + - text: Close current page or window + - button "Remove shortcut for Close current page or window" + - button "Edit shortcut for Close current page or window" + - button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W + - listitem: + - text: Refresh current page + - button "Remove shortcut for Refresh current page" + - button "Edit shortcut for Refresh current page" + - button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R + - listitem: + - text: Browser + - button "Remove shortcut for Browser" + - button "Edit shortcut for Browser" + - button "Browser Ctrl + Alt + T": Ctrl + Alt + T - listitem: - text: Open settings - button "Remove shortcut for Open settings" @@ -51,6 +81,16 @@ - button "Remove shortcut for Archive session" - button "Edit shortcut for Archive session" - button "Archive session Ctrl + Alt + A": Ctrl + Alt + A + - listitem: + - text: Workspace files + - button "Remove shortcut for Workspace files" + - button "Edit shortcut for Workspace files" + - button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P + - listitem: + - text: New terminal + - button "Remove shortcut for New terminal" + - button "Edit shortcut for New terminal" + - 'button "New terminal Ctrl + `"': "Ctrl + `" - region "Message input": - heading "Message input" [level=3] - list: diff --git a/apps/web/tests/expected/shortcuts/zh-CN.expected.md b/apps/web/tests/expected/shortcuts/zh-CN.expected.md index 6d08507a05..5f8468b01d 100644 --- a/apps/web/tests/expected/shortcuts/zh-CN.expected.md +++ b/apps/web/tests/expected/shortcuts/zh-CN.expected.md @@ -16,6 +16,35 @@ - button "移除展开/收起左侧栏快捷键" - button "修改展开/收起左侧栏快捷键" - button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B + - listitem: + - text: 展开/收起右侧栏 + - button "移除展开/收起右侧栏快捷键" + - button "修改展开/收起右侧栏快捷键" + - button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B + - listitem: + - text: 分栏 + - button "移除分栏快捷键" + - button "修改分栏快捷键" + - button "分栏 ⌘ \\": ⌘ \ + - listitem: + - text: 面板全屏/退出全屏 + - button "移除面板全屏/退出全屏快捷键" + - button "修改面板全屏/退出全屏快捷键" + - button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter + - listitem: + - text: 关闭当前页面/窗口 + - button "移除关闭当前页面/窗口快捷键" + - button "修改关闭当前页面/窗口快捷键" + - button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W + - listitem: + - text: 刷新当前页面 + - button "修改刷新当前页面快捷键" + - button "刷新当前页面 暂无快捷键": 暂无快捷键 + - listitem: + - text: 浏览器 + - button "移除浏览器快捷键" + - button "修改浏览器快捷键" + - button "浏览器 ⌥ ⌘ T": ⌥ ⌘ T - listitem: - text: 打开设置 - button "移除打开设置快捷键" @@ -51,6 +80,16 @@ - button "移除归档会话快捷键" - button "修改归档会话快捷键" - button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A + - listitem: + - text: 工作区文件 + - button "移除工作区文件快捷键" + - button "修改工作区文件快捷键" + - button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P + - listitem: + - text: 新建终端 + - button "移除新建终端快捷键" + - button "修改新建终端快捷键" + - 'button "新建终端 ⌃ `"': "⌃ `" - region "消息输入": - heading "消息输入" [level=3] - list: diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts new file mode 100644 index 0000000000..26f8249b58 --- /dev/null +++ b/apps/web/tests/shortcuts-panels.e2e.ts @@ -0,0 +1,234 @@ +/** Sidebar commands through the shipped Loader, real sidebar/file/terminal owners, and Web keybinding editor. */ +import { writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace } from './support.ts' + +const expected = fileURLToPath(new URL('./expected/shortcuts-panels', import.meta.url)) +const mode = webSnapshotMode() + +/** Reassign a spare Web combination through the actual editor, leaving browser-reserved defaults untouched. */ +async function bind(page: Page, primary: string, label: string, previous?: string): Promise { + await page.keyboard.press(`${primary}+/`) + const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await dialog.waitFor() + if (label === 'Split' || label === 'Toggle panel fullscreen') { + const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }) }) + expect(await row.getByText(label, { exact: true }).count()).toBe(1) + expect(await row.getByText('Focus a right sidebar pane first', { exact: true }).count()).toBe(0) + expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0) + } + if (previous !== undefined) { + const remove = dialog.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }) + await remove.click() + await remove.waitFor({ state: 'hidden' }) + } + await dialog.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click() + const recorder = dialog.getByRole('group', { name: label, exact: true }) + await recorder.waitFor() + await page.keyboard.press(`${primary}+Shift+,`) + await recorder.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'detached' }) +} + +// The fixture owns a POSIX shell; Windows has a separate physical-input acceptance run. +describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { + let browser: Browser + beforeAll(async () => { + browser = await chromium.launch() + }, 120_000) + afterAll(async () => { await browser?.close() }) + + it.each([ + { platform: 'MacIntel', primary: 'Meta', aria: 'Shift+Meta+,', file: 'macos' }, + { platform: 'Win32', primary: 'Control', aria: 'Control+Shift+,', file: 'windows-labels' }, + ].flatMap(platform => (['left', 'right'] as const).map(closeFirst => ({ ...platform, closeFirst }))))('targets panes and closes $closeFirst first with $file bindings', async ({ platform, primary, aria, file, closeFirst }) => { + const scaffold: WebScaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) }) + const context = await browser.newContext({ locale: 'en-US', timezoneId: 'Asia/Shanghai', viewport: { width: 1680, height: 1000 } }) + try { + // Device-label/DOM branch evidence only; physical Windows input has its own acceptance checklist. + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list().find(agent => !before.has(agent.id)) + if (agent === undefined) throw new Error('The workspace did not create a Session') + // A settled fixture activates the ordinary Session UI; this scenario issues no model requests. + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Prepare panel actions.' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { turn: 1, step: 1, stream: [], message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Panel actions are ready.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn: 1, step: 1 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByText('Panel actions are ready.', { exact: true }).waitFor() + const composer = page.locator('[data-composer-input]') + await composer.click() + await page.keyboard.insertText('T5 draft remains unsent') + await page.keyboard.press(`${primary}+Alt+Enter`) + expect(await composer.innerText()).toContain('T5 draft remains unsent') + const eventCount = agent.session.seq + + await bind(page, primary, 'Toggle right sidebar') + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') + await panel.waitFor() + expect(await panel.getByRole('button', { name: 'Collapse right sidebar' }).getAttribute('aria-keyshortcuts')).toBe(aria) + await panel.getByRole('tab').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + await page.keyboard.press(`${primary}+Shift+,`) + await panel.waitFor() + + const terminalGuide = panel.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button').first() + await terminalGuide.hover() + expect(await page.getByRole('tooltip').count()).toBe(0) + await page.mouse.move(10, 10) + await terminalGuide.focus() + expect(await page.getByRole('tooltip').count()).toBe(0) + + await bind(page, primary, 'Browser', 'Toggle right sidebar') + const browserGuide = panel.locator('[data-sidebar-right-guide-entry="browser"]') + await browserGuide.hover() + await browserGuide.focus() + expect(await browserGuide.getAttribute('aria-keyshortcuts')).toBe(aria) + expect(await page.getByRole('tooltip').count()).toBe(0) + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + await panel.getByPlaceholder('Enter an HTTP(S) address').waitFor() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2) + await expect.poll(() => panel.getByRole('button', { name: 'Disable sandbox restrictions', exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden(join(expected, `${file}-browser.expected.md`), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + await panel.locator('[data-dockkit-tab-close]').last().click() + await panel.locator('[data-dockkit-tab-close]').click() + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + await bind(page, primary, 'Workspace files', 'Browser') + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).waitFor() + await page.keyboard.press(`${primary}+Shift+,`) + expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1) + + await bind(page, primary, 'Toggle panel fullscreen', 'Workspace files') + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + expect(await page.locator('[data-sidebar-right-panel="fullscreen"]').count()).toBe(0) + const filesTab = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + await filesTab.focus() + await expect.poll(() => filesTab.evaluate(element => document.activeElement === element)).toBe(true) + await page.keyboard.press(`${primary}+Shift+,`) + await page.locator('[data-sidebar-right-panel="fullscreen"]').waitFor() + expect(await composer.innerText()).toContain('T5 draft remains unsent') + expect(agent.session.seq).toBe(eventCount) + + await bind(page, primary, 'Split', 'Toggle panel fullscreen') + await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2) + const panes = panel.locator('[data-dockkit-pane]') + const left = panes.nth(0) + const right = panes.nth(1) + const disabled = right.locator('[data-dockkit-split-button]') + expect(await disabled.isDisabled()).toBe(true) + await disabled.locator('..').focus() + await page.getByRole('tooltip', { name: 'Two panes is the limit', exact: true }).waitFor() + + await bind(page, primary, 'Workspace files', 'Split') + await right.getByRole('tab').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(2) + await page.keyboard.press(`${primary}+Shift+,`) + expect(await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1) + + await bind(page, primary, 'New terminal', 'Workspace files') + await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus() + await page.keyboard.press(`${primary}+Shift+,`) + await right.locator('[data-sidebar-terminal]').waitFor() + await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-') + await right.locator('.xterm-helper-textarea').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => right.getByRole('tab').count()).toBe(3) + await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-') + expect(await left.getByRole('tab').count()).toBe(1) + await compareOrRefreshGolden(join(expected, `${file}.expected.md`), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + await bind(page, primary, 'Refresh current page', 'New terminal') + await right.locator('.xterm-helper-textarea').focus() + await page.keyboard.press(`${primary}+Shift+,`) + expect(await right.getByRole('tab').count()).toBe(3) + await left.getByRole('tab', { name: /Files/ }).click() + await writeFile(join(scaffold.workspaceCwd, 'workspace', 't6-refresh.txt'), 'page refresh evidence\n') + await left.getByRole('tab', { name: /Files/ }).focus() + await page.keyboard.press(`${primary}+Shift+,`) + await left.getByText('t6-refresh.txt', { exact: true }).waitFor() + expect(await left.getByRole('button', { name: 'Reload', exact: true }).getAttribute('aria-keyshortcuts')).toBe(aria) + await bind(page, primary, 'Close current page or window', 'Refresh current page') + await right.getByRole('tab').last().focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => right.getByRole('tab').count()).toBe(2) + expect(await left.getByRole('tab').count()).toBe(1) + await panel.getByRole('button', { name: 'Exit fullscreen', exact: true }).click() + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + expect(await right.getByRole('tab').count()).toBe(2) + await compareOrRefreshGolden(join(expected, `${file}-page-actions.expected.md`), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + const menuTab = left.getByRole('tab', { name: /Files/ }) + await menuTab.locator('[data-dockkit-tab-close]').focus() + await page.getByRole('tooltip').filter({ hasText: 'Close' }).waitFor() + for (let attempt = 0; attempt < 2; attempt++) { + await menuTab.click({ button: 'right' }) + await page.getByRole('menu').waitFor() + expect(await page.getByRole('tooltip').count()).toBe(0) + expect(await page.getByRole('menuitem').getAttribute('aria-keyshortcuts')).toBeNull() + if (attempt === 0) { + await compareOrRefreshGolden(join(expected, `${file}-tab-menu.expected.md`), + await captureStableAria(page, '[data-dockkit-tab-menu]', scaffold.workspaceCwd), mode) + } else { + await page.getByRole('menuitem').focus() + } + await page.keyboard.press('Escape') + await page.getByRole('menu').waitFor({ state: 'detached' }) + expect(await left.getByRole('tab').count()).toBe(1) + expect(await right.getByRole('tab').count()).toBe(2) + } + expect(await menuTab.evaluate(tab => tab === document.activeElement)).toBe(true) + await (closeFirst === 'right' ? right : left).getByRole('tab').last().focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab').count()).toBe(2) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab').count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + await panel.getByRole('button', { name: 'Fullscreen', exact: true }).click() + await panel.locator('[data-dockkit-split-button]').click() + await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2) + await panel.getByRole('tab').nth(closeFirst === 'left' ? 0 : 1).focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab').count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true) + await compareOrRefreshGolden(join(expected, `${file}-guide-close.expected.md`), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + expect(await composer.innerText()).toContain('T5 draft remains unsent') + expect(agent.session.seq).toBe(eventCount) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { try { await context.close() } finally { await scaffold.close() } } + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 43e0e92265..91060146a5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,6 +24,7 @@ "exclude": [ "tests/shortcuts-desktop.e2e.ts", "tests/shortcuts-workspace.e2e.ts", + "tests/shortcuts-panels.e2e.ts", "tests/shortcuts.e2e.ts", "tests/composer-shortcuts.e2e.ts", "tests/default-product-isolation.e2e.ts", diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 333cd57389..54e3e3f130 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md -README.md: 613cdbcb105108eb848160f1cb6bc8ee28d39fac -README.zh.md: 77b256f01dd46aea5144e9f8da644fd39330f187 +README.md: 6520f149094b91e2d84c051da68791169f9f31a9 +README.zh.md: f62c7d1ae63caede511312b52b4c3b9c2e5d8e0d diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 613cdbcb10..6520f14909 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -39,6 +39,8 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a **The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. +Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab. Composition and held-key repeats do not dismiss it. + ## Embedding it @@ -63,6 +65,8 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp ## Interaction rules worth keeping +The split control accepts effective shortcut tooltip and ARIA labels from its embedder. A disabled control has a keyboard-focusable wrapper that explains the pane-budget or width restriction. Docked and floating pane containers can receive programmatic focus without entering the normal tab sequence. Tab navigation and selection use unmodified keys and leave composition input to its owner. + These are not stylistic; each one fixes a defect found in a real browser. - **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works. @@ -104,6 +108,8 @@ None; this package neither assembles nor sends a provider request. None. +The embedder supplies effective close keycaps and ARIA combinations for tab and floating close controls. Close controls expose the same tooltip on hover and keyboard focus. Tab close tooltips stay hidden while their pane's context menu is open. + **Runtime invariant:** No companion is published. The engine is pure functions over plain data and the components report intents only; the operation sequence's invertibility and the settle rule are asserted directly by this package's engine specs, and no cordis service is provided or observed. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 77b256f01d..f62c7d1ae6 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -39,6 +39,8 @@ kind: "package-reference" **组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 +标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签。输入法组合输入和长按重复事件不关闭菜单。 + ## 如何嵌入 @@ -63,6 +65,8 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间 ## 值得保留的交互规则 +分栏控件接收宿主传入的有效快捷键提示和 ARIA 标签。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。 + 这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。 - **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。 @@ -104,6 +108,8 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间 无。 +嵌入方为标签和浮动面板的关闭控件提供有效关闭键帽及 ARIA 组合。关闭控件在悬停和键盘聚焦时显示相同 Tooltip。分栏的上下文菜单打开时,该分栏的标签关闭 Tooltip 保持隐藏。 + **运行时不变量:** 不发布 companion。引擎是作用于纯数据的纯函数,组件只上报意图;操作序列的可逆性与 settle 规则由本包的引擎 spec 直接断言,不提供也不观察任何 Cordis 服务。 diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index 5f512423bd..0bd58652c4 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -130,8 +130,8 @@ export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canC {(canCloseTab?.(tab.id) ?? true) && ( - - + + + )} {menu?.tabId === tabId && ( {!(callbacks.hideSplitWhenBlocked && block !== undefined) && ( - - + + )} {/* The embedder's surface-wide controls, in the top-right pane only: the @@ -416,6 +423,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
{ if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) diff --git a/packages/client/ui-dockkit/src/contract/adapter.ts b/packages/client/ui-dockkit/src/contract/adapter.ts index 81239e816e..b35fbb18d7 100644 --- a/packages/client/ui-dockkit/src/contract/adapter.ts +++ b/packages/client/ui-dockkit/src/contract/adapter.ts @@ -19,12 +19,20 @@ export interface DockLabels { readonly emptyPane: string /** The split control, while splitting is allowed. */ readonly splitPane: string + /** Effective localized action and shortcut tooltip, when supplied. */ + readonly splitPaneTooltip?: string + /** ARIA combination from the effective binding, when bound. */ + readonly splitPaneShortcut?: string | undefined /** The split control, once the pane budget is spent. */ readonly splitPaneDisabled: string /** The split control, while the pane is too narrow for two working halves. */ readonly splitPaneNarrow: string /** Destroy a tab: the chip's close control and the menu's close item. */ readonly closeTab: string + /** Effective close label and keycaps supplied by the embedder. */ + readonly closeTabTooltip?: string + /** Effective close combination, when bound. */ + readonly closeTabShortcut?: string | undefined /** The strip's add control, which seats the embedder's seeded tab. */ readonly addTab: string /** Send a floating panel back into the docked tree. */ diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 85768ebb55..33d0d67130 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -256,7 +256,7 @@ describe('DockSurface', () => { renderSurface(controller, intents, false) const disabled = screen.getByRole('button', { name: TEST_LABELS.splitPane }) expect(disabled.hasAttribute('disabled')).toBe(true) - expect(disabled.getAttribute('title')).toBe(TEST_LABELS.splitPaneDisabled) + expect(disabled.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneDisabled) expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget') }) @@ -336,7 +336,7 @@ describe('DockSurface', () => { expect(narrowButton).toBeNull() } else { expect(narrowButton?.hasAttribute('disabled')).toBe(true) - expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow) + expect(narrowButton?.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneNarrow) expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width') } }) @@ -815,6 +815,64 @@ describe('DockSurface', () => { expect(screen.queryByRole('menu')).toBeNull() }) + it.each(['tab', 'item'])('Escape dismisses a reopened menu from the %s without closing its tab', (focus) => { + const intents = spyIntents() + const { unmount } = renderSurface(seededController(), intents) + const tab = screen.getByRole('tab') + for (let attempt = 0; attempt < 2; attempt++) { + fireEvent.contextMenu(tab) + const target = focus === 'tab' ? tab : screen.getByRole('menuitem') + target.focus() + expect(fireEvent.keyDown(target, { key: 'Escape', code: 'Escape' })).toBe(false) + expect(screen.queryByRole('menu')).toBeNull() + expect(document.activeElement).toBe(tab) + expect(intents.closeTab).not.toHaveBeenCalled() + } + unmount() + expect(fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape' })).toBe(true) + }) + + it('keeps the menu during composition, modified Escape and key repeat', () => { + renderSurface(seededController(), spyIntents()) + const tab = screen.getByRole('tab') + fireEvent.contextMenu(tab) + for (const modifier of ['ctrlKey', 'altKey', 'metaKey', 'shiftKey', 'isComposing']) { + expect(fireEvent.keyDown(tab, { key: 'Escape', [modifier]: true })).toBe(true) + expect(screen.getByRole('menu')).toBeDefined() + } + fireEvent.compositionStart(tab) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.compositionEnd(tab) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.keyUp(tab, { key: 'Escape' }) + expect(fireEvent.keyDown(tab, { key: 'Escape', repeat: true })).toBe(false) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('yields Escape to a foreground modal and consumes it before a parent handler', () => { + renderSurface(seededController(), spyIntents()) + const tab = screen.getByRole('tab') + fireEvent.contextMenu(tab) + const modal = document.createElement('div') + modal.setAttribute('role', 'dialog') + modal.setAttribute('aria-modal', 'true') + document.body.append(modal) + onTestFinished(() => { modal.remove() }) + expect(fireEvent.keyDown(modal, { key: 'Escape' })).toBe(true) + expect(screen.getByRole('menu')).toBeDefined() + modal.remove() + const parent = vi.fn((event: KeyboardEvent) => { expect(event.defaultPrevented).toBe(true) }) + document.addEventListener('keydown', parent) + onTestFinished(() => { document.removeEventListener('keydown', parent) }) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(parent).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + }) + it('hangs the menu below the chip\'s left edge, or from its right edge when it would run off the viewport', () => { const controller = seededController() let left = 10 @@ -1118,6 +1176,18 @@ describe('keyboard tabs', () => { expect(intents.focusTab).toHaveBeenCalledTimes(2) }) + it('leaves modified and composing keys available to their owners', () => { + const { intents, chip } = threeChips() + const tab = chip('a.txt') + tab.focus() + for (const modifiers of [{ ctrlKey: true, altKey: true }, { metaKey: true, altKey: true }, { shiftKey: true }, { isComposing: true }]) { + expect(fireEvent.keyDown(tab, { key: 'Enter', ...modifiers })).toBe(true) + expect(fireEvent.keyDown(tab, { key: 'ArrowRight', ...modifiers })).toBe(true) + expect(document.activeElement).toBe(tab) + } + expect(intents.focusTab).not.toHaveBeenCalled() + }) + it('records nothing for Enter on the active pane\'s selected chip, and leaves keys on the close control alone', () => { const { intents, chip } = threeChips() fireEvent.keyDown(chip('b.txt'), { key: 'Enter' }) @@ -1483,7 +1553,7 @@ describe('surface chrome', () => { const role = (child: Element): string => { if (child.hasAttribute('data-dockkit-strip-tabs')) return 'tabs' if (child.hasAttribute('data-dockkit-add-tab')) return 'add' - if (child.hasAttribute('data-dockkit-split-button')) return 'split' + if (child.hasAttribute('data-dockkit-split-button') || child.querySelector('[data-dockkit-split-button]')) return 'split' if (child.hasAttribute('data-dockkit-strip-chrome')) return 'chrome' return 'fill' } diff --git a/packages/client/ui-sidebar-browser/README.i18n.yaml b/packages/client/ui-sidebar-browser/README.i18n.yaml index 9cd455c8d6..5711eb390c 100644 --- a/packages/client/ui-sidebar-browser/README.i18n.yaml +++ b/packages/client/ui-sidebar-browser/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-browser/README.md -README.md: 52ebeddd088a8427b1bf058442b6fab6a768de37 -README.zh.md: 93df01dbe13bfa4a5aa318a1fd3a9e9786cd3c67 +README.md: 33ef2d8e4dd82a67f826b4b446604ac88d3458ea +README.zh.md: 1d822796ed33cc6ac27c6d75e27a7d44039c7711 diff --git a/packages/client/ui-sidebar-browser/README.md b/packages/client/ui-sidebar-browser/README.md index 52ebeddd08..33ef2d8e4d 100644 --- a/packages/client/ui-sidebar-browser/README.md +++ b/packages/client/ui-sidebar-browser/README.md @@ -42,6 +42,8 @@ The package has no plugin configuration. A Web profile enables the shipped entry Client plugins can open a tab through `ctx.sidebarRight.openTab('browser', { params: { url } })`. The optional URL passes the same validation as address-bar input before navigation. +The `browser.new` command opens a separate Browser page in the focused dock pane, replacing a guide and retaining existing content pages. From the conversation or a floating content page, it uses the active dock pane. Desktop defaults to Cmd+T on macOS and Ctrl+T on Windows; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves the command unbound. The guide button displays the effective shortcut inline without a duplicate tooltip. + The toolbar provides Back, Forward, Reload, Go, and Open in system browser. Web also offers a per-tab sandbox toggle; disabling it is temporary and displays a warning. Desktop shows the observed page title. After a restart, Browser shows the saved title and URL; Restore or Reload opens that address only when requested. ----- @@ -68,6 +70,8 @@ Each tab's `BrowserController` owns address validation, commands and explicit re Desktop's main process approves guest leases and enforces attachment, navigation and permission policy. Preload exposes only scoped Browser operations. Shared declarations use the standard `/types` export with `import type`; the Host and Client compile through separate tsconfig files. Desktop Browser tabs declare `keepMounted`, so Sidebar preserves their DOM across tab changes, Session switches, collapse and floating. +The page refresh shortcut calls the same reload operation as the toolbar. Its tooltip and ARIA key combination follow the effective binding. Desktop can route that shortcut from the embedded page; Web leaves browser-reserved combinations unchanged. + ----- diff --git a/packages/client/ui-sidebar-browser/README.zh.md b/packages/client/ui-sidebar-browser/README.zh.md index 93df01dbe1..1d822796ed 100644 --- a/packages/client/ui-sidebar-browser/README.zh.md +++ b/packages/client/ui-sidebar-browser/README.zh.md @@ -42,6 +42,8 @@ Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用 Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url } })` 打开 tab。可选 URL 会在导航前接受与地址栏输入相同的校验。 +命令 `browser.new` 在焦点停靠分栏打开独立浏览器页,替换开始页并保留已有内容页。从聊天区或浮动内容页触发时,使用活动停靠分栏。桌面默认键在 macOS 上为 Cmd+T,在 Windows 上为 Ctrl+T;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。开始页按钮内显示有效快捷键,不额外弹出重复提示。 + 工具栏提供后退、前进、刷新、前往和在系统浏览器中打开。Web 还提供逐 tab sandbox 开关;关闭它是临时选择,并会显示警告。Desktop 显示观察到的页面标题。重启后,Browser 展示保存的标题和 URL;只有点击恢复或刷新才打开该地址。 ----- @@ -68,6 +70,8 @@ Web 记录 toolbar 提交和 typed tab 打开。导航状态机把每个受控 r Desktop 主进程批准 guest 租约,并执行挂载、导航和权限策略。preload 只暴露限定范围的 Browser 操作。共享声明通过标准 `/types` 出口配合 `import type` 引入;Host 与 Client 使用独立 tsconfig 编译。Desktop Browser tab 声明 `keepMounted`,Sidebar 因而在切 tab、切 Session、收起与浮动期间保留其 DOM。 +页面刷新快捷键调用工具栏使用的同一重载操作。其 Tooltip 和 ARIA 组合随有效绑定更新。桌面可从内嵌页面路由该快捷键;Web 保留浏览器专用组合。 + ----- diff --git a/packages/client/ui-sidebar-browser/package.json b/packages/client/ui-sidebar-browser/package.json index 94f05df6ed..e5cd52651a 100644 --- a/packages/client/ui-sidebar-browser/package.json +++ b/packages/client/ui-sidebar-browser/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-sidebar-browser/src/client/definition.tsx b/packages/client/ui-sidebar-browser/src/client/definition.tsx index c91fb8195c..a829e10533 100644 --- a/packages/client/ui-sidebar-browser/src/client/definition.tsx +++ b/packages/client/ui-sidebar-browser/src/client/definition.tsx @@ -1,4 +1,5 @@ /** Static Browser tab type and guide declaration. */ +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' @@ -19,7 +20,7 @@ export function browserDefinition(t: TranslateNS<'sidebarBrowser'>): SidebarRigh priority: 'builtin', title: () => t('type.label'), guide: [{ - id: 'new', order: 30, title: () => t('guide.title'), + id: 'new', commandId: 'browser.new' as ShortcutCommandId, order: 30, title: () => t('guide.title'), description: () => t('guide.description'), icon: IconGlobeOutlineRegular, }], } diff --git a/packages/client/ui-sidebar-browser/src/client/index.ts b/packages/client/ui-sidebar-browser/src/client/index.ts index 780f7f2d85..0efa715424 100644 --- a/packages/client/ui-sidebar-browser/src/client/index.ts +++ b/packages/client/ui-sidebar-browser/src/client/index.ts @@ -1,4 +1,5 @@ /** Register the HTTP(S) Browser tab type in the right Sidebar. */ +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -42,6 +43,22 @@ export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs'] export function apply(ctx: Context): void { const namespace = 'sidebarBrowser' const t = ctx.locale.bind(namespace) + ctx.inject(['shortcuts'], (ctx) => { + ctx.effect(() => ctx.shortcuts.register({ + id: 'browser.new' as ShortcutCommandId, label: () => t('guide.title'), aliases: ['browser', 'new browser tab'], + defaults: { desktop: { code: 'KeyT', modifiers: ['primary'] } }, + regions: ['page', 'editable', 'terminal'], modals: [], + availability: { + getSnapshot: () => ctx.sidebarRight.commandTarget() === undefined ? t('shortcut.noSession') : null, + subscribe: listener => ctx.sidebarRight.interaction.subscribe(listener), + }, + resolve: ({ target: element }) => { + const target = ctx.sidebarRight.commandTarget(element) + if (target === undefined) return { status: 'blocked', reason: t('shortcut.noSession') } + return { status: 'handled', run: () => { ctx.sidebarRight.openTabFromTarget('browser', target) } } + }, + }), 'ui-sidebar-browser: shortcut') + }) const store = createBrowserStore() const openTabs = ctx.sidebarRight.openTabs const carrier = (globalThis as typeof globalThis & { diff --git a/packages/client/ui-sidebar-browser/src/client/locales.ts b/packages/client/ui-sidebar-browser/src/client/locales.ts index 3c0d305b66..acf00a50e2 100644 --- a/packages/client/ui-sidebar-browser/src/client/locales.ts +++ b/packages/client/ui-sidebar-browser/src/client/locales.ts @@ -3,6 +3,7 @@ export const zh = { 'type.label': '浏览器', 'guide.title': '浏览器', 'guide.description': '浏览网页', + 'shortcut.noSession': '请先打开一个会话', 'address.placeholder': '输入 HTTP(S) 地址', 'address.changed': 'URL 已变化', back: '后退', @@ -35,6 +36,7 @@ export const en = { 'type.label': 'Browser', 'guide.title': 'Browser', 'guide.description': 'Browse web pages', + 'shortcut.noSession': 'Open a session first', 'address.placeholder': 'Enter an HTTP(S) address', 'address.changed': 'URL changed', back: 'Back', diff --git a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx index 7c5a7e455b..23a8a332f7 100644 --- a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx +++ b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx @@ -1,12 +1,12 @@ /** Common browser chrome; a presentation adapter attaches the page inside its content container. */ -import { useId, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import type { FormEvent, ReactNode } from 'react' import { Button, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconLinkOutlineRegular, - IconRefreshOutlineRegular, + IconRefreshOutlineRegular, Tooltip, IconRightUpOutlineRegular, SHIELD_OUTLINE_PATH, ICON_REGULAR_STROKE, @@ -47,6 +47,7 @@ function useBrowserDraft(url: string | undefined, revision: number): readonly [s export function BrowserBody(props: BrowserBodyProps): ReactNode { const { mount, loadUrl, restore, goBack, goForward, reload, setSandbox, useBrowserState, useStore, useTabInfo, t } = props const { tab } = useTabInfo() + useEffect(() => tab.actions.bindCommands({ refresh: () => { reload(tab.id) } }), [tab.actions, tab.id, reload]) const saved = useStore(state => state.byTab[tab.id]) const initial = useRef(saved) const initialUrl = useRef(tab.navigation.params?.url) @@ -80,7 +81,9 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
- + + +
{ dictionaries.delete(namespace) } }), } - ctx.provide('sidebarRightTabs', tabs as never) const openTabs = createSnapshotStore([]) - ctx.provide('sidebarRight', { openTabs } as never) + const target = { sessionId: 'session', paneId: 'pane' } + const sidebar = { openTabs, commandTarget: vi.fn<() => typeof target | undefined>(() => target), + openTabFromTarget: vi.fn(), interaction: { subscribe: vi.fn<(listener: () => void) => () => void>(() => () => {}) } } + const registry = new ShortcutRegistry(runtime, platform) + ctx.provide('sidebarRight', sidebar as never) + ctx.provide('shortcuts', { register: (command: ShortcutCommand) => registry.register(command) } as never) + ctx.provide('sidebarRightTabs', tabs as never) ctx.provide('workspaces', { list: createSnapshotStore({ phase: 'ready', items: [] }) } as never) ctx.provide('slots', slots as never) ctx.provide('locale', locale as never) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { tabs, registered, dictionaries, fiber, openTabs } + return { tabs, registered, dictionaries, fiber, openTabs, registry, sidebar, target } } describe('ui-sidebar-browser apply', () => { @@ -135,6 +142,33 @@ describe('ui-sidebar-browser apply', () => { expect(typeof browser.mount).toBe('function') }) + it.each(['macos', 'windows'] as const)('opens the resolved Browser target with the %s default and releases the shortcut', async (platform) => { + const h = await boot(platform) + try { + expect(h.registry.catalog.getSnapshot()[0]?.keys).toEqual(platform === 'macos' ? ['⌘', 'T'] : ['Ctrl', '+', 'T']) + expect(h.tabs.get(BROWSER_KIND)?.guide?.[0]?.commandId).toBe('browser.new') + const gesture = { code: 'KeyT', control: platform === 'windows', meta: platform === 'macos', + alt: false, shift: false, repeat: false, composing: false, defaultPrevented: false } + const context = { region: 'page' as const, modal: null, target: null } + const consume = vi.fn() + expect(h.registry.dispatch(gesture, context, consume).status).toBe('handled') + expect(h.sidebar.openTabFromTarget).toHaveBeenCalledExactlyOnceWith('browser', h.target) + h.registry.dispatch({ ...gesture, repeat: true }, context, consume) + expect(h.sidebar.openTabFromTarget).toHaveBeenCalledOnce() + h.sidebar.commandTarget.mockReturnValue(undefined) + h.sidebar.interaction.subscribe.mock.calls[0]?.[0]() + expect(h.registry.dispatch(gesture, context, consume).status).toBe('blocked') + expect(h.registry.catalog.getSnapshot()[0]?.unavailableReason).toBe('shortcut.noSession') + } finally { await h.fiber.dispose() } + expect(h.registry.catalog.getSnapshot()).toEqual([]) + }) + + it('publishes the macOS Web Browser default', async () => { + const h = await boot('macos', 'web') + try { expect(h.registry.catalog.getSnapshot()[0]?.binding).toEqual({ code: 'KeyT', modifiers: ['alt', 'meta'] }) } + finally { await h.fiber.dispose() } + }) + it('removes every registration when the plugin is disposed', async () => { const { tabs, registered, dictionaries, fiber } = await boot() await fiber.dispose() diff --git a/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx b/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx index 09ca4e75d1..65fd91d325 100644 --- a/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx +++ b/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx @@ -46,7 +46,7 @@ function mountBrowser(navigation?: { readonly url?: string }, const injected = createBrowserControllers(store.actions, options.createPage ?? createIframePage, () => true) controllers.push(injected) const { keyedHooks, ...commands } = injected - const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn() } + const tabActions = { bindCommands: vi.fn(() => vi.fn()), openResource: vi.fn(), openTab: vi.fn(), close: vi.fn() } const props: Pick & Omit = { sessionId: SESSION, diff --git a/packages/client/ui-sidebar-browser/tsconfig.client.json b/packages/client/ui-sidebar-browser/tsconfig.client.json index 875abf3b12..d09540aeb5 100644 --- a/packages/client/ui-sidebar-browser/tsconfig.client.json +++ b/packages/client/ui-sidebar-browser/tsconfig.client.json @@ -15,6 +15,7 @@ { "path": "../../api/workspace-controller/tsconfig.client.json" }, { "path": "../../util/brand" }, { "path": "../locale/tsconfig.client.json" }, + { "path": "../shortcuts/tsconfig.client.json" }, { "path": "../store" }, { "path": "../ui-dockkit" }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index d46092d14f..6f80d78afb 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: 510e43fb723039b918672a58ec2bd26cf3b6023a -README.zh.md: 5bd9fba7c7f60f7f477afb82b0e88a5dd8b5ac30 +README.md: cb8c9e889790216cf3ed11f8eda17a00ee80f347 +README.zh.md: 93477f2c7205736cc5cd08ef300da940c67f7f79 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 510e43fb72..cb8c9e8897 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -48,6 +48,8 @@ After all text pages load, Markdown images use the authenticated `/api/file` rou Developer tools selects the HTML preview policy in both Web and desktop. The renderer receives `interactivePreview` from plugin assembly. Off uses a DOMPurify-sanitized complete static document in an iframe with no sandbox permissions: CSP blocks scripts, external resources, connections, forms, and nested frames; all `href` and `xlink:href` attributes, refresh directives, and declarative shadow roots are removed before reparsing. Inline styles and data images remain visible, and related files are not read. On uses the scripted Blob preview described below. A mode change unmounts the previous frame and aborts its pending related-file reads. Static preview releases CSS/JS resource subscriptions and keeps the root file watched. Other document formats retain their own policies. +Both HTML modes set the initial iframe name to `dsh-sidebar-html-` for Desktop shortcut routing. This correlation does not grant the preview access to the parent document. + The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads: - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; loaded content remains while metadata is unavailable. @@ -108,6 +110,8 @@ Office registration, loading, caching, and font notices live in `src/client/offi The Office Remote returns converted PDFs as native `Uint8Array` values through Connection's multipart binary transport. Renderers borrow retained bytes read-only and copy them before Worker transfer. +The page refresh shortcut reuses the selected preview’s reload operation, including its request retirement and scroll retention. The header reload tooltip and ARIA combination follow the effective shortcut binding. + diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 5bd9fba7c7..93477f2c72 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -48,6 +48,8 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路 Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从插件组装层接收 `interactivePreview`。关闭时,将经 DOMPurify 清理的完整静态文档放入不授予沙箱权限的 iframe:CSP 禁止脚本、外部资源、连接、表单和嵌套框架;所有 `href` 和 `xlink:href` 属性、刷新指令及声明式 Shadow DOM 均在重新解析前被移除。行内样式和 data 图片仍可显示,不读取关联文件。开启时使用下述支持脚本的 Blob 预览。切换模式会卸载之前的框架并中止其待处理关联文件读取。静态预览释放 CSS/JS 的 Resource 订阅,根文件继续监听。其他文档格式保持各自策略。 +两种 HTML 模式均将 iframe 初始名称设为 `dsh-sidebar-html-`,供 Desktop 快捷键路由关联目标。这种关联不授予预览访问父文档的权限。 + 正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取: - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;元数据不可用时保留已加载内容。 @@ -108,6 +110,8 @@ Office 注册、加载、缓存和字体提示位于 `src/client/office/`。注 Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8Array` PDF。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 +页面刷新快捷键复用所选预览的重载操作,包括旧请求失效与滚动位置保留。头部重载 Tooltip 和 ARIA 组合随有效快捷键绑定更新。 + diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index b9e03e1748..3e1a2be166 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -172,6 +172,7 @@ export function TextPreview({ else if (mode === 'bytes-complete') reloadAll(tab.id, file, signal, observedVersion) else rendererReload() }, [canRead, mode, reloadPages, reloadAll, rendererReload, tab.id, file, signal, observedVersion]) + useEffect(() => tab.actions.bindCommands({ refresh: reload }), [tab.actions, reload]) useEffect(() => { if (state?.autoRefresh && changed && current !== undefined && !current.loading && meta.status === 'live') reload() }, [state?.autoRefresh, changed, current?.loading, meta.status, reload]) @@ -307,12 +308,13 @@ export function TextPreview({ - +