diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 1e05757729..b44d570223 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: 08c32be1d519b9d5185decf6811dc92939649f6e -README.zh.md: 70d8f969a1e3f2e7fd8af90c3d170a9bf60adefa +README.md: 23c83c0eadd4aac736cd6ed1d11fd7628da70674 +README.zh.md: 8d5eb440e701f4c029dbd4d740f85bd8d097fcf6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 08c32be1d5..23c83c0ead 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -24,7 +24,7 @@ The original artwork lives in `resources/icon.png` and `resources/icon.svg`; pla Shortcut overrides are stored in `app.getPath('userData')/keybindings.json`, separately from `DSH_HOME`. The main process validates and serializes changes before publishing accepted bindings. Failed reads retain the last accepted bindings and block edits, including Restore All; unreadable and future-version files remain unchanged. Development can isolate these preferences with `DSH_DESKTOP_USER_DATA_DIR`; the launcher prints its resolved path. See the [shortcut service](../../packages/client/shortcuts/README.md) for format and conflict semantics. -The macOS File menu displays the accepted single-key binding, including arrow keys, and routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document, embedded-frame, and browser-guest input, including editing and terminal input. Recording and input-method composition remain protected. Two-key chords leave the first key’s initial press available to the page and do not intercept its release; complete chords and their repeats are consumed. The renderer leaves configurable binding dispatch to the native adapter. Chords have no native menu accelerator. Accepted commands are forwarded once through the trusted preload. Linux dispatches main-document shortcuts through the DOM and forwards accepted embedded-frame bindings to the client resolver. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks. The [close decision](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md) records this lifecycle choice. +The macOS File menu displays the accepted single-key binding, including arrow keys, and routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document, embedded-frame, and browser-guest input, including editing and terminal input. Recording and input-method composition remain protected. Update overlays block product shortcuts and editing-key delivery to the parent window and its browser guests from creation until the last overlay closes. Every overlay opening or closing invalidates pending chord state. The main process shares one overlay owner between update dialogs and shortcut input through explicit creation and input-state capabilities. Two-key chords leave the first key’s initial press available to the page and do not intercept its release; complete chords and their repeats are consumed. The renderer leaves configurable binding dispatch to the native adapter. Chords have no native menu accelerator. Accepted commands are forwarded once through the trusted preload. Linux dispatches main-document shortcuts through the DOM and forwards accepted embedded-frame bindings to the client resolver. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks. The [close decision](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md) records this lifecycle choice. The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release. @@ -112,6 +112,7 @@ pnpm run start:desktop The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. +The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile in [their own Client test project](../../tsconfig.desktop-keyboard-tests.json), reached by the repository Client typecheck. Its Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules. ### Startup onboarding diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 70d8f969a1..8d5eb440e7 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -24,7 +24,7 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie 快捷键覆盖保存在 `app.getPath('userData')/keybindings.json`,与 `DSH_HOME` 分离。主进程校验并串行保存修改后才发布已接受键位。读取失败保留上次接受的键位并阻止编辑,包括全部恢复;不可读和未来版本的文件保持不变。开发时可通过 `DSH_DESKTOP_USER_DATA_DIR` 隔离这些偏好,启动器会输出解析后的路径。格式和冲突语义见[快捷键服务](../../packages/client/shortcuts/README.zh.md)。 -macOS“文件”菜单显示已接受的单键绑定(包括方向键),并通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档、内嵌 frame 和浏览器 guest 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 通过 DOM 分发主文档快捷键,并将已接受的内嵌 frame 绑定转发给 Client 解析器。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。[关窗决策](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)记录了这一生命周期选择。 +macOS“文件”菜单显示已接受的单键绑定(包括方向键),并通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档、内嵌 frame 和浏览器 guest 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。更新蒙层从创建到最后一个蒙层关闭期间阻挡父窗口及其浏览器 guest 的产品快捷键和编辑按键递送。每次打开或关闭蒙层都会作废待完成的组合键状态。主进程通过显式的创建能力和输入状态读取能力,让更新对话框与快捷键输入共享同一个蒙层管理实例。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 通过 DOM 分发主文档快捷键,并将已接受的内嵌 frame 绑定转发给 Client 解析器。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。[关窗决策](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)记录了这一生命周期选择。 macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。 @@ -112,6 +112,7 @@ pnpm run start:desktop Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 +[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)在[独立的 Client 测试项目](../../tsconfig.desktop-keyboard-tests.json)中编译,由仓库 Client 类型检查纳入。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。 ### 启动引导 diff --git a/apps/desktop/src/keyboard.ts b/apps/desktop/src/keyboard.ts index 4cd994f8f7..d41f6b47c2 100644 --- a/apps/desktop/src/keyboard.ts +++ b/apps/desktop/src/keyboard.ts @@ -12,10 +12,12 @@ import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts' * @param userData - Electron-resolved device preference directory. * @param platform - local device platform. * @param updateMenu - rebuild the application menu when the close accelerator or availability changes. + * @param overlayInput - Current shell-owned input blocking state for the product window. * @returns menu construction, editor key delivery, window attachment, and teardown operations. */ export function installDesktopShortcuts( getWindow: () => BrowserWindow | undefined, userData: string, platform: ShortcutPlatform, updateMenu: () => void, + overlayInput: (window: BrowserWindow) => { readonly revision: number; readonly blocked: boolean }, ): { fileMenu(labels: { fileMenu: string; closePage: string }): MenuItemConstructorOptions /** @@ -48,7 +50,7 @@ export function installDesktopShortcuts( const sendMenuClose = (): void => { const window = getWindow() if (window === undefined || window.isDestroyed() || !window.isFocused() || !window.isEnabled() - || revision === undefined || recording) return + || revision === undefined || recording || overlayInput(window).blocked) return window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: 'menu', commandId: 'page.close', revision }) } let keys = new Set() @@ -81,10 +83,10 @@ export function installDesktopShortcuts( persistence.setDefinitions(definitions) return persistence.readCurrent() }) - ipcMain.handle(DESKTOP_IPC.shortcutsEdit, async (event, input: unknown, revision: unknown) => { + ipcMain.handle(DESKTOP_IPC.shortcutsEdit, async (event, input: unknown, expectedRevision: unknown) => { assertSender(event) - if (typeof revision !== 'string') throw new Error('desktop shortcuts: invalid revision') - return persistence.edit(parseShortcutEdit(input), revision as ShortcutRevision) + if (typeof expectedRevision !== 'string') throw new Error('desktop shortcuts: invalid revision') + return persistence.edit(parseShortcutEdit(input), expectedRevision as ShortcutRevision) }) ipcMain.handle(DESKTOP_IPC.shortcutsRecording, (event, active: unknown) => { const window = assertSender(event) @@ -94,7 +96,8 @@ export function installDesktopShortcuts( }) ipcMain.handle(DESKTOP_IPC.shortcutsCloseWindow, (event, expected: unknown) => { const window = assertSender(event) - if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled()) return + if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled() + || overlayInput(window).blocked) return window.close() }) function attachInput(window: BrowserWindow, contents: WebContents, guestName?: DesktopBrowserLeaseId): () => void { @@ -103,6 +106,7 @@ export function installDesktopShortcuts( const consumed = new Map() let inputFrame: typeof contents.focusedFrame = null let inputRevision: ShortcutRevision | undefined + let overlayRevision = overlayInput(window).revision const resetInput = (): void => { deadKey = false; held.clear(); consumed.clear(); inputFrame = null; inputRevision = undefined if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) @@ -122,6 +126,10 @@ export function installDesktopShortcuts( if (event.isMainFrame && !event.isSameDocument) clear() } const beforeInput = (event: Electron.Event, input: Input): void => { + const overlay = overlayInput(window) + if (overlay.revision !== overlayRevision) { resetInput(); overlayRevision = overlay.revision } + if (overlay.blocked) { event.preventDefault(); return } + if (event.defaultPrevented) { resetInput(); return } if (editingInput === contents) { held.clear() consumed.clear() @@ -216,7 +224,7 @@ export function installDesktopShortcuts( return { sendEditingKey(keyCode, modifiers) { const window = getWindow() - if (window === undefined || window.isDestroyed()) return + if (window === undefined || window.isDestroyed() || overlayInput(window).blocked) return const contents = [...guestInputs].find(([guest, owner]) => owner.window === window && !guest.isDestroyed() && guest.isFocused())?.[0] ?? window.webContents contents.focus() diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2b93427bf1..4225e39904 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -49,6 +49,7 @@ import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.t import { readDesktopRuntime } from './runtime-tree.ts' import { DesktopBrowserGuests } from './browser-guests.ts' import { installDesktopShortcuts } from './keyboard.ts' +import { DesktopUpdateOverlays } from './update-overlay.ts' let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} @@ -313,7 +314,8 @@ async function main(): Promise { const currentMainWindow = (): BrowserWindow | undefined => mainWindow const ordinaryDialogs = new Set() const currentDialogWindow = (): BrowserWindow | undefined => welcomeWindow ?? mainWindow - const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale) + const updateOverlays = new DesktopUpdateOverlays() + const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale, updateOverlays) const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise => { const controller = new AbortController() @@ -569,7 +571,7 @@ async function main(): Promise { installDesktopDirectoryPicker(() => mainWindow) installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents) const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath('userData'), - process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() }) + process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() }, window => updateOverlays.input(window)) app.on('will-quit', () => { shortcuts.dispose() }) ipcMain.handle(DESKTOP_IPC.boot, async (event) => { @@ -1085,6 +1087,7 @@ async function main(): Promise { }, policyAuth?.request) const policy = mandatoryPolicy mandatoryUI = new DesktopMandatoryUpdateWindow({ + overlays: updateOverlays, preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale, allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow, policy: () => policy.state, update: () => updates.state, diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index cfca36679b..724df4ff97 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -5,7 +5,7 @@ import type { DesktopLocale } from './locale.ts' import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts' import { MANDATORY_IPC } from './mandatory-update-ipc.ts' -import { createUpdateOverlay } from './update-overlay.ts' +import type { DesktopUpdateOverlays } from './update-overlay.ts' import { DesktopUpdateAttention } from './update-attention.ts' /** A renderer action never carries a URL or authorizes a different version. */ @@ -33,6 +33,7 @@ export interface MandatoryUpdateApi { /** Main-process operations owned by the policy client, updater, and application lifecycle. */ export interface MandatoryUpdateWindowOptions { readonly preload: string + readonly overlays: Pick readonly locale: DesktopLocale readonly allowedPageOrigins: readonly string[] readonly parent: () => BrowserWindow | undefined @@ -190,7 +191,7 @@ export class DesktopMandatoryUpdateWindow { if (this.window === undefined) { const parent = this.options.parent() if (parent === undefined) return - const window = createUpdateOverlay(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false) + const window = this.options.overlays.create(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false) this.window = window window.setMenu(null) window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } }) diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index a0ac654f57..db0fd390e6 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -1,7 +1,7 @@ /** Main-owned update confirmations; closing or replacing a dialog never grants installation permission. */ import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron' import type { DesktopLocale } from './locale.ts' -import { createUpdateOverlay } from './update-overlay.ts' +import type { DesktopUpdateOverlays } from './update-overlay.ts' /** Channels available only to the isolated update-dialog document. */ export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const @@ -51,8 +51,10 @@ export class DesktopUpdateDialog { /** * @param preload - Bundled isolated preload. * @param locale - Shell-owned copy or a reader of the current UI language. + * @param overlays - Application-owned overlay creation and input tracking. */ - constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale)) { + constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale), + private readonly overlays: Pick) { ipcMain.handle(UPDATE_DIALOG_IPC.status, (event) => { this.owned(event); return this.active?.view ?? null }) ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, revision: unknown, index: unknown) => { this.owned(event) @@ -83,7 +85,7 @@ export class DesktopUpdateDialog { clearTimeout(this.closing) this.closing = undefined const existing = this.window - const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? locale.messages.updateTitle, false) + const window = existing ?? this.overlays.create(parent, this.preload, options.title ?? locale.messages.updateTitle, false) this.window = window this.parent = parent const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message, diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index a6e341ecb9..71483d3faf 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -1,49 +1,69 @@ /** Shell-owned modal windows cover the parent's content without replacing its native window controls. */ import { BrowserWindow } from 'electron' -/** - * @param parent - Product window whose content is blocked while the overlay is open. - * @param preload - Isolated shell-only preload. - * @param title - Localized window title. - * @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets. - * @returns A transparent child that follows its parent's content bounds and releases its listeners on close. - */ -export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow { - const window = new BrowserWindow({ - parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true, - ...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false, - skipTaskbar: true, hasShadow: false, title, - webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, - }) - // macOS native modals animate the entire viewport as a sheet. - const focus = (): void => { if (!window.isDestroyed()) window.focus() } - const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() } - if (!nativeModal && process.platform === 'darwin') { - parent.on('focus', focus) - parent.webContents.on('before-input-event', blockInput) - window.once('closed', () => { - parent.off('focus', focus) - parent.webContents.off('before-input-event', blockInput) +const unblockedInput = { revision: 0, blocked: false } as const + +/** Tracks application-owned update overlays and their parent input state. */ +export class DesktopUpdateOverlays { + private readonly inputStates = new WeakMap() + + /** + * @param parent - Product window whose input may belong to an update dialog. + * @returns Current blocking state; its revision changes whenever an overlay opens or closes. + */ + input(parent: BrowserWindow): { readonly revision: number; readonly blocked: boolean } { + return this.inputStates.get(parent) ?? unblockedInput + } + + /** + * @param parent - Product window whose content is blocked while the overlay is open. + * @param preload - Isolated shell-only preload. + * @param title - Localized window title. + * @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets. + * @returns A transparent child that follows its parent's content bounds and releases its listeners on close. + */ + create(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow { + const window = new BrowserWindow({ + parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true, + ...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false, + skipTaskbar: true, hasShadow: false, title, + webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, }) + const inputState = this.inputStates.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } } + this.inputStates.set(parent, inputState) + inputState.active++; inputState.revision++ + window.once('closed', () => { inputState.active--; inputState.revision++ }) + // macOS native modals animate the entire viewport as a sheet. + const focus = (): void => { if (!window.isDestroyed()) window.focus() } + const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() } + if (!nativeModal && process.platform === 'darwin') { + parent.on('focus', focus) + // Shell dialogs block input before product shortcut listeners can dispatch it. + parent.webContents.prependListener('before-input-event', blockInput) + window.once('closed', () => { + parent.off('focus', focus) + parent.webContents.off('before-input-event', blockInput) + }) + } + const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) } + parent.on('move', follow) + parent.on('resize', follow) + let closed = false + let blur: string | undefined + const unblur = (): void => { + if (blur === undefined || parent.isDestroyed()) return + void parent.webContents.removeInsertedCSS(blur).catch((error: unknown) => { console.warn('desktop update: could not remove background blur', error) }) + blur = undefined + } + void parent.webContents.insertCSS('body { filter: blur(2px) !important; }').then((key) => { + blur = key + if (closed) unblur() + }).catch((error: unknown) => { console.warn('desktop update: could not blur background', error) }) + window.once('closed', () => { closed = true; unblur() }) + window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) }) + window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) + window.setMenu(null) + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + return window } - const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) } - parent.on('move', follow) - parent.on('resize', follow) - let closed = false - let blur: string | undefined - const unblur = (): void => { - if (blur === undefined || parent.isDestroyed()) return - void parent.webContents.removeInsertedCSS(blur).catch((error: unknown) => { console.warn('desktop update: could not remove background blur', error) }) - blur = undefined - } - void parent.webContents.insertCSS('body { filter: blur(2px) !important; }').then((key) => { - blur = key - if (closed) unblur() - }).catch((error: unknown) => { console.warn('desktop update: could not blur background', error) }) - window.once('closed', () => { closed = true; unblur() }) - window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) }) - window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) - window.setMenu(null) - window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) - return window } diff --git a/apps/desktop/tests/keyboard.spec.ts b/apps/desktop/tests/keyboard.spec.ts index 210ba9852b..3d435e52f8 100644 --- a/apps/desktop/tests/keyboard.spec.ts +++ b/apps/desktop/tests/keyboard.spec.ts @@ -7,19 +7,34 @@ import { afterEach, expect, it, onTestFinished, vi } from 'vitest' import type { BrowserWindow, WebContents, WebFrameMain } from 'electron' import type { DesktopShortcutInput, ShortcutBinding, ShortcutCommandId, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' -import { ShortcutRegistry } from '../../../packages/client/shortcuts/src/client/registry.ts' -import { installKeyboard } from '../../../packages/client/shortcuts/src/client/dom.ts' -import { installNativeKeyboard } from '../../../packages/client/shortcuts/src/client/native.ts' +import { ShortcutRegistry } from '@deepseek-ai/dsh-client-shortcuts/src/client/registry.ts' +import { installKeyboard } from '@deepseek-ai/dsh-client-shortcuts/src/client/dom.ts' +import { installNativeKeyboard } from '@deepseek-ai/dsh-client-shortcuts/src/client/native.ts' import type { DesktopBrowserLeaseId, DesktopBrowserReservation } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' import { DESKTOP_IPC } from '../src/ipc.ts' const ipc = vi.hoisted(() => ({ handle: vi.fn(), removeHandler: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: ipc, app: { isPackaged: true }, session: { fromPartition: () => ({ +const overlays = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + class Window extends EventEmitter { + private destroyed = false + readonly webContents = { setWindowOpenHandler: vi.fn() } + readonly focus = vi.fn() + readonly show = vi.fn() + readonly setBounds = vi.fn() + setMenu() {} + isDestroyed() { return this.destroyed } + destroy() { this.destroyed = true; this.emit('closed') } + } + return { Window } +}) +vi.mock('electron', () => ({ ipcMain: ipc, BrowserWindow: overlays.Window, app: { isPackaged: true }, session: { fromPartition: () => ({ setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(), setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), webRequest: { onBeforeRequest: vi.fn() }, }) } })) const { installDesktopShortcuts } = await import('../src/keyboard.ts') const { DesktopBrowserGuests } = await import('../src/browser-guests.ts') +const { DesktopUpdateOverlays } = await import('../src/update-overlay.ts') afterEach(() => { vi.clearAllMocks() }) type FrameFixture = { url: WebFrameMain['url']; name: WebFrameMain['name']; parent: FrameFixture | null } @@ -40,6 +55,7 @@ type KeyboardFixture = Omit, 'attach' const installFixture = installDesktopShortcuts as ( getWindow: () => WindowFixture | undefined, userData: string, platform: Parameters[2], updateMenu: () => void, + overlayInput: (window: WindowFixture) => { readonly revision: number; readonly blocked: boolean }, ) => KeyboardFixture type GuestsFixture = { acquire(owner: ContentsFixture, workspace: unknown): DesktopBrowserReservation @@ -51,6 +67,16 @@ function desktopDefaults(binding: ShortcutBinding): ShortcutDefinition['defaults return { 'desktop:macos': binding, 'desktop:windows': binding, 'desktop:linux': binding } } +function browserGuest(reservation: DesktopBrowserReservation) { + const frame: FrameFixture = { url: `about:blank#${reservation.lease}`, name: '', parent: null } + const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + getURL: () => frame.url, isDestroyed: () => false, isFocused: vi.fn(() => true), + setWindowOpenHandler: vi.fn(), setIgnoreMenuShortcuts: vi.fn(), send: vi.fn(), close: vi.fn(), + focus: vi.fn(), sendInputEvent: vi.fn() }) + onTestFinished(() => { guest.emit('destroyed') }) + return { frame, guest } +} + async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') { const root = await mkdtemp(join(tmpdir(), 'dsh-keyboard-')) onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) @@ -62,7 +88,8 @@ async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') { isFocused: vi.fn(() => true), isEnabled: vi.fn(() => true), close: vi.fn() }) let current: WindowFixture | undefined = window const updateMenu = vi.fn() - const keyboard = installFixture(() => current, root, platform, updateMenu) + const updateOverlays = new DesktopUpdateOverlays() + const keyboard = installFixture(() => current, root, platform, updateMenu, window => updateOverlays.input(window as BrowserWindow)) keyboard.attach(current) onTestFinished(() => { keyboard.dispose() }) const handlers = new Map unknown>( @@ -72,7 +99,23 @@ async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') { const definitions: readonly ShortcutDefinition[] = [ { id: 'sidebar.left.toggle' as ShortcutCommandId, defaults: desktopDefaults({ code: 'KeyB', modifiers: ['primary'] }) }, ] - return { keyboard, window, contents, frame, event, handlers, call, definitions, updateMenu, detach: () => { current = undefined } } + return { keyboard, window, updateOverlays, contents, frame, event, handlers, call, definitions, updateMenu, + detach: () => { current = undefined } } +} + +function updateOverlayFixture(f: Awaited>) { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + onTestFinished(() => { platform.mockRestore() }) + const parent: WindowFixture & Pick = Object.assign(f.window, { + getContentBounds: () => ({ x: 0, y: 0, width: 900, height: 650 }), + }) + Object.assign(f.contents, { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }) + return () => { + const overlay = f.updateOverlays.create(parent as BrowserWindow, 'test-overlay-preload', 'Update', false) + onTestFinished(() => { if (!overlay.isDestroyed()) overlay.destroy() }) + expect(vi.spyOn(overlay, 'show')).not.toHaveBeenCalled() + return overlay + } } it('mirrors only successful bindings, suppresses recording menus, and invalidates pre-navigation drafts', async () => { @@ -97,6 +140,115 @@ it('mirrors only successful bindings, suppresses recording menus, and invalidate expect((await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('stale') }) +it('blocks macOS shortcuts while an update overlay loads and clears held and consumed keys before dismissal', async () => { + const f = await fixture() + const open = updateOverlayFixture(f) + const snapshot = await f.call(DESKTOP_IPC.shortcutsGet, [ + { id: 'page.close', defaults: { 'desktop:macos': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } }, + { id: 'sidebar.left.toggle', defaults: desktopDefaults({ code: 'KeyK', modifiers: ['primary'] }) }, + ]) + const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[] + const closeMenu = menu[0]!.click as () => void + const press = (code: string, type = 'keyDown', meta = false) => { + const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true } } + f.contents.emit('before-input-event', event, { type, code, key: code.slice(3), modifiers: meta ? ['meta'] : [], + meta, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + return event.defaultPrevented + } + f.contents.send.mockClear() + expect(press('KeyA')).toBe(false) + const first = open() + expect(press('KeyB')).toBe(true) + expect(press('KeyK', 'keyDown', true)).toBe(true) + closeMenu() + await f.call(DESKTOP_IPC.shortcutsCloseWindow, snapshot.revision) + f.keyboard.sendEditingKey('C', ['control']) + expect(f.window.close).not.toHaveBeenCalled() + expect(f.contents.sendInputEvent).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + first.destroy() + expect(press('KeyB')).toBe(false) + expect(press('KeyB', 'keyUp')).toBe(false) + expect(f.contents.send).not.toHaveBeenCalled() + expect(press('KeyA')).toBe(false) + expect(press('KeyB')).toBe(true) + expect(f.contents.send).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ code: 'KeyA', secondCode: 'KeyB' })) + const second = open() + expect(press('KeyB', 'keyUp')).toBe(true) + second.destroy() + expect(press('KeyB', 'keyUp')).toBe(false) + expect(press('KeyK', 'keyDown', true)).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ code: 'KeyK', meta: true })) + closeMenu() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'menu' })) + await f.call(DESKTOP_IPC.shortcutsCloseWindow, snapshot.revision) + expect(f.window.close).toHaveBeenCalledOnce() + f.keyboard.sendEditingKey('C', ['control']) + expect(f.contents.sendInputEvent).toHaveBeenCalledTimes(2) +}) + +it.each([false, true])('blocks approved browser guest input across update overlays, including guests attached while blocked: %s', async (attachWhileBlocked) => { + const f = await fixture() + const open = updateOverlayFixture(f) + await f.call(DESKTOP_IPC.shortcutsGet, [ + { id: 'page.close', defaults: { 'desktop:macos': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } }, + { id: 'sidebar.left.toggle', defaults: desktopDefaults({ code: 'KeyK', modifiers: ['primary'] }) }, + ]) + const guests = new DesktopBrowserGuests(() => undefined) as GuestsFixture + guests.bind(f.window, (guest, name) => f.keyboard.attachGuest(f.window, guest, name)) + const reservation = guests.acquire(f.contents, 'session:test') + const { frame, guest } = browserGuest(reservation) + const attach = () => { + const event = { preventDefault: vi.fn() } + f.contents.emit('will-attach-webview', event, {}, { src: frame.url, partition: reservation.partition }) + expect(event.preventDefault).not.toHaveBeenCalled() + f.contents.emit('did-attach-webview', {}, guest) + guest.emit('dom-ready') + } + if (!attachWhileBlocked) attach() + const first = open() + if (attachWhileBlocked) attach() + const press = (code: string, type = 'keyDown', meta = false) => { + const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true } } + guest.emit('before-input-event', event, { type, code, key: code.slice(3), modifiers: meta ? ['meta'] : [], + meta, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + return event.defaultPrevented + } + f.contents.send.mockClear() + expect(press('KeyK', 'keyDown', true)).toBe(true) + f.keyboard.sendEditingKey('C', ['control']) + expect(guest.sendInputEvent).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + const second = open() + first.destroy() + expect(press('KeyA')).toBe(true) + expect(press('KeyB')).toBe(true) + expect(f.contents.send).not.toHaveBeenCalled() + second.destroy() + expect(press('KeyB')).toBe(false) + expect(press('KeyB', 'keyUp')).toBe(false) + expect(press('KeyA')).toBe(false) + open().destroy() + expect(press('KeyB')).toBe(false) + expect(press('KeyB', 'keyUp')).toBe(false) + expect(f.contents.send).not.toHaveBeenCalled() + expect(press('KeyA')).toBe(false) + expect(press('KeyB')).toBe(true) + expect(f.contents.send).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'webview', frameName: reservation.lease, code: 'KeyA', secondCode: 'KeyB' })) + open().destroy() + expect(press('KeyB', 'keyUp')).toBe(false) + expect(press('KeyK', 'keyDown', true)).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(2) + f.keyboard.sendEditingKey('C', ['control']) + expect(guest.sendInputEvent).toHaveBeenCalledTimes(2) +}) + it('rejects other windows, subframes, remote/shell pages, and malformed edits', async () => { const f = await fixture() const get = f.handlers.get(DESKTOP_IPC.shortcutsGet)! @@ -563,12 +715,7 @@ it.each(['macos', 'windows', 'linux'] as const)('routes approved %s browser gues const attach = vi.fn((guest: ContentsFixture, name: DesktopBrowserLeaseId) => f.keyboard.attachGuest(f.window, guest, name)) guests.bind(f.window, attach) const reservation = guests.acquire(f.contents, 'session:test') - const frame: FrameFixture = { url: `about:blank#${reservation.lease}`, name: '', parent: null } - const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, - getURL: () => frame.url, isDestroyed: () => false, isFocused: vi.fn(() => true), - setWindowOpenHandler: vi.fn(), setIgnoreMenuShortcuts: vi.fn(), send: vi.fn(), close: vi.fn(), - focus: vi.fn(), sendInputEvent: vi.fn() }) - onTestFinished(() => { guest.emit('destroyed') }) + const { frame, guest } = browserGuest(reservation) const rejected = { preventDefault: vi.fn() } f.contents.emit('will-attach-webview', rejected, {}, { src: 'about:blank#unknown', partition: reservation.partition }) expect(rejected.preventDefault).toHaveBeenCalledOnce() diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts index a1c218852a..484d01065f 100644 --- a/apps/desktop/tests/mandatory-update-window.spec.ts +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { afterEach, expect, it, vi } from 'vitest' -import type { BrowserWindow } from 'electron' +import type { BrowserWindow, WebContents, WebFrameMain } from 'electron' import { DesktopMandatoryUpdateWindow, type MandatoryUpdateView } from '../src/mandatory-update-window.ts' import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts' import { resolveDesktopLocale } from '../src/locale.ts' @@ -13,7 +13,11 @@ vi.mock('electron', () => ({ ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler), removeHandler: (channel: string) => native.handlers.delete(channel), }, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } })) -vi.mock('../src/update-overlay.ts', () => ({ createUpdateOverlay: vi.fn(() => window) })) + +type WindowFixture = EventEmitter & Pick & { + webContents: EventEmitter & Pick & { mainFrame: Pick } + } let window: ReturnType let ui: DesktopMandatoryUpdateWindow | undefined @@ -32,8 +36,10 @@ function setup(platform: NodeJS.Platform = 'darwin') { let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' } let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' } const install = vi.fn(async () => update) - ui = new DesktopMandatoryUpdateWindow({ preload: 'owned', locale: resolveDesktopLocale('zh'), - allowedPageOrigins: ['https://downloads.example.com'], parent: () => window as unknown as BrowserWindow, + const createOverlay = (): WindowFixture => window + const parent = createOverlay as () => BrowserWindow + ui = new DesktopMandatoryUpdateWindow({ overlays: { create: parent }, preload: 'owned', locale: resolveDesktopLocale('zh'), + allowedPageOrigins: ['https://downloads.example.com'], parent, policy: () => policy, update: () => update, refresh: async () => {}, download: async () => update, install }) ui.sync() const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index 781fd7317e..f6c49ffeca 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -1,6 +1,7 @@ import { afterEach, expect, it, vi } from 'vitest' import type { BrowserWindow } from 'electron' import { DesktopUpdateDialog, UPDATE_DIALOG_IPC } from '../src/update-dialog.ts' +import { DesktopUpdateOverlays } from '../src/update-overlay.ts' import { resolveDesktopLocale, type DesktopLocale } from '../src/locale.ts' const fixture = await vi.hoisted(async () => { @@ -45,7 +46,7 @@ afterEach(() => { function setup(locale: DesktopLocale | (() => DesktopLocale) = resolveDesktopLocale('zh-CN')) { const parent = new fixture.FakeWindow({}) - dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale) + dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale, new DesktopUpdateOverlays()) const show = (signal?: AbortSignal) => dialogs!.show(parent as unknown as BrowserWindow, { message: '下载完成', buttons: ['安装并重启'], cancelId: 1, ...(signal === undefined ? {} : { signal }), }) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index 9f4a79878d..9d013af5ae 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -1,14 +1,18 @@ import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' -import { createUpdateOverlay } from '../src/update-overlay.ts' +import { expect, it, onTestFinished, vi } from 'vitest' +import type { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from 'electron' +import { DesktopUpdateOverlays } from '../src/update-overlay.ts' const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() })) vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } })) +type ParentFixture = EventEmitter & Pick & { + webContents: EventEmitter & Pick +} + it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => { const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - const parent = Object.assign(new EventEmitter(), { + const parent: ParentFixture = Object.assign(new EventEmitter(), { getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), isDestroyed: () => false, @@ -19,7 +23,7 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu }) native.create.mockReturnValue(window) try { - createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false) + new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false) expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) const event = { preventDefault: vi.fn() } parent.webContents.emit('before-input-event', event) @@ -30,3 +34,44 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu expect(parent.webContents.listenerCount('before-input-event')).toBe(0) } finally { platform.mockRestore() } }) + +it('blocks each parent until its last owned overlay closes and invalidates each transition', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + onTestFinished(() => { platform.mockRestore() }) + const parent = (): ParentFixture => Object.assign(new EventEmitter(), { + getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), + webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), + isDestroyed: () => false, + }) + const firstParent = parent() as BrowserWindow + const secondParent = parent() as BrowserWindow + const overlays = new DesktopUpdateOverlays() + const isolated = new DesktopUpdateOverlays() + native.create.mockImplementation(() => { + const window = Object.assign(new EventEmitter(), { + webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(), + setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false, + }) + onTestFinished(() => { window.emit('closed') }) + return window + }) + expect(overlays.input(firstParent)).toEqual({ revision: 0, blocked: false }) + const first = overlays.create(firstParent, 'owned', 'Update required', false) + const second = overlays.create(firstParent, 'owned', 'Confirm installation', false) + const other = overlays.create(secondParent, 'owned', 'Update required', false) + expect(overlays.input(firstParent)).toMatchObject({ revision: 2, blocked: true }) + expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true }) + expect(isolated.input(firstParent)).toEqual({ revision: 0, blocked: false }) + first.emit('closed') + expect(overlays.input(firstParent)).toMatchObject({ revision: 3, blocked: true }) + expect(firstParent.webContents.listenerCount('before-input-event')).toBe(1) + second.emit('closed') + expect(overlays.input(firstParent)).toMatchObject({ revision: 4, blocked: false }) + expect(firstParent.listenerCount('move')).toBe(0) + expect(firstParent.listenerCount('resize')).toBe(0) + expect(firstParent.listenerCount('focus')).toBe(0) + expect(firstParent.webContents.listenerCount('before-input-event')).toBe(0) + expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true }) + other.emit('closed') + expect(overlays.input(secondParent)).toMatchObject({ revision: 2, blocked: false }) +}) diff --git a/apps/web/tests/client-plugin-live.e2e.ts b/apps/web/tests/client-plugin-live.e2e.ts index 8c8ff2b55f..21c131a764 100644 --- a/apps/web/tests/client-plugin-live.e2e.ts +++ b/apps/web/tests/client-plugin-live.e2e.ts @@ -49,9 +49,6 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await trigger.click() const menu = page.getByRole('menu') await menu.waitFor() - expect(await menu.getByRole('menuitem').allTextContents()).toEqual([ - 'Pin session', 'Rename', 'Fork session', 'Archive session', 'Export session', 'Copy session ID', - ]) expect(await menu.getByRole('separator').count()).toBe(1) await compareOrRefreshGolden( SESSION_ACTION_EXPECTED, @@ -74,9 +71,12 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await row.hover() await trigger.click() await menu.waitFor() - expect(await menu.getByRole('menuitem').allTextContents()).toEqual([ - 'Pin session', 'Rename', 'Fork session', 'Archive session', - ]) + const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session'] + const remainingItems = menu.getByRole('menuitem') + expect(await remainingItems.count()).toBe(remainingNames.length) + for (const [index, name] of remainingNames.entries()) { + expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1) + } expect(await menu.getByRole('separator').count()).toBe(0) expect(console.pageErrors).toEqual([]) await assertFixtureInventory(EXPECTED, [ diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 50088b05f7..68dd75f3d8 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -186,15 +186,23 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S const blankViewport = page.viewportSize()! try { await blankColumn.locator('[data-sidebar-right-toggle]').click() + await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' }) await page.setViewportSize({ width: 767, height: blankViewport.height }) + await expect.poll(() => columns(page)).toEqual([56, 711, 0]) await page.locator('[data-sidebar-right-expand]').click() + // The fullscreen shell keeps its viewport box while its docked content closes. + await expect.poll(() => blankColumn.locator('[data-sidebar-right-open]').count()).toBe(1) + await expect.poll(() => blankColumn.locator('[data-dockkit-host="dock"]:not([hidden])').evaluate(element => getComputedStyle(element).transform)) + .toBe('none') await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox()) .toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height }) await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor() await blankColumn.locator('[data-sidebar-right-toggle]').click() + await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' }) } finally { await page.setViewportSize(blankViewport) } + await expect.poll(() => columns(page)).toEqual([280, blankViewport.width - 280, 0]) await page.locator('[data-sidebar-right-expand]').click() await blankColumn.locator('[data-dockkit-add-tab]').click() await blankColumn.locator('[data-sidebar-right-guide-entry="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 7c9861aec6..380d3abc84 100644 --- a/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md @@ -6,6 +6,55 @@ - searchbox "Search shortcuts" - region "Application": - list: + - 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: 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: Split + - button "Remove shortcut for Split" + - button "Edit shortcut for Split" + - button "Split ⌘ \\": ⌘ \ + - listitem: + - text: Archive session + - button "Remove shortcut for Archive session" + - button "Edit shortcut for Archive session" + - button "Archive session ⌥ ⌘ A": ⌥ ⌘ A + - listitem: + - text: Fork session + - button "Remove shortcut for Fork session" + - button "Edit shortcut for Fork session" + - button "Fork session ⇧ ⌘ F": ⇧ ⌘ F + - listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session ⌥ ⌘ N": ⌥ ⌘ N + - listitem: + - text: Rename session + - button "Remove shortcut for Rename session" + - button "Edit shortcut for Rename session" + - button "Rename session ⇧ ⌘ R": ⇧ ⌘ R + - listitem: + - text: Search sessions + - button "Remove shortcut for Search sessions" + - button "Edit shortcut for Search sessions" + - button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K + - listitem: + - text: Open settings + - button "Remove shortcut for Open settings" + - button "Edit shortcut for Open settings" + - button "Open settings ⌘ ,": ⌘ , - listitem: - text: Open keyboard shortcuts - button "Remove shortcut for Open keyboard shortcuts" @@ -22,102 +71,53 @@ - 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: New Session - - button "Remove shortcut for New Session" - - button "Edit shortcut for New Session" - - button "New Session ⌥ ⌘ N": ⌥ ⌘ N - - listitem: - - text: Search sessions - - button "Remove shortcut for Search sessions" - - button "Edit shortcut for Search sessions" - - button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K + - text: New terminal + - button "Remove shortcut for New terminal" + - button "Edit shortcut for New terminal" + - 'button "New terminal ⌃ `"': "⌃ `" - listitem: - text: Add workspace - button "Remove shortcut for Add workspace" - button "Edit shortcut for Add workspace" - button "Add workspace ⌥ ⌘ O": ⌥ ⌘ O - - listitem: - - text: Rename session - - button "Remove shortcut for Rename session" - - button "Edit shortcut for Rename session" - - button "Rename session ⇧ ⌘ R": ⇧ ⌘ R - - listitem: - - text: Fork session - - button "Remove shortcut for Fork session" - - button "Edit shortcut for Fork session" - - button "Fork session ⇧ ⌘ F": ⇧ ⌘ F - - listitem: - - text: Archive session - - button "Remove shortcut for Archive session" - - button "Edit shortcut for Archive session" - - button "Archive session ⌥ ⌘ A": ⌥ ⌘ A - - listitem: - - text: Open settings - - button "Remove shortcut for Open settings" - - button "Edit shortcut for Open settings" - - button "Open settings ⌘ ,": ⌘ , - 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: - listitem: - - text: Send message - - button "Send message Enter" [disabled]: Enter + - text: Use the complementary Queue / Steer action + - button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter + - listitem: + - text: Open reference menu + - button "Open reference menu @" [disabled]: "@" - listitem: - text: New line - button "New line ⇧ Enter" [disabled]: ⇧ Enter - listitem: - - text: Use the complementary Queue / Steer action - - button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter + - text: Send message + - button "Send message Enter" [disabled]: Enter - listitem: - text: Open command menu - button "Open command menu /" [disabled]: / - - listitem: - - text: Open reference menu - - button "Open reference menu @" [disabled]: "@" - listitem: - text: Stop generating - button "Stop generating Esc Esc" [disabled]: Esc Esc - region "Menus and dialogs": - heading "Menus and dialogs" [level=3] - list: + - listitem: + - text: Close menu or top dialog + - button "Close menu or top dialog Esc" [disabled]: Esc - listitem: - text: Move menu selection - button "Move menu selection ↑ ↓" [disabled]: ↑ ↓ - listitem: - text: Select menu item - button "Select menu item Enter" [disabled]: Enter - - listitem: - - text: Close menu or top dialog - - button "Close menu or top dialog Esc" [disabled]: Esc - region "Approval area": - heading "Approval area" [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 5a2447642e..9c92e5ffe3 100644 --- a/apps/web/tests/expected/shortcuts/en-US.expected.md +++ b/apps/web/tests/expected/shortcuts/en-US.expected.md @@ -6,6 +6,56 @@ - searchbox "Search shortcuts" - region "Application": - list: + - 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: 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: Split + - button "Remove shortcut for Split" + - button "Edit shortcut for Split" + - button "Split Ctrl + \\": Ctrl + \ + - listitem: + - text: Archive session + - button "Remove shortcut for Archive session" + - button "Edit shortcut for Archive session" + - button "Archive session Ctrl + Alt + A": Ctrl + Alt + A + - listitem: + - text: Fork session + - button "Remove shortcut for Fork session" + - button "Edit shortcut for Fork session" + - button "Fork session Ctrl + Shift + F": Ctrl + Shift + F + - listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session Ctrl + Alt + N": Ctrl + Alt + N + - listitem: + - text: Rename session + - button "Remove shortcut for Rename session" + - button "Edit shortcut for Rename session" + - button "Rename session Ctrl + Shift + R": Ctrl + Shift + R + - listitem: + - text: Search sessions + - button "Remove shortcut for Search sessions" + - button "Edit shortcut for Search sessions" + - button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K + - listitem: + - text: Open settings + - button "Remove shortcut for Open settings" + - button "Edit shortcut for Open settings" + - button "Open settings Ctrl + ,": Ctrl + , - listitem: - text: Open keyboard shortcuts - button "Remove shortcut for Open keyboard shortcuts" @@ -22,103 +72,53 @@ - 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: New Session - - button "Remove shortcut for New Session" - - button "Edit shortcut for New Session" - - button "New Session Ctrl + Alt + N": Ctrl + Alt + N - - listitem: - - text: Search sessions - - button "Remove shortcut for Search sessions" - - button "Edit shortcut for Search sessions" - - button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K + - text: New terminal + - button "Remove shortcut for New terminal" + - button "Edit shortcut for New terminal" + - 'button "New terminal Ctrl + `"': "Ctrl + `" - listitem: - text: Add workspace - button "Remove shortcut for Add workspace" - button "Edit shortcut for Add workspace" - button "Add workspace Ctrl + Alt + O": Ctrl + Alt + O - - listitem: - - text: Rename session - - button "Remove shortcut for Rename session" - - button "Edit shortcut for Rename session" - - button "Rename session Ctrl + Shift + R": Ctrl + Shift + R - - listitem: - - text: Fork session - - button "Remove shortcut for Fork session" - - button "Edit shortcut for Fork session" - - button "Fork session Ctrl + Shift + F": Ctrl + Shift + F - - listitem: - - text: Archive session - - button "Remove shortcut for Archive session" - - button "Edit shortcut for Archive session" - - button "Archive session Ctrl + Alt + A": Ctrl + Alt + A - - listitem: - - text: Open settings - - button "Remove shortcut for Open settings" - - button "Edit shortcut for Open settings" - - button "Open settings Ctrl + ,": Ctrl + , - 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: - listitem: - - text: Send message - - button "Send message Enter" [disabled]: Enter + - text: Use the complementary Queue / Steer action + - button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter + - listitem: + - text: Open reference menu + - button "Open reference menu @" [disabled]: "@" - listitem: - text: New line - button "New line Shift + Enter" [disabled]: Shift + Enter - listitem: - - text: Use the complementary Queue / Steer action - - button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter + - text: Send message + - button "Send message Enter" [disabled]: Enter - listitem: - text: Open command menu - button "Open command menu /" [disabled]: / - - listitem: - - text: Open reference menu - - button "Open reference menu @" [disabled]: "@" - listitem: - text: Stop generating - button "Stop generating Esc Esc" [disabled]: Esc Esc - region "Menus and dialogs": - heading "Menus and dialogs" [level=3] - list: + - listitem: + - text: Close menu or top dialog + - button "Close menu or top dialog Esc" [disabled]: Esc - listitem: - text: Move menu selection - button "Move menu selection ↑ ↓" [disabled]: ↑ ↓ - listitem: - text: Select menu item - button "Select menu item Enter" [disabled]: Enter - - listitem: - - text: Close menu or top dialog - - button "Close menu or top dialog Esc" [disabled]: Esc - region "Approval area": - heading "Approval area" [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 9eb7945d00..1a118a48d0 100644 --- a/apps/web/tests/expected/shortcuts/zh-CN.expected.md +++ b/apps/web/tests/expected/shortcuts/zh-CN.expected.md @@ -6,6 +6,55 @@ - searchbox "搜索快捷键" - region "应用操作": - list: + - listitem: + - text: 关闭当前页面/窗口 + - button "移除关闭当前页面/窗口快捷键" + - button "修改关闭当前页面/窗口快捷键" + - button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W + - listitem: + - text: 刷新当前页面 + - button "修改刷新当前页面快捷键" + - button "刷新当前页面 暂无快捷键": 暂无快捷键 + - listitem: + - text: 面板全屏/退出全屏 + - button "移除面板全屏/退出全屏快捷键" + - button "修改面板全屏/退出全屏快捷键" + - button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter + - listitem: + - text: 分栏 + - button "移除分栏快捷键" + - button "修改分栏快捷键" + - button "分栏 ⌘ \\": ⌘ \ + - listitem: + - text: 归档会话 + - button "移除归档会话快捷键" + - button "修改归档会话快捷键" + - button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A + - listitem: + - text: 分叉会话 + - button "移除分叉会话快捷键" + - button "修改分叉会话快捷键" + - button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F + - listitem: + - text: 新会话 + - button "移除新会话快捷键" + - button "修改新会话快捷键" + - button "新会话 ⌥ ⌘ N": ⌥ ⌘ N + - listitem: + - text: 重命名会话 + - button "移除重命名会话快捷键" + - button "修改重命名会话快捷键" + - button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R + - listitem: + - text: 搜索会话 + - button "移除搜索会话快捷键" + - button "修改搜索会话快捷键" + - button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K + - listitem: + - text: 打开设置 + - button "移除打开设置快捷键" + - button "修改打开设置快捷键" + - button "打开设置 ⌘ ,": ⌘ , - listitem: - text: 快捷键速查 - button "移除快捷键速查快捷键" @@ -22,102 +71,53 @@ - 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 "新会话 ⌥ ⌘ N": ⌥ ⌘ N - - listitem: - - text: 搜索会话 - - button "移除搜索会话快捷键" - - button "修改搜索会话快捷键" - - button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K + - text: 新建终端 + - button "移除新建终端快捷键" + - button "修改新建终端快捷键" + - 'button "新建终端 ⌃ `"': "⌃ `" - listitem: - text: 添加工作区 - button "移除添加工作区快捷键" - button "修改添加工作区快捷键" - button "添加工作区 ⌥ ⌘ O": ⌥ ⌘ O - - listitem: - - text: 重命名会话 - - button "移除重命名会话快捷键" - - button "修改重命名会话快捷键" - - button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R - - listitem: - - text: 分叉会话 - - button "移除分叉会话快捷键" - - button "修改分叉会话快捷键" - - button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F - - listitem: - - text: 归档会话 - - button "移除归档会话快捷键" - - button "修改归档会话快捷键" - - button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A - - listitem: - - text: 打开设置 - - button "移除打开设置快捷键" - - button "修改打开设置快捷键" - - button "打开设置 ⌘ ,": ⌘ , - listitem: - text: 工作区文件 - button "移除工作区文件快捷键" - button "修改工作区文件快捷键" - button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P - - listitem: - - text: 新建终端 - - button "移除新建终端快捷键" - - button "修改新建终端快捷键" - - 'button "新建终端 ⌃ `"': "⌃ `" - region "消息输入": - heading "消息输入" [level=3] - list: - listitem: - - text: 发送消息 - - button "发送消息 Enter" [disabled]: Enter + - text: 使用互补的 Queue/Steer 方式 + - button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter + - listitem: + - text: 打开引用菜单 + - button "打开引用菜单 @" [disabled]: "@" - listitem: - text: 换行 - button "换行 ⇧ Enter" [disabled]: ⇧ Enter - listitem: - - text: 使用互补的 Queue/Steer 方式 - - button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter + - text: 发送消息 + - button "发送消息 Enter" [disabled]: Enter - listitem: - text: 打开命令菜单 - button "打开命令菜单 /" [disabled]: / - - listitem: - - text: 打开引用菜单 - - button "打开引用菜单 @" [disabled]: "@" - listitem: - text: 停止生成 - button "停止生成 Esc Esc" [disabled]: Esc Esc - region "菜单与弹层": - heading "菜单与弹层" [level=3] - list: + - listitem: + - text: 关闭菜单或顶层弹窗 + - button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc - listitem: - text: 移动菜单选择 - button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓ - listitem: - text: 选择菜单项 - button "选择菜单项 Enter" [disabled]: Enter - - listitem: - - text: 关闭菜单或顶层弹窗 - - button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc - region "审批区域": - heading "审批区域" [level=3] - list: diff --git a/apps/web/tests/shortcuts-desktop.e2e.ts b/apps/web/tests/shortcuts-desktop.e2e.ts index 172d919126..1b8e0259fe 100644 --- a/apps/web/tests/shortcuts-desktop.e2e.ts +++ b/apps/web/tests/shortcuts-desktop.e2e.ts @@ -64,11 +64,11 @@ it.each([ }, marker) const console = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - const deliverPrimary = (code: string) => page.evaluate((input) => { + const deliverPrimary = (code: string, alt = false) => page.evaluate((input) => { window.shortcutFixture.deliver(input) }, { kind: 'keyboard', frameName: '', revision: snapshot.revision, code, - control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, repeat: false, + control: platform === 'windows', meta: platform === 'macos', alt, shift: false, repeat: false, } satisfies DesktopShortcutInput) const openReference = () => deliverPrimary('Slash') const group = page.getByRole('treeitem').first() @@ -79,14 +79,29 @@ it.each([ const composer = page.locator('[data-composer-input]').first() await composer.focus() await page.keyboard.insertText('Desktop focus draft') + const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') + const files = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + for (const opening of [{ code: 'KeyP', alt: false }, { code: 'KeyB', alt: true }]) { + await composer.focus() + await deliverPrimary(opening.code, opening.alt) + await panel.waitFor() + const focusedAfterOpening = await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => getComputedStyle(pane).outlineStyle)).toBe('none') + await deliverPrimary('KeyW') + await expect.poll(() => page.evaluate(() => ({ + closedWindows: window.shortcutFixture.closedWindows, + openPanels: document.querySelectorAll('[data-sidebar-right-open]').length, + }))).toEqual({ closedWindows: 0, openPanels: 0 }) + expect(focusedAfterOpening).toBe(true) + expect(await composer.innerText()).toBe('Desktop focus draft') + } + await composer.focus() const selectDraft = () => composer.evaluate((element) => { const text = element.querySelector('[data-lexical-text]')!.firstChild! document.getSelection()!.setBaseAndExtent(text, 0, text, 7) }) await selectDraft() await deliverPrimary('KeyP') - const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') - const files = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) await files.waitFor() expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop') @@ -96,6 +111,15 @@ it.each([ expect(await files.count()).toBe(1) expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop') + await composer.click() + const pane = panel.locator('[data-dockkit-pane]') + await pane.click({ position: { x: 20, y: 2 } }) + expect(await pane.evaluate(element => ({ + focused: document.activeElement === element, outline: getComputedStyle(element).outlineStyle, + }))).toEqual({ focused: true, outline: 'none' }) + await page.keyboard.press('Tab') + expect(await files.evaluate(element => element === document.activeElement && element.matches(':focus-visible'))).toBe(true) + expect(await files.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none') await deliverPrimary('KeyW') await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) expect(await page.evaluate(() => window.shortcutFixture.closedWindows)).toBe(0) @@ -247,6 +271,18 @@ it.each([ await page.keyboard.up('a') await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' }) await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0) + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + await openReference() + await dialog.waitFor() + await dialog.getByRole('searchbox').focus() + await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, { + kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyA', + control: platform === 'windows', meta: platform === 'macos', alt: false, shift: true, repeat: false, + } satisfies DesktopShortcutInput) + await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0) + expect(await dialog.isVisible()).toBe(true) expect(console.pageErrors).toEqual([]) expect(console.warnings).toEqual([]) } finally { await browser.close() } diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts index 7369666245..316cf5b67f 100644 --- a/apps/web/tests/shortcuts-panels.e2e.ts +++ b/apps/web/tests/shortcuts-panels.e2e.ts @@ -81,11 +81,28 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await page.keyboard.press(`${primary}+Alt+Enter`) expect(await composer.innerText()).toContain('T5 draft remains unsent') const eventCount = agent.session.seq + const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') - await bind(page, primary, 'Toggle right sidebar') + for (const { label, previous } of [ + { label: 'Browser', previous: undefined }, + { label: 'New terminal', previous: 'Browser' }, + ]) { + await bind(page, primary, label, previous) + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + const openedPage = label === 'Browser' ? panel.getByPlaceholder('Enter an HTTP(S) address') + : panel.locator('[data-sidebar-terminal]') + await openedPage.waitFor() + const focusedAfterOpening = await panel.locator('[data-dockkit-pane]').evaluate(pane => pane.contains(document.activeElement)) + await page.keyboard.press(`${primary}+Alt+W`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count(), { message: label }).toBe(0) + expect(focusedAfterOpening, label).toBe(true) + expect(await composer.innerText()).toBe('T5 draft remains unsent') + } + + await bind(page, primary, 'Toggle right sidebar', 'New terminal') 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() diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts index bd21136cb3..807dab594a 100644 --- a/apps/web/tests/shortcuts.e2e.ts +++ b/apps/web/tests/shortcuts.e2e.ts @@ -89,6 +89,8 @@ describe('web e2e: shortcut reference', () => { const boundActionsOpacity = () => boundRow.getByRole('button').first().evaluate(element => getComputedStyle(element.parentElement!).opacity) expect(await boundActionsOpacity()).toBe('0') expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Shift+Tab') + expect(await dialog.getByRole('button', { disabled: false }).last().evaluate(element => element === document.activeElement)).toBe(true) await badge.click() await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() await boundRow.getByRole('group').waitFor({ state: 'hidden' }) @@ -127,6 +129,8 @@ describe('web e2e: shortcut reference', () => { expect(await editButton.evaluate(element => element.matches(':focus-visible'))).toBe(false) await dialog.getByRole('heading', { name: title, exact: true }).hover() expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') + await page.keyboard.press('Shift+Tab') + expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true) const successToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已修改' : 'Modified' }) await successToast.waitFor() await compareOrRefreshGolden(join(expected, `${locale}-saved.expected.md`), await successToast.ariaSnapshot(), mode) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index f6c0c4f596..0f99b46c2d 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -206,6 +206,33 @@ async function tabTitles(root: Locator): Promise { return await root.locator('[data-dockkit-tab-title]').allInnerTexts() } +/** Compare the rendered glyph and text rather than their containing boxes. */ +async function expectTitleAlignment(title: Locator): Promise { + const geometry = await title.evaluate((node) => { + const icon = node.querySelector('svg') + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT) + let textBox: DOMRect | undefined + while (walker.nextNode()) { + if (!walker.currentNode.textContent?.trim()) continue + const range = document.createRange() + range.selectNodeContents(walker.currentNode) + const box = range.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + textBox = box + break + } + } + if (icon === null || textBox === undefined) throw new Error('tab icon or visible title text is missing') + const iconBox = icon.getBoundingClientRect() + return { + gap: textBox.left - iconBox.right, + centreOffset: Math.abs(iconBox.y + iconBox.height / 2 - textBox.y - textBox.height / 2), + } + }) + expect(geometry.gap).toBeCloseTo(5, 2) + expect(geometry.centreOffset).toBeLessThan(1) +} + /** * A rendered width, read once the frame's track transition has settled. * @@ -416,6 +443,7 @@ describe('web e2e: shipped right Sidebar', () => { } await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await expectTitleAlignment(column.locator('[data-dockkit-tab-title]')) await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2) expect(await column.locator('[data-sidebar-right-guide-entry="browser"]').count()).toBe(0) await column.locator('[data-sidebar-right-guide-entry="files"]').click() @@ -425,6 +453,7 @@ describe('web e2e: shipped right Sidebar', () => { const addTab = column.locator('[data-dockkit-add-tab]') const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) + await expectTitleAlignment(filesTab.locator('[data-dockkit-tab-title]')) await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1) await expect.poll(async () => await addTab.count()).toBe(1) @@ -963,6 +992,7 @@ describe('web e2e: shipped right Sidebar', () => { const floatOne = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) await floatByDrag(page, floatOne) await expect.poll(async () => await floats.count()).toBe(1) + await expectTitleAlignment(floats.first().locator('[data-dockkit-tab-title]')) const box = await floats.first().boundingBox() if (box === null) throw new Error('float is not rendered') await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 }) @@ -972,6 +1002,7 @@ describe('web e2e: shipped right Sidebar', () => { const second = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await floatByDrag(page, second) await expect.poll(async () => await floats.count()).toBe(2) + await expectTitleAlignment(floats.last().locator('[data-dockkit-tab-title]')) // 6. Dock one back: the docked tree takes it, the other float stays. Dock // the TOPMOST float — floats render bottom-to-top, so the newest one diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index aac87b9f54..7c4bcd0d91 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -78,6 +78,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) }) browser = await chromium.launch() const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', timezoneId: 'Asia/Shanghai' }) + // These snapshots use the Linux Web shortcut defaults. + await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'Linux x86_64' }) }) page = await context.newPage() tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) @@ -473,12 +475,25 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { expect(alive(original)).toBe(false) transport.reconnect() await page.context().setOffline(false) - await page.reload({ waitUntil: 'load' }) - await page.locator('[data-sidebar-right-expand]').click() + const environmentReady = Promise.withResolvers() + const environmentUrl = new URL('/api/terminal/environment', scaffold.baseUrl).href + await page.route(environmentUrl, async (route) => { + await environmentReady.promise + await route.continue() + }) + const terminal = page.locator('[data-sidebar-terminal]') + try { + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-sidebar-right-expand]').click() + // The title starts discovery before the lazy body mounts; keep that request pending through mounting. + await terminal.getByRole('status').getByText('Reading terminal environment…', { exact: true }).waitFor() + } finally { + environmentReady.resolve(undefined) + await page.unrouteAll({ behavior: 'wait' }) + } await expect.poll(() => page.getByRole('alert').innerText()).toContain('no longer exists') expect(handles).toHaveLength(1) const unavailable = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url)) - const terminal = page.locator('[data-sidebar-terminal]') await compareOrRefreshGolden(unavailable, await terminal.ariaSnapshot(), webSnapshotMode()) await terminal.screenshot({ path: `${shots}/unavailable.png`, animations: 'disabled' }) const tabCount = await page.locator('[data-dockkit-tab]').count() diff --git a/packages/client/shortcuts/README.i18n.yaml b/packages/client/shortcuts/README.i18n.yaml index 9f91920d60..6d1b4d3260 100644 --- a/packages/client/shortcuts/README.i18n.yaml +++ b/packages/client/shortcuts/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/shortcuts/README.md -README.md: 3583a31046946a29d3f8ddb1b263d4b676d45dc0 -README.zh.md: 0f1fef10405509740b2e0ce1f672946c1d898abb +README.md: c9c5fe591aed234f70debb0b59e686550d584c0d +README.zh.md: 64e1bd9835c025eb1fb0378582fe66071375b72c diff --git a/packages/client/shortcuts/README.md b/packages/client/shortcuts/README.md index 3583a31046..c9c5fe591a 100644 --- a/packages/client/shortcuts/README.md +++ b/packages/client/shortcuts/README.md @@ -45,7 +45,7 @@ Bindings activate after the first read. Failed reads preserve the last accepted
Implementation internals — click to expand -Windows and macOS Desktop intercept complete accepted combinations before local handlers and forward them through the trusted preload. Desktop startup requires the native keyboard bridge on every platform. On Windows and macOS, only native input dispatches configurable commands; DOM input continues to feed fixed actions. The adapter intercepts a key release only when it intercepted that key’s initial press. Default and user-saved bindings have identical priority, including over modal controls. Other environments dispatch after local handlers and respect their declared regions. Composition, dead keys, AltGraph, and consumed input pass through; macOS Web can consume a bound Option+Command+N reported as a dead key outside composition without guarding the next input. Web and Linux modal layers block background commands. Owned repeats remain consumed without rerunning the action. The context carries the original DOM input element, including elements inside a shadow root. Owners synchronously resolve their target and retain responsibility for action errors. The pure `./protocol` export has no React, DOM, or Electron dependency and shares validation and serialized persistence between adapters. Explicit overrides displace conflicting new defaults; conflicting explicit overrides are all disabled. Desktop rejects stale revisions and restricts IPC to the current product window and top frame. Rejected adapter calls are logged and returned as save failures; errors while publishing an accepted command catalog propagate to the caller. +Windows and macOS Desktop intercept complete accepted combinations before local handlers and forward them through the trusted preload. Desktop startup requires the native keyboard bridge on every platform. On Windows and macOS, only native input dispatches configurable commands; DOM input continues to feed fixed actions. The adapter intercepts a key release only when it intercepted that key’s initial press. Default and user-saved bindings have identical priority, including over modal controls. Other environments dispatch after local handlers and respect their declared regions. Composition, dead keys, AltGraph, and consumed input pass through; macOS Web can consume a bound Option+Command+N reported as a dead key outside composition without guarding the next input. Web and Linux modal layers block background commands. Owned repeats remain consumed without rerunning the action. The context carries the original DOM input element, including elements inside a shadow root. Owners synchronously resolve their target and retain responsibility for action errors. `ctx.shortcuts.closeWindow()` owns the Desktop keyboard bridge and sends its current accepted revision; Web calls and bridge request failures reject. The pure `./protocol` export has no React, DOM, or Electron dependency and shares validation and serialized persistence between adapters. Explicit overrides displace conflicting new defaults; conflicting explicit overrides are all disabled. Desktop rejects stale revisions and restricts IPC to the current product window and top frame. Rejected adapter calls are logged and returned as save failures; errors while publishing an accepted command catalog propagate to the caller. DOM fixed-input observers receive composition and consumption flags before application dispatch. Consuming an event makes it unavailable to subsequent observers and commands. Focus, pointer, window blur, composition, modal changes, and locally stopped propagation reset pending DOM sequences. Native chords reset on window blur, focused-frame or configuration changes, composition, recording, and modifier release. Electron can omit keyups after native interception; completing a native binding discards held-key state, so each chord activation needs two fresh presses. Releasing a modifier clears held-key state because macOS may omit character keyup events while Command is held. The adapter removes its listeners and modal observer when disposed; one feature's observer failure does not prevent the remaining observers from receiving input. @@ -77,7 +77,7 @@ None; this package neither assembles nor sends a provider request. -- Feature owners declare the Windows and macOS Web defaults explicitly: Mod+Slash, Mod+Comma, Mod+Backslash, and Control+Backquote stay two-key combinations; other defaults without Alt use Mod+Alt, and defaults with Alt use Mod+Shift except fullscreen uses Mod+Alt+Enter. macOS Web leaves Refresh current page unbound by default; its button and explicit user bindings remain available. Mod is Command on macOS and Control on Windows. Windows and macOS accept any three or four distinct modifiers in Web and Desktop. Web also accepts the listed combinations and Mod+Shift combinations; browser or system delivery requires platform testing. Linux Web accepts Mod+Slash, Mod+Shift+Comma, and Mod+Shift+Period. Operating-system shortcuts that do not reach the window cannot be intercepted. A chord does not delay or consume its first key while waiting for the second; a native action or focus change can therefore prevent completion. Command registration or disposal invalidates open edit drafts even when saved preferences are unchanged. Desktop rereads its file on every catalog update; there is no file watcher. +- Feature owners declare the Windows and macOS Web defaults explicitly: Mod+Slash, Mod+Comma, Mod+Backslash, and Control+Backquote stay two-key combinations; other defaults without Alt use Mod+Alt, and defaults with Alt use Mod+Shift except fullscreen uses Mod+Alt+Enter. macOS Web leaves Refresh current page unbound by default to preserve browser-specific Command+Option+R actions; its button and explicit user bindings remain available. Mod is Command on macOS and Control on Windows. Windows and macOS accept any three or four distinct modifiers in Web and Desktop. Web also accepts the listed combinations and Mod+Shift combinations; browser or system delivery requires platform testing. Linux Web accepts Mod+Slash, Mod+Shift+Comma, and Mod+Shift+Period. Operating-system shortcuts that do not reach the window cannot be intercepted. A chord does not delay or consume its first key while waiting for the second; a native action or focus change can therefore prevent completion. Command registration or disposal invalidates open edit drafts even when saved preferences are unchanged. Desktop rereads its file on every catalog update; there is no file watcher. ### Dev Note diff --git a/packages/client/shortcuts/README.zh.md b/packages/client/shortcuts/README.zh.md index 0f1fef1040..64e1bd9835 100644 --- a/packages/client/shortcuts/README.zh.md +++ b/packages/client/shortcuts/README.zh.md @@ -45,7 +45,7 @@ Windows 和 macOS Desktop 接受一至两个不同的受支持非修饰键,可
实现细节——点击展开 -Windows 和 macOS Desktop 在局部处理器之前拦截已接受的完整组合,通过可信 preload 转发。所有平台的 Desktop 启动都要求原生键盘桥接。Windows 和 macOS 的可配置命令只由原生输入分发;DOM 输入继续交给固定操作。适配器仅拦截初次按下事件已被拦截的按键的松开事件。默认绑定与用户保存的绑定优先级一致,均优先于模态控件。其他环境在局部处理器之后分发,并遵守声明的区域。输入法组合、死键、AltGraph 和已消费的输入继续传递;macOS Web 可在输入法组合之外消费已绑定且报告为死键的 Option+Command+N,不保护后续输入。Web 和 Linux 的模态层阻止后台命令。已接管的重复事件仍被消费,但不重复执行。上下文携带原始 DOM 输入元素,包括 shadow root 内的元素。owner 同步解析目标,并负责操作错误。纯 `./protocol` 导出不依赖 React、DOM 或 Electron,在两个适配器间共享校验和串行持久化。显式覆盖取代冲突的新默认值;互相冲突的显式覆盖全部禁用。Desktop 拒绝过期版本,并将 IPC 限制在当前产品窗口和顶层 frame。适配器调用被拒绝时会记录日志并返回保存失败;发布已接受命令目录时的错误会向调用方抛出。 +Windows 和 macOS Desktop 在局部处理器之前拦截已接受的完整组合,通过可信 preload 转发。所有平台的 Desktop 启动都要求原生键盘桥接。Windows 和 macOS 的可配置命令只由原生输入分发;DOM 输入继续交给固定操作。适配器仅拦截初次按下事件已被拦截的按键的松开事件。默认绑定与用户保存的绑定优先级一致,均优先于模态控件。其他环境在局部处理器之后分发,并遵守声明的区域。输入法组合、死键、AltGraph 和已消费的输入继续传递;macOS Web 可在输入法组合之外消费已绑定且报告为死键的 Option+Command+N,不保护后续输入。Web 和 Linux 的模态层阻止后台命令。已接管的重复事件仍被消费,但不重复执行。上下文携带原始 DOM 输入元素,包括 shadow root 内的元素。owner 同步解析目标,并负责操作错误。`ctx.shortcuts.closeWindow()` 统一持有 Desktop 键盘桥接,并携带当前已接受的 revision;Web 调用和桥接请求错误均返回失败。纯 `./protocol` 导出不依赖 React、DOM 或 Electron,在两个适配器间共享校验和串行持久化。显式覆盖取代冲突的新默认值;互相冲突的显式覆盖全部禁用。Desktop 拒绝过期版本,并将 IPC 限制在当前产品窗口和顶层 frame。适配器调用被拒绝时会记录日志并返回保存失败;发布已接受命令目录时的错误会向调用方抛出。 DOM 固定输入观察者在应用分发前收到输入法组合和消费标记。消费事件后,后续观察者和命令不能使用它。焦点、指针、窗口失焦、输入法组合、模态变化和局部停止传播会重置待完成的 DOM 序列。原生双键状态在窗口失焦、焦点 frame 或配置变化、输入法组合、录制和修饰键释放时重置。Electron 在原生拦截后可能省略 keyup;原生绑定完成时立即清空按住状态,因此每次触发双键组合都需要重新按下两个键。释放修饰键会清空按住状态,因为 macOS 在按住 Command 时可能省略字符键的 keyup。适配器释放时移除监听器和模态观察者;单个功能观察者失败不妨碍其他观察者接收输入。 @@ -77,7 +77,7 @@ Desktop 原生菜单和内嵌 frame 使用与 DOM 输入相同的命令注册表 -- 功能 owner 显式声明 Windows 和 macOS Web 的默认键位:Mod+Slash、Mod+Comma、Mod+Backslash 和 Control+Backquote 保持双键;其他不含 Alt 的默认键位使用 Mod+Alt,含 Alt 的使用 Mod+Shift,全屏除外,使用 Mod+Alt+Enter。macOS Web 的“刷新当前页”默认不绑定快捷键,刷新按钮和用户显式绑定仍然可用。Mod 在 macOS 上为 Command,在 Windows 上为 Control。Windows 和 macOS 的 Web、Desktop 均接受任意三个或四个不同修饰键的组合。Web 还接受上述组合及 Mod+Shift 组合;浏览器或系统能否送达按键需在对应平台测试。Linux Web 接受 Mod+Slash、Mod+Shift+Comma 和 Mod+Shift+Period。未送达窗口的操作系统快捷键无法被拦截。双键组合等待第二个键时,不延迟或消费第一个键;原生操作或焦点变化可能使组合无法完成。命令注册或注销会使打开的编辑草稿过期,即使保存的偏好未改变。Desktop 在每次命令目录更新时重新读取文件,不设置文件 watcher。 +- 功能 owner 显式声明 Windows 和 macOS Web 的默认键位:Mod+Slash、Mod+Comma、Mod+Backslash 和 Control+Backquote 保持双键;其他不含 Alt 的默认键位使用 Mod+Alt,含 Alt 的使用 Mod+Shift,全屏除外,使用 Mod+Alt+Enter。macOS Web 的“刷新当前页”默认不绑定快捷键,以保留浏览器自身的 Command+Option+R 操作;刷新按钮和用户显式绑定仍然可用。Mod 在 macOS 上为 Command,在 Windows 上为 Control。Windows 和 macOS 的 Web、Desktop 均接受任意三个或四个不同修饰键的组合。Web 还接受上述组合及 Mod+Shift 组合;浏览器或系统能否送达按键需在对应平台测试。Linux Web 接受 Mod+Slash、Mod+Shift+Comma 和 Mod+Shift+Period。未送达窗口的操作系统快捷键无法被拦截。双键组合等待第二个键时,不延迟或消费第一个键;原生操作或焦点变化可能使组合无法完成。命令注册或注销会使打开的编辑草稿过期,即使保存的偏好未改变。Desktop 在每次命令目录更新时重新读取文件,不设置文件 watcher。 ### 开发备注 diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json index 7bd6786771..1e7669177e 100644 --- a/packages/client/shortcuts/package.json +++ b/packages/client/shortcuts/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^" diff --git a/packages/client/shortcuts/src/client/dom.ts b/packages/client/shortcuts/src/client/dom.ts index 83f9641370..36872fd89a 100644 --- a/packages/client/shortcuts/src/client/dom.ts +++ b/packages/client/shortcuts/src/client/dom.ts @@ -1,4 +1,5 @@ /** Main-document keyboard adapter; local controls arbitrate before window bubbling. */ +import { observeComposition } from '@deepseek-ai/dsh-client-ui-primitives' import type { ShortcutContext, ShortcutFixedInput } from './types.ts' import type { ShortcutRegistry } from './registry.ts' import type { ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' @@ -30,16 +31,12 @@ export function detectEnvironment(document: Document, navigator: Navigator): { export function installKeyboard(window: Window, shortcuts: Pick, fixed?: (input: ShortcutFixedInput) => void, native = false): () => void { const document = window.document + const composition = observeComposition(document) let pending = false let pendingTimer: number | undefined const reset = (): void => { fixed?.({ type: 'reset' }) } - let composing = false - let compositionEnded = false let deadKey = false - const start = (): void => { composing = true; reset() } - const end = (): void => { composing = false; compositionEnded = true; reset() } - const release = (): void => { compositionEnded = false } - const blur = (): void => { composing = false; compositionEnded = false; deadKey = false; reset() } + const blur = (): void => { deadKey = false; reset() } const modalSelector = '[role="dialog"][aria-modal="true"], [role="menu"]' const containsModal = (node: Node): boolean => node instanceof Element && (node.matches(modalSelector) || node.querySelector(modalSelector) !== null) @@ -74,14 +71,12 @@ export function installKeyboard(window: Window, shortcuts: Pick(modalSelector) const top = [...dialogs].at(-1) const context: ShortcutContext = { region, modal: top === undefined ? null : top.dataset.shortcutModal ?? 'other', target: element } - // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. - const guarded = composing || compositionEnded || deadKey || event.isComposing || event.keyCode === 229 + const guarded = composition.guards(event) || deadKey || event.getModifierState('AltGraph') const isDead = event.key === 'Dead' // macOS can report Option+Command+N as Dead outside input-method composition. const commandDeadKey = isDead && shortcuts.runtime === 'web' && shortcuts.platform === 'macos' && event.code === 'KeyN' && event.metaKey && event.altKey && !event.ctrlKey && !event.shiftKey - compositionEnded = false deadKey = isDead const gesture = { code: event.code, control: event.ctrlKey, alt: event.altKey, shift: event.shiftKey, meta: event.metaKey, repeat: event.repeat, composing: guarded || isDead, defaultPrevented: event.defaultPrevented } @@ -94,25 +89,24 @@ export function installKeyboard(window: Window, shortcuts: Pick { pending = false window.clearTimeout(pendingTimer) observer?.disconnect() - document.removeEventListener('compositionstart', start, true) - document.removeEventListener('compositionend', end, true) + composition.dispose() + document.removeEventListener('compositionstart', reset, true) + document.removeEventListener('compositionend', reset, true) document.removeEventListener('focusin', reset, true) document.removeEventListener('pointerdown', reset, true) window.removeEventListener('keydown', capture, true) window.removeEventListener('keydown', keydown) - window.removeEventListener('keyup', release) window.removeEventListener('blur', blur) } } diff --git a/packages/client/shortcuts/src/client/index.ts b/packages/client/shortcuts/src/client/index.ts index 6947690abe..bf4ee42a91 100644 --- a/packages/client/shortcuts/src/client/index.ts +++ b/packages/client/shortcuts/src/client/index.ts @@ -34,6 +34,7 @@ export default class ShortcutsService extends Service implements Shortcuts { readonly stopSequenceMs: number private readonly fixedListeners = new Set<(input: ShortcutFixedInput) => void>() private readonly adapter: DesktopShortcutsApi | undefined + private readonly keyboard: DesktopKeyboardApi | undefined private active = true private connected = false private readonly registry: ShortcutRegistry @@ -44,6 +45,7 @@ export default class ShortcutsService extends Service implements Shortcuts { ? (window as Window & { dshDesktop?: { keyboard?: DesktopKeyboardApi } }).dshDesktop?.keyboard : undefined if (environment.runtime === 'desktop' && keyboard === undefined) throw new Error('Desktop keyboard bridge unavailable') super(ctx, 'shortcuts') + this.keyboard = keyboard this.runtime = environment.runtime this.platform = environment.platform const config = Config((globalThis as { __DSH_SHORTCUTS_CONFIG__?: unknown }).__DSH_SHORTCUTS_CONFIG__ ?? {}) @@ -149,4 +151,9 @@ export default class ShortcutsService extends Service implements Shortcuts { if (this.adapter === undefined) throw new Error('Desktop shortcuts bridge unavailable') await this.adapter.recording(active) } + + async closeWindow(): Promise { + if (this.keyboard === undefined) throw new Error('Desktop keyboard bridge unavailable') + await this.keyboard.closeWindow(this.config.getSnapshot().revision) + } } diff --git a/packages/client/shortcuts/src/client/types.ts b/packages/client/shortcuts/src/client/types.ts index d374b89618..a530ce549d 100644 --- a/packages/client/shortcuts/src/client/types.ts +++ b/packages/client/shortcuts/src/client/types.ts @@ -23,7 +23,10 @@ export interface ShortcutCommand { readonly aliases: readonly string[] readonly defaults: Readonly>> readonly regions: readonly ShortcutRegion[] - /** Modal identifiers in which this command may resolve; others block it. */ + /** + * Allowed modal identifiers for Web/Linux keyboard input and explicit menu actions; + * Windows/macOS Desktop keyboard bindings take priority. + */ readonly modals: readonly string[] resolve(context: ShortcutContext): ShortcutResolution } @@ -121,6 +124,11 @@ export interface Shortcuts { * @returns completion of the native interception update. */ recording(active: boolean): Promise + /** + * Request closure of the Desktop window using the currently accepted shortcut revision. + * @returns completion of the native request; rejects outside Desktop or when the bridge request fails. + */ + closeWindow(): Promise /** * Register a command; duplicate ids and overlapping default bindings throw. * @param command - feature-owned labels, defaults, and target resolver. diff --git a/packages/client/shortcuts/tests/dom.client.spec.ts b/packages/client/shortcuts/tests/dom.client.spec.ts index e8c93c6e61..ee0f5ac9a7 100644 --- a/packages/client/shortcuts/tests/dom.client.spec.ts +++ b/packages/client/shortcuts/tests/dom.client.spec.ts @@ -168,6 +168,16 @@ it('preserves composition, late closing keys, 229, AltGraph, and dead-key input' expect(press(input).defaultPrevented).toBe(true) }) +it('accepts a shortcut after the composition-closing key is released inside a local control', () => { + const { input, run } = mount() + fireEvent.compositionStart(input) + fireEvent.compositionEnd(input) + input.addEventListener('keyup', (event) => { event.stopPropagation() }, { once: true }) + fireEvent.keyUp(input, { key: 'Enter', code: 'Enter' }) + expect(press(input).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() +}) + it('blocks background commands during dialogs and gives terminal input ownership', () => { const { input, run } = mount() const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') diff --git a/packages/client/shortcuts/tests/service.client.spec.ts b/packages/client/shortcuts/tests/service.client.spec.ts index 8848d4b42c..103ef2e9fd 100644 --- a/packages/client/shortcuts/tests/service.client.spec.ts +++ b/packages/client/shortcuts/tests/service.client.spec.ts @@ -47,6 +47,24 @@ function mount() { return { ctx, service, dispose } } +it('closes the Desktop window with the latest accepted revision and propagates bridge errors', async () => { + const f = desktop() + const { service } = mount() + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + const latest = snapshot(10) + f.publish(latest) + await service.closeWindow() + expect(f.keyboard.closeWindow).toHaveBeenCalledExactlyOnceWith(latest.revision) + const failure = new Error('Window unavailable') + f.keyboard.closeWindow.mockRejectedValueOnce(failure) + await expect(service.closeWindow()).rejects.toBe(failure) +}) + +it('rejects a native window close outside Desktop', async () => { + const { service } = mount() + await expect(service.closeWindow()).rejects.toThrow('Desktop keyboard bridge unavailable') +}) + it('describes registered binding conflicts on Linux Desktop', async () => { desktop() document.documentElement.dataset.platform = 'linux' diff --git a/packages/client/shortcuts/tsconfig.client.json b/packages/client/shortcuts/tsconfig.client.json index f795e9fd2e..6157c1a154 100644 --- a/packages/client/shortcuts/tsconfig.client.json +++ b/packages/client/shortcuts/tsconfig.client.json @@ -21,6 +21,9 @@ { "path": "../store" }, + { + "path": "../ui-primitives" + }, { "path": "../../util/crypto" }, diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx index 56eb0bece3..5ad53b3126 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -1019,7 +1019,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, value={folderDraft ?? ''} aria-label={t('browser.folderName')} placeholder={t('browser.untitledFolder')} - autoFocus + data-modal-autofocus disabled={creatingFolder} onChange={(event) => { setFolderDraft(event.target.value) }} {...compositionGuard} diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 54e3e3f130..f598297d75 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: 6520f149094b91e2d84c051da68791169f9f31a9 -README.zh.md: f62c7d1ae63caede511312b52b4c3b9c2e5d8e0d +README.md: deae0abb23d7136d908a15351a8298e6b9a2ce43 +README.zh.md: 9dcbf6925bd0b5f8d22a0f729d46baa84211f41d diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 6520f14909..deae0abb23 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -65,7 +65,7 @@ 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. +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 or drawing a focus outline; their controls retain their own keyboard focus indicators. 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. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index f62c7d1ae6..9dcbf6925b 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -65,7 +65,7 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间 ## 值得保留的交互规则 -分栏控件接收宿主传入的有效快捷键提示和 ARIA 标签。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。 +分栏控件接收宿主传入的有效快捷键提示和 ARIA 标签。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序,也不绘制焦点外框;内部控件保留各自的键盘焦点提示。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。 这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。 diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index fd24348a10..676f2750fb 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -215,6 +215,11 @@ overflow: hidden; } +.pane:focus, +.float:focus { + outline: none; +} + /* A 38px row whose 28px content band sits at its bottom: chips fill the band, so they end flush with the strip's bottom edge, and the 28px add control, the 28px split control, and the embedder's 28px chrome centre on the band's one diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 8ed82cc807..2c7bc7fd59 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: e7d903d5c11acd8c8f83e3e8b6452e233f7e8b44 -README.zh.md: 3d6149009ded4a31f44a87b5bf2a1776c9660dca +README.md: 9c73af31107c079868d5df2e8cb1d93e25562c82 +README.zh.md: 48115b391961a6e34ea776a4cad3fb49c90f36d6 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index e7d903d5c1..9c73af3110 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -27,6 +27,8 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out `Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` renders caller-supplied effective keys together in one rounded badge; its optional `className` lets the owner style interaction states. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime. +Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls. + This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). Compose feature UI from these atoms whenever the web client needs a standard control or an agent-output renderer. They render through React only and take `--dsw-*` design tokens from the theme, so they fit any plugin without importing the theme or the slot system. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3d6149009d..48115b3919 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -27,6 +27,8 @@ kind: "package-library" `Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 将调用方提供的有效按键组合显示在同一个圆角标签中;可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。 +用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。 + 本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 只要 Web 客户端需要标准控件或 agent 输出渲染器,就用这些原子组件拼装功能 UI。它们只经 React 渲染,并从主题取得 `--dsw-*` 设计 token,因此无需导入主题或 slot 系统即可适配任意插件。 diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 430e44adfa..78d5a01247 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -31,7 +31,8 @@ type ModalProps = ModalBaseProps & ( * @param props.title - dialog heading (aria-label in every mode). * @param props.closeLabel - localized accessible close-button label. * @param props.description - optional supporting sentence under the title. - * @param props.children - body (inputs, etc.). + * @param props.children - dialog body; mark its initial-focus control with + * data-modal-autofocus instead of React autoFocus to preserve return focus. * @param props.footer - action row (Cancel / Create). * @param props.contentClassName - optional class for a scrollable content region. * @param props.shortcutModal - command scope allowed by shortcut owners; unnamed diff --git a/packages/client/ui-primitives/src/RiskConfirmation.tsx b/packages/client/ui-primitives/src/RiskConfirmation.tsx index 7f5c6f49c2..7109d096b2 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.tsx +++ b/packages/client/ui-primitives/src/RiskConfirmation.tsx @@ -73,7 +73,7 @@ export function RiskConfirmation({ type="checkbox" checked={acknowledged} disabled={disabled} - autoFocus + data-modal-autofocus onChange={(event) => { onAcknowledgedChange(event.currentTarget.checked) }} /> {acknowledgeLabel} diff --git a/packages/client/ui-primitives/src/useModalLayer.ts b/packages/client/ui-primitives/src/useModalLayer.ts index e5523787e6..bbe16002e6 100644 --- a/packages/client/ui-primitives/src/useModalLayer.ts +++ b/packages/client/ui-primitives/src/useModalLayer.ts @@ -19,6 +19,8 @@ const focusable = 'button:not(:disabled), input:not(:disabled), textarea:not(:di /** * Give only the top modal Escape and Tab ownership, then restore its previous focus. + * Controls mounted with the dialog use data-modal-autofocus for initial focus; + * React autoFocus runs before this layer can capture the invoking control. * Local menus handle their Escape during capture before this bubble listener. * @param dialog - mounted dialog element. * @param open - whether this layer is active. @@ -55,7 +57,7 @@ export function useModalLayer(dialog: RefObject, open: boole const first = items[0] ?? element const last = items.at(-1) ?? element const atEdge = event.shiftKey ? document.activeElement === first : document.activeElement === last - if (!element.contains(document.activeElement) || atEdge) { + if (document.activeElement === element || !element.contains(document.activeElement) || atEdge) { event.preventDefault() const target = event.shiftKey ? last : first target.focus() diff --git a/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx b/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx index 416b137490..05d135c63e 100644 --- a/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx +++ b/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx @@ -7,7 +7,7 @@ import { isBehindModal } from '../src/useModalLayer.ts' import { Menu } from '../src/Menu.tsx' afterEach(cleanup) -function Nested() { +function Nested({ withSearch = true }: { withSearch?: boolean }) { const [settings, setSettings] = useState(false) const [reference, setReference] = useState(false) const [menu, setMenu] = useState(false) @@ -17,7 +17,7 @@ function Nested() { { setReference(false) }}> - + {withSearch && } { setMenu(true) }}>Menu} items={[{ id: 'item', label: 'Item' }]} onSelect={() => { setMenu(false) }} onClose={() => { setMenu(false) }} /> @@ -67,6 +67,25 @@ it('keeps Tab within the top dialog and releases listeners after unmount', () => expect(event.defaultPrevented).toBe(false) }) +it.each([ + { direction: 'forward', shiftKey: false, target: 'Close reference' }, + { direction: 'backward', shiftKey: true, target: 'Menu' }, +])('moves $direction from the top dialog container into its controls', ({ shiftKey, target }) => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + const reference = screen.getByRole('button', { name: 'Reference' }) + reference.focus() + fireEvent.click(reference) + const dialog = screen.getByRole('dialog', { name: 'Reference' }) + dialog.focus() + + expect(fireEvent.keyDown(dialog, { key: 'Tab', shiftKey })).toBe(false) + expect(document.activeElement).toBe(screen.getByRole('button', { name: target })) + escape() + expect(screen.queryByRole('dialog', { name: 'Reference' })).toBeNull() + expect(document.activeElement).toBe(reference) +}) + it('keeps focus in a headless empty dialog and leaves portaled menu traversal to its owner', () => { render( {}} />) const dialog = screen.getByRole('dialog') diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 0547dc7827..838b475f60 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -98,6 +98,7 @@ it.each([en, zh])('updates the Settings menu keycaps and accessible combination const props: AccountMenuProps = { ...({} as GlobalStandardProps), ...operations, wide: true, useAccount: selector => selector(operations.hooks.account.getSnapshot()), + useTheme: selector => selector(operations.hooks.theme.getSnapshot()), openSettings: vi.fn(() => { expect(document.activeElement).toBe(screen.getByRole('button', { name: copy.menu })) }), openOnboarding: vi.fn(), t: key => key in copy ? copy[key as AccountKey] : key, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 4ffadd18b4..d075e01007 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: d98ddc336852becbe2a6f4b80de487d9a493b995 -README.zh.md: c89f804b4927cdc1bd90de48879e5f7b610462db +README.md: a929dcc90f5803d60a2bf21604912ecf00b23072 +README.zh.md: 541c97410c16dbfadecfcdbcb3b8a370bde56a23 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index d98ddc3368..a929dcc90f 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -33,7 +33,7 @@ When the section navigation exceeds the panel's available height, the list scrol In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 13px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. -Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. Desktop Mod+, and the sidebar control open the same dialog; repeated commands keep it open. Settings and the shortcut reference use body portals at the same modal layer. The last opened dialog appears above the other and owns keyboard focus and Escape; closing it restores its invoking control. +Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. The Settings command (`Mod+,` by default in Desktop) and the sidebar control open the same dialog. Repeating either action keeps the dialog open. Settings and the shortcut reference use body portals at the same modal layer. The last opened dialog appears above the other and receives keyboard focus; `Escape` closes the top dialog and restores focus to its invoking control. ### The General section diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index c89f804b49..541c97410c 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" 在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、13px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。 -设置的可见状态和分区选择由 shell owner store 保存。 外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。桌面 Mod+, 与侧栏控件打开同一弹窗;重复命令保持其打开。设置和快捷键速查通过 body portal 使用同一模态层级。最后打开的弹窗显示在另一弹窗上方,并拥有键盘焦点和 Esc;关闭后恢复触发控件的焦点。 +设置的可见状态和分区选择由 shell owner store 保存。外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。设置命令(Desktop 中默认为 `Mod+,`)与侧栏控件打开同一弹窗;重复执行设置命令或点击侧栏控件均保持弹窗打开。设置和快捷键速查通过 body portal 使用同一模态层级。最后打开的弹窗显示在另一弹窗上方并获得键盘焦点;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。 ### 「通用」分区 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 7ae8118762..e1bf1b862d 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -678,7 +678,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS className={styles['deleteDialog'] as string} footer={( <> -