From 7b564f61e7819eeaf7635f08244b5b41f71a242a Mon Sep 17 00:00:00 2001 From: Xinyu Yang Date: Mon, 21 Sep 2026 23:56:42 +0800 Subject: [PATCH] fix(shortcuts): integrate current desktop guests and client APIs --- .../2026-09-16-desktop-window-menus.i18n.yaml | 4 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/browser-guests.ts | 8 +- apps/desktop/src/keyboard.ts | 235 ++++++++++-------- apps/desktop/src/main.ts | 2 +- apps/desktop/src/preload-app.ts | 5 + apps/desktop/tests/keyboard.spec.ts | 129 +++++++++- apps/desktop/tests/preload-app.spec.ts | 32 +++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 15 ++ docs/config-catalog.zh.md | 15 ++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 4 + docs/module-graph.zh.md | 4 + packages/client/README.i18n.yaml | 4 +- packages/client/shortcuts/README.i18n.yaml | 4 +- packages/client/shortcuts/README.md | 2 +- packages/client/shortcuts/README.zh.md | 2 +- .../client/shortcuts/src/client/native.ts | 12 +- packages/client/shortcuts/src/client/types.ts | 2 +- packages/client/shortcuts/src/native.ts | 4 +- .../shortcuts/tests/native.client.spec.ts | 40 +++ .../client/shortcuts/tsconfig.client.json | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- .../src/client/OpenTargetButton.tsx | 2 +- .../tests/browser-plugin.client.spec.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 2 + packages/client/ui-shortcuts/tsconfig.json | 4 +- .../ui-sidebar-browser/README.i18n.yaml | 4 +- packages/client/ui-sidebar-browser/README.md | 2 +- .../client/ui-sidebar-browser/README.zh.md | 2 +- .../electron/ElectronWebviewPresentation.ts | 1 + .../tests/electron-lifecycle.client.spec.ts | 1 + .../src/client/contract/slots.ts | 5 +- .../client/session-actions/ForkSession.tsx | 4 +- .../tests/shortcuts.client.spec.ts | 8 +- .../src/client/slot-catalog.ts | 48 ++-- .../references/packages.md | 2 + 43 files changed, 468 insertions(+), 172 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml index 9c2cdd061a..edd7fa1440 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.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 .agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md -2026-09-16-desktop-window-menus.md: 8a5343404e0cb3d2e7c4e20248a8b29f3bfbec47 -2026-09-16-desktop-window-menus.zh.md: d6ee6412462aecf49557a7994a683d37bf8e7253 +2026-09-16-desktop-window-menus.md: 34eff83707440719182f4ebe61aa552919bd44ee +2026-09-16-desktop-window-menus.zh.md: c3cb3e5d89497ae15210c034c0077fc8019e5688 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 0381897eaa..1281bcb68f 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: 66e94a8d334f5c48b387bf6f5d7d5a7af60e2d46 -README.zh.md: 5a765a09c217babe70f9ec4fc224b7442a4ce192 +README.md: 813813cf2292a6cfece78a273dcf6843093a95cf +README.zh.md: e63557e98c0a19ecd9bf9e6c9497ce074ce8b57b diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c6b01138ef..813813cf22 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 and embedded-frame 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. 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. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index da4f1d472b..e63557e98c 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 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 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 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 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 的显示效果。 diff --git a/apps/desktop/src/browser-guests.ts b/apps/desktop/src/browser-guests.ts index 027b7cee5b..c3f0b10eea 100644 --- a/apps/desktop/src/browser-guests.ts +++ b/apps/desktop/src/browser-guests.ts @@ -9,6 +9,7 @@ interface GuestLease { readonly partition: string attached: boolean guest?: WebContents + releaseInput?: () => void } /** Owns workspace storage partitions independently from individual tab guests. */ @@ -51,6 +52,7 @@ export class DesktopBrowserGuests { const lease = this.leases.get(key) if (lease === undefined) return if (lease.owner !== owner) throw new Error('desktop browser: guest belongs to another window') + lease.releaseInput?.() this.leases.delete(key) const guest = lease.guest if (guest !== undefined && !guest.isDestroyed()) { @@ -63,8 +65,9 @@ export class DesktopBrowserGuests { /** * Install attachment checks before the application document can create a webview. * @param window - primary application window. + * @param attachInput - attaches native input after guest ownership is verified and returns its disposer. */ - bind(window: BrowserWindow): void { + bind(window: BrowserWindow, attachInput: (guest: WebContents, name: DesktopBrowserLeaseId) => () => void): void { const owner = window.webContents owner.on('will-attach-webview', (event, preferences, params) => { const id = typeof params.src === 'string' && params.src.startsWith('about:blank#') @@ -102,7 +105,8 @@ export class DesktopBrowserGuests { } lease.guest = guest attachedLease = id - guest.once('destroyed', () => { this.leases.delete(id) }) + lease.releaseInput = attachInput(guest, id) + guest.once('destroyed', () => { lease.releaseInput?.(); this.leases.delete(id) }) }) guest.setWindowOpenHandler(({ url, postBody }) => { const lease = attachedLease === undefined ? undefined : this.leases.get(attachedLease) diff --git a/apps/desktop/src/keyboard.ts b/apps/desktop/src/keyboard.ts index 37d9e665d2..4cd994f8f7 100644 --- a/apps/desktop/src/keyboard.ts +++ b/apps/desktop/src/keyboard.ts @@ -1,7 +1,8 @@ /** Product-window preference IPC and native menu interception during physical-key dispatch/recording. */ -import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type Input, type MenuItemConstructorOptions } from 'electron' +import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type Input, type MenuItemConstructorOptions, type WebContents } from 'electron' import { bindingKey, effectiveShortcuts, presentBinding, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol' import type { NormalizedBinding, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { DesktopBrowserLeaseId } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' import { desktopKeybindings } from './keybindings.ts' import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts' @@ -24,6 +25,14 @@ export function installDesktopShortcuts( */ sendEditingKey(keyCode: string, modifiers: Array<'control'>): void attach(window: BrowserWindow): void + /** + * Intercept an approved browser guest's keys until its lease ends. + * @param window - owning product window. + * @param guest - approved browser contents. + * @param name - main-issued lease used as the webview element name. + * @returns idempotent listener disposer. + */ + attachGuest(window: BrowserWindow, guest: WebContents, name: DesktopBrowserLeaseId): () => void dispose(): void } { let definitions: readonly ShortcutDefinition[] = [] @@ -33,6 +42,7 @@ export function installDesktopShortcuts( let editingInput: BrowserWindow['webContents'] | undefined const scopedDesktop = platform === 'windows' || platform === 'macos' const disposers = new Set<() => void>() + const guestInputs = new Map() const closeAccelerator = (): string | undefined => presentBinding(closeBinding, platform).aria ?.replace('Meta+', 'Command+').replace(/Arrow(Up|Down|Left|Right)$/u, '$1') const sendMenuClose = (): void => { @@ -87,11 +97,128 @@ export function installDesktopShortcuts( if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled()) return window.close() }) + function attachInput(window: BrowserWindow, contents: WebContents, guestName?: DesktopBrowserLeaseId): () => void { + let deadKey = false + const held = new Set() + const consumed = new Map() + let inputFrame: typeof contents.focusedFrame = null + let inputRevision: ShortcutRevision | undefined + const resetInput = (): void => { + deadKey = false; held.clear(); consumed.clear(); inputFrame = null; inputRevision = undefined + if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) + } + if (guestName !== undefined) guestInputs.set(contents, { window, reset: resetInput }) + const resetWindow = (): void => { + resetInput() + for (const guest of guestInputs.values()) if (guest.window === window) guest.reset() + } + const clear = (): void => { + resetInput() + if (guestName !== undefined) return + definitions = []; keys.clear(); recording = false + persistence.setDefinitions(null) + } + const navigation = (event: Electron.Event): void => { + if (event.isMainFrame && !event.isSameDocument) clear() + } + const beforeInput = (event: Electron.Event, input: Input): void => { + if (editingInput === contents) { + held.clear() + consumed.clear() + contents.setIgnoreMenuShortcuts(true) + return + } + if (window !== getWindow() || !window.isFocused() || !window.isEnabled() || revision === undefined + || (guestName !== undefined && !contents.isFocused())) { + contents.setIgnoreMenuShortcuts(false) + held.clear() + consumed.clear() + return + } + const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(modifier => input[modifier]) + const key = bindingKey({ code: input.code, modifiers }) + const match = keys.has(key) + const menuMatch = closeBinding !== null && closeBinding.secondCode === undefined && modifiers.join('+') === closeBinding.modifiers.join('+') + && input.key.toUpperCase() === (closeBinding.code.startsWith('Key') ? closeBinding.code.slice(3) : input.code === closeBinding.code ? input.key.toUpperCase() : '') + contents.setIgnoreMenuShortcuts(recording || match || menuMatch) + const frame = contents.focusedFrame + const composing = input.isComposing || input.key === 'Dead' || deadKey || input.modifiers.includes('altgr') + if (input.type === 'keyDown') deadKey = input.key === 'Dead' + if (recording || composing || frame === null) { held.clear(); consumed.clear(); return } + let binding: NormalizedBinding = { code: input.code, modifiers } + let priority = false + if (scopedDesktop) { + if (frame !== inputFrame || inputRevision !== revision) { held.clear(); inputFrame = frame; inputRevision = revision } + const modifierKey = /^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(input.code) + if (input.type === 'keyUp') { + // A chord's first key reached the renderer, so its release must reach the same input handlers. + if (consumed.get(input.code) === 'press') event.preventDefault() + consumed.delete(input.code) + held.delete(input.code) + if (modifierKey) held.clear() + return + } + if (!input.isAutoRepeat) consumed.delete(input.code) + if (modifierKey) { held.clear(); return } + if (input.isAutoRepeat && consumed.has(input.code) && !match) { event.preventDefault(); return } + if (input.isAutoRepeat && !held.has(input.code) && !match) return + held.add(input.code) + const codes: [string, ...string[]] = [input.code, ...[...held].filter(value => value !== input.code)] + codes.sort() + const pair = { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), modifiers } + priority = keys.has(key) + if (codes.length === 2 && keys.has(bindingKey(pair))) { binding = pair; priority = true } + } + const main = guestName === undefined && frame === contents.mainFrame + if (!priority && (main || !match)) return + event.preventDefault() + if (input.type !== 'keyDown') return + if (scopedDesktop) { + if (!input.isAutoRepeat) { + if (binding.secondCode !== undefined) { + consumed.set(binding.code, 'repeat') + consumed.set(binding.secondCode, 'repeat') + } + consumed.set(input.code, 'press') + } + // Electron can omit both keyups after interception; completed presses cannot seed another chord. + held.clear() + } + let embedding = frame + while (guestName === undefined && !main && embedding.parent !== null && embedding.parent !== contents.mainFrame) { + embedding = embedding.parent + } + window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: guestName === undefined ? main ? 'keyboard' : 'iframe' : 'webview', + revision, frameName: guestName ?? (main ? '' : embedding.name), + code: binding.code, ...(binding.secondCode === undefined ? {} : { secondCode: binding.secondCode }), + repeat: input.isAutoRepeat, control: input.control, alt: input.alt, shift: input.shift, meta: input.meta }) + } + const dispose = (): void => { + contents.off('did-start-navigation', navigation) + contents.off('before-input-event', beforeInput) + contents.off('blur', resetInput) + contents.off('destroyed', dispose) + if (guestName === undefined) { window.off('blur', resetWindow); window.off('closed', closed) } + disposers.delete(dispose) + guestInputs.delete(contents) + if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) + } + const closed = (): void => { clear(); dispose() } + contents.on('did-start-navigation', navigation) + contents.on('before-input-event', beforeInput) + contents.on('blur', resetInput) + contents.once('destroyed', dispose) + if (guestName === undefined) { window.on('closed', closed); window.on('blur', resetWindow) } + disposers.add(dispose) + return dispose + } + return { sendEditingKey(keyCode, modifiers) { const window = getWindow() if (window === undefined || window.isDestroyed()) return - const contents = window.webContents + const contents = [...guestInputs].find(([guest, owner]) => owner.window === window && !guest.isDestroyed() && guest.isFocused())?.[0] + ?? window.webContents contents.focus() const previous = editingInput editingInput = contents @@ -112,108 +239,8 @@ export function installDesktopShortcuts( click: sendMenuClose, }] } }, - attach(window) { - const contents = window.webContents - let deadKey = false - const held = new Set() - const consumed = new Map() - let inputFrame: typeof contents.focusedFrame = null - let inputRevision: ShortcutRevision | undefined - const clear = (): void => { - definitions = []; keys.clear(); held.clear(); consumed.clear() - inputFrame = null; recording = false; deadKey = false - persistence.setDefinitions(null) - if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) - } - const navigation = (event: Electron.Event): void => { - if (event.isMainFrame && !event.isSameDocument) clear() - } - const blur = (): void => { - deadKey = false; held.clear(); consumed.clear(); inputFrame = null; contents.setIgnoreMenuShortcuts(false) - } - const beforeInput = (event: Electron.Event, input: Input): void => { - if (editingInput === contents) { - held.clear() - consumed.clear() - contents.setIgnoreMenuShortcuts(true) - return - } - if (!window.isFocused() || !window.isEnabled() || revision === undefined) { - contents.setIgnoreMenuShortcuts(false) - held.clear() - consumed.clear() - return - } - const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(modifier => input[modifier]) - const key = bindingKey({ code: input.code, modifiers }) - const match = keys.has(key) - const menuMatch = closeBinding !== null && closeBinding.secondCode === undefined && modifiers.join('+') === closeBinding.modifiers.join('+') - && input.key.toUpperCase() === (closeBinding.code.startsWith('Key') ? closeBinding.code.slice(3) : input.code === closeBinding.code ? input.key.toUpperCase() : '') - contents.setIgnoreMenuShortcuts(recording || match || menuMatch) - const frame = contents.focusedFrame - const composing = input.isComposing || input.key === 'Dead' || deadKey || input.modifiers.includes('altgr') - if (input.type === 'keyDown') deadKey = input.key === 'Dead' - if (recording || composing || frame === null) { held.clear(); consumed.clear(); return } - let binding: NormalizedBinding = { code: input.code, modifiers } - let priority = false - if (scopedDesktop) { - if (frame !== inputFrame || inputRevision !== revision) { held.clear(); inputFrame = frame; inputRevision = revision } - const modifierKey = /^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(input.code) - if (input.type === 'keyUp') { - // A chord's first key reached the renderer, so its release must reach the same input handlers. - if (consumed.get(input.code) === 'press') event.preventDefault() - consumed.delete(input.code) - held.delete(input.code) - if (modifierKey) held.clear() - return - } - if (!input.isAutoRepeat) consumed.delete(input.code) - if (modifierKey) { held.clear(); return } - if (input.isAutoRepeat && consumed.has(input.code) && !match) { event.preventDefault(); return } - if (input.isAutoRepeat && !held.has(input.code) && !match) return - held.add(input.code) - const codes: [string, ...string[]] = [input.code, ...[...held].filter(value => value !== input.code)] - codes.sort() - const pair = { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), modifiers } - priority = keys.has(key) - if (codes.length === 2 && keys.has(bindingKey(pair))) { binding = pair; priority = true } - } - const main = frame === contents.mainFrame - if (!priority && (main || !match)) return - event.preventDefault() - if (input.type !== 'keyDown') return - if (scopedDesktop) { - if (!input.isAutoRepeat) { - if (binding.secondCode !== undefined) { - consumed.set(binding.code, 'repeat') - consumed.set(binding.secondCode, 'repeat') - } - consumed.set(input.code, 'press') - } - // Electron can omit both keyups after interception; completed presses cannot seed another chord. - held.clear() - } - let embedding = frame - while (!main && embedding.parent !== null && embedding.parent !== contents.mainFrame) embedding = embedding.parent - contents.send(DESKTOP_IPC.shortcutsInput, { kind: main ? 'keyboard' : 'iframe', revision, frameName: main ? '' : embedding.name, - code: binding.code, ...(binding.secondCode === undefined ? {} : { secondCode: binding.secondCode }), - repeat: input.isAutoRepeat, control: input.control, alt: input.alt, shift: input.shift, meta: input.meta }) - } - const dispose = (): void => { - contents.off('did-start-navigation', navigation) - contents.off('before-input-event', beforeInput) - window.off('blur', blur) - window.off('closed', closed) - disposers.delete(dispose) - if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) - } - const closed = (): void => { clear(); dispose() } - contents.on('did-start-navigation', navigation) - contents.on('before-input-event', beforeInput) - window.on('closed', closed) - window.on('blur', blur) - disposers.add(dispose) - }, + attach(window) { attachInput(window, window.webContents) }, + attachGuest: attachInput, dispose() { for (const dispose of disposers) dispose() persistence.dispose() diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e28a6ef481..905e80109f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -836,7 +836,7 @@ async function main(): Promise { const createMainWindow = (): BrowserWindow => { const window = createWindow(appPreload, false, true) mainWindow = window - browserGuests.bind(window) + browserGuests.bind(window, (guest, name) => shortcuts.attachGuest(window, guest, name)) shortcuts.attach(window) window.on('focus', automaticCheck) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index d068cd259c..a500f6ed93 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -24,6 +24,11 @@ function createProductApi(): DshDesktopProductApi { || !element.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]')) return if (input.frameName === '' || element.name !== input.frameName) return } + if (input.kind === 'webview') { + const element = document.activeElement + if (element?.matches('webview[data-sidebar-browser-frame]') !== true || !element.isConnected + || input.frameName === '' || element.getAttribute('name') !== input.frameName) return + } listener(input) } ipcRenderer.on(DESKTOP_IPC.shortcutsInput, handle) diff --git a/apps/desktop/tests/keyboard.spec.ts b/apps/desktop/tests/keyboard.spec.ts index 157df5035c..3deb5bac98 100644 --- a/apps/desktop/tests/keyboard.spec.ts +++ b/apps/desktop/tests/keyboard.spec.ts @@ -4,17 +4,22 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, onTestFinished, vi } from 'vitest' -import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import type { BrowserWindow, IpcMainInvokeEvent, WebContents } 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 type { DesktopBrowserLeaseId } 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 })) +vi.mock('electron', () => ({ ipcMain: ipc, 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') afterEach(() => { vi.clearAllMocks() }) function desktopDefaults(binding: ShortcutBinding): ShortcutDefinition['defaults'] { @@ -523,3 +528,123 @@ it.each(['macos', 'windows'] as const)('requires fresh %s chord presses when Ele expect(f.contents.send).toHaveBeenCalledTimes(5) } }) + +it.each(['macos', 'windows', 'linux'] as const)('routes approved %s browser guest input to its owner and stops delivery before guest destruction', async (platform) => { + const f = await fixture(platform) + const snapshot = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id: 'browser.new', defaults: desktopDefaults({ code: 'KeyT', modifiers: ['primary'] }) }]) + const guests = new DesktopBrowserGuests(() => undefined) + const attach = vi.fn((guest: WebContents, name: DesktopBrowserLeaseId) => f.keyboard.attachGuest(f.window as never, guest, name)) + guests.bind(f.window as never, attach) + const reservation = guests.acquire(f.contents as never, 'session:test') + const frame = { url: `about:blank#${reservation.lease}`, 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() }) + onTestFinished(() => { guest.emit('destroyed') }) + const rejected = { preventDefault: vi.fn() } + f.contents.emit('will-attach-webview', rejected, {}, { src: 'about:blank#unknown', partition: reservation.partition }) + expect(rejected.preventDefault).toHaveBeenCalledOnce() + const invalid = Object.assign(new EventEmitter(), { getURL: () => 'about:blank#unknown', setWindowOpenHandler: vi.fn(), close: vi.fn() }) + f.contents.emit('did-attach-webview', {}, invalid) + invalid.emit('dom-ready') + expect(invalid.close).toHaveBeenCalledExactlyOnceWith({ waitForBeforeUnload: false }) + expect(attach).not.toHaveBeenCalled() + + const approved = { preventDefault: vi.fn() } + f.contents.emit('will-attach-webview', approved, {}, { src: frame.url, partition: reservation.partition }) + expect(approved.preventDefault).not.toHaveBeenCalled() + f.contents.emit('did-attach-webview', {}, guest) + guest.emit('dom-ready') + expect(attach).toHaveBeenCalledExactlyOnceWith(guest, reservation.lease) + const input = { modifiers: [], type: 'keyDown', code: 'KeyT', key: 't', meta: platform === 'macos', + control: platform !== 'macos', alt: false, shift: false, isAutoRepeat: false, isComposing: false } + const preventDefault = vi.fn() + f.contents.send.mockClear() + guest.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, { + kind: 'webview', frameName: reservation.lease, revision: snapshot.revision, code: 'KeyT', + meta: platform === 'macos', control: platform !== 'macos', alt: false, shift: false, repeat: false, + }) + guest.isFocused.mockReturnValue(false) + guest.emit('before-input-event', { preventDefault }, input) + expect(f.contents.send).toHaveBeenCalledOnce() + guest.isFocused.mockReturnValue(true) + const release = guests.release(f.contents as never, reservation.lease) + expect(guest.close).toHaveBeenCalledExactlyOnceWith({ waitForBeforeUnload: false }) + expect(guest.listenerCount('before-input-event')).toBe(0) + guest.emit('before-input-event', { preventDefault }, input) + expect(f.contents.send).toHaveBeenCalledOnce() + guest.emit('destroyed') + await release +}) + +it('keeps browser guest chord state local and leaves accepted bindings active through guest navigation', async () => { + const f = await fixture('macos') + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', + binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, initial.revision) + const frame = { url: 'https://example.test/', parent: null } + const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + isDestroyed: () => false, isFocused: () => true, setIgnoreMenuShortcuts: vi.fn() }) + const blurListeners = f.window.listenerCount('blur') + const closedListeners = f.window.listenerCount('closed') + const dispose = f.keyboard.attachGuest(f.window as never, guest as never, 'guest' as DesktopBrowserLeaseId) + onTestFinished(dispose) + expect(f.window.listenerCount('blur')).toBe(blurListeners) + expect(f.window.listenerCount('closed')).toBe(closedListeners) + const press = (contents: EventEmitter, code: string): void => { + contents.emit('before-input-event', { preventDefault: vi.fn() }, { modifiers: [], type: 'keyDown', code, key: code, + meta: false, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + } + f.contents.send.mockClear() + press(guest, 'KeyF') + guest.emit('blur') + press(guest, 'KeyG') + expect(f.contents.send).not.toHaveBeenCalled() + f.window.emit('blur') + press(guest, 'KeyF') + expect(f.contents.send).not.toHaveBeenCalled() + guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + press(guest, 'KeyF') + press(f.contents, 'KeyG') + expect(f.contents.send).not.toHaveBeenCalled() + press(guest, 'KeyG') + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'webview', frameName: 'guest', code: 'KeyF', secondCode: 'KeyG' })) + guest.emit('destroyed') + press(f.contents, 'KeyF') + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'keyboard', code: 'KeyF', secondCode: 'KeyG' })) +}) + +it('delivers Windows Edit actions to the focused browser guest without invoking its custom shortcut', async () => { + const f = await fixture('windows') + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', + binding: { code: 'KeyC', modifiers: ['control'] } }, initial.revision) + const frame = { url: 'https://example.test/', parent: null } + const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + isDestroyed: () => false, isFocused: () => true, setIgnoreMenuShortcuts: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() }) + const dispose = f.keyboard.attachGuest(f.window as never, guest as never, 'guest' as DesktopBrowserLeaseId) + onTestFinished(dispose) + const events: string[] = [] + guest.sendInputEvent.mockImplementation((input: { type: 'keyDown' | 'keyUp' }) => { + const preventDefault = vi.fn() + guest.emit('before-input-event', { preventDefault }, { ...input, code: 'KeyC', key: 'c', modifiers: ['control'], + control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + if (!preventDefault.mock.calls.length) events.push(input.type) + }) + f.contents.send.mockClear() + f.keyboard.sendEditingKey('C', ['control']) + expect(guest.focus).toHaveBeenCalledOnce() + expect(events).toEqual(['keyDown', 'keyUp']) + expect(f.contents.send).not.toHaveBeenCalled() + expect(f.contents.sendInputEvent).not.toHaveBeenCalled() + dispose() + f.keyboard.sendEditingKey('C', ['control']) + expect(f.contents.sendInputEvent).toHaveBeenCalledTimes(2) +}) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 82c90f46cb..0564fdf91a 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -189,3 +189,35 @@ it('forwards only the focused product iframe and releases native input subscript off() expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsInput, handler) }) + +it('forwards browser guest input only for the focused live webview lease', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + const listener = vi.fn() + const off = api.keyboard.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsInput)![1] as + (event: unknown, state: unknown) => void + const input = { kind: 'webview', frameName: 'guest', revision: 'current' } + const frame = document.createElement('webview') + frame.tabIndex = 0 + frame.setAttribute('name', 'guest') + document.body.append(frame) + frame.focus() + handler({}, input) + expect(listener).not.toHaveBeenCalled() + frame.setAttribute('data-sidebar-browser-frame', 'webview') + handler({}, { ...input, frameName: '' }) + handler({}, { ...input, frameName: 'old' }) + expect(listener).not.toHaveBeenCalled() + handler({}, input) + expect(listener).toHaveBeenCalledExactlyOnceWith(input) + const other = document.createElement('input') + document.body.append(other) + other.focus() + handler({}, input) + frame.remove() + handler({}, input) + expect(listener).toHaveBeenCalledOnce() + off() +}) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 29fa75561d..b7375693d4 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 573d16f37b9ce3c123c55c366d8ee6424feb3d64 -config-catalog.zh.md: 9d827a4c65ec198239d7d1ef29735747a39838b9 +config-catalog.md: 5957a8be6abaa5906cf358a2a1dbaac0cb8d1c39 +config-catalog.zh.md: 7058b149bb9aa9236764c754dde7622db4090901 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 573d16f37b..5957a8be6a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -481,6 +481,20 @@ export interface Config { Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-client-shortcuts` + +```ts config-catalog +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} +``` + +Source: [`packages/client/shortcuts/src/config.ts:5`](../packages/client/shortcuts/src/config.ts) + ## `@deepseek-ai/dsh-client-ui-settings-account` @@ -4127,6 +4141,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-settings-shell` ([`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-subagent` ([`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-web-search` ([`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-shortcuts` ([`packages/client/ui-shortcuts/src/index.ts`](../packages/client/ui-shortcuts/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-browser` ([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9d827a4c65..7058b149bb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -483,6 +483,20 @@ export interface Config { 来源: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-client-shortcuts` + +```ts config-catalog +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} +``` + +来源: [`packages/client/shortcuts/src/config.ts:5`](../packages/client/shortcuts/src/config.ts) + ## `@deepseek-ai/dsh-client-ui-settings-account` @@ -4129,6 +4143,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-settings-shell`([`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-subagent`([`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-web-search`([`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-shortcuts` ([`packages/client/ui-shortcuts/src/index.ts`](../packages/client/ui-shortcuts/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-browser`([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 5f3fa3d374..c74bb1441d 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 13ac82b2c9fb0b2aeeaa2bec9c736db5b5b63992 -event-producer-consumer.zh.md: fce8463041181139e45fb5f3a9ab14c87117135f +event-producer-consumer.md: bb80e1a51bb5e718d2bef7fdf24b3a0048645df2 +event-producer-consumer.zh.md: ccdf22451656aa80493dc060170ca6e5e3f4b46f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 13ac82b2c9..bb80e1a51b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -75,7 +75,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index fce8463041..ccdf224516 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -77,7 +77,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 8f2887a8d4..17b0c377e5 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: fc5c06727e58705ac6d9b44470b1b37010dc3223 -module-graph.zh.md: 6911d9820932569c7a666b47a8bd53696fdb6393 +module-graph.md: 5e07e6a4a38cbcd866dcbaab1cbf449e3e822c09 +module-graph.zh.md: 9d094d061872000e840be1c09e9ea6e458222318 diff --git a/docs/module-graph.md b/docs/module-graph.md index fc5c06727e..5e07e6a4a3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -152,6 +152,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_resources["client-resources"] + pkg_client_shortcuts["client-shortcuts"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] @@ -189,6 +190,7 @@ flowchart TD pkg_client_ui_settings_shell["client-ui-settings-shell"] pkg_client_ui_settings_subagent["client-ui-settings-subagent"] pkg_client_ui_settings_web_search["client-ui-settings-web-search"] + pkg_client_ui_shortcuts["client-ui-shortcuts"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -1372,6 +1374,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-resources`](../packages/client/resources) | `client` | — | +| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | @@ -1409,6 +1412,7 @@ flowchart TD | [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — | | [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — | | [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — | +| [`client-ui-shortcuts`](../packages/client/ui-shortcuts) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6911d98209..9d094d0618 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -154,6 +154,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_resources["client-resources"] + pkg_client_shortcuts["client-shortcuts"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] @@ -191,6 +192,7 @@ flowchart TD pkg_client_ui_settings_shell["client-ui-settings-shell"] pkg_client_ui_settings_subagent["client-ui-settings-subagent"] pkg_client_ui_settings_web_search["client-ui-settings-web-search"] + pkg_client_ui_shortcuts["client-ui-shortcuts"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -1374,6 +1376,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-resources`](../packages/client/resources) | `client` | — | +| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | @@ -1411,6 +1414,7 @@ flowchart TD | [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — | | [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — | | [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — | +| [`client-ui-shortcuts`](../packages/client/ui-shortcuts) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index b7d80f9227..398ed88e7c 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/README.md -README.md: 8a5f789a4ee6343e00ee2b9cdc68d6bf7e7f8d79 -README.zh.md: ae5dbb237f504ca0cd66a8105dd772493b356326 +README.md: a7e4228fac5e4e9c7b50a67b8d66cb244df945f7 +README.zh.md: d5c33dc99d40e1e203848aa14a8c23c492b011a8 diff --git a/packages/client/shortcuts/README.i18n.yaml b/packages/client/shortcuts/README.i18n.yaml index 9e35757507..9f91920d60 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: 7cfba95eaceb262b20870b521236fd6d7fe639a9 -README.zh.md: 84960f311c9eda2ea0e0fa62e38196b427f91a2f +README.md: 3583a31046946a29d3f8ddb1b263d4b676d45dc0 +README.zh.md: 0f1fef10405509740b2e0ce1f672946c1d898abb diff --git a/packages/client/shortcuts/README.md b/packages/client/shortcuts/README.md index 7cfba95eac..3583a31046 100644 --- a/packages/client/shortcuts/README.md +++ b/packages/client/shortcuts/README.md @@ -49,7 +49,7 @@ Windows and macOS Desktop intercept complete accepted combinations before local 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. -Desktop native menus and embedded frames use the same command registry as DOM input. Preload matches the focused iframe name against the embedding frame reported by Electron; the client rejects messages from an older configuration. Native menu selection is explicit and works without a bound accelerator. Two-key chords have no native menu accelerator; their labels remain visible in the shortcut reference. Terminal Ctrl+W/R remains local input outside Windows and macOS Desktop. Explicit native-menu actions retain their modal checks; keyboard bindings use the device dispatch policy. +Desktop native menus and embedded frames use the same command registry as DOM input. Preload and the client match the focused iframe name or browser webview lease against native input; the client rejects messages from an older configuration. Browser guest input ends when its lease is released, before guest destruction finishes. Native menu selection is explicit and works without a bound accelerator. Two-key chords have no native menu accelerator; their labels remain visible in the shortcut reference. Terminal Ctrl+W/R remains local input outside Windows and macOS Desktop. Explicit native-menu actions retain their modal checks; keyboard bindings use the device dispatch policy. diff --git a/packages/client/shortcuts/README.zh.md b/packages/client/shortcuts/README.zh.md index 84960f311c..0f1fef1040 100644 --- a/packages/client/shortcuts/README.zh.md +++ b/packages/client/shortcuts/README.zh.md @@ -49,7 +49,7 @@ Windows 和 macOS Desktop 在局部处理器之前拦截已接受的完整组合 DOM 固定输入观察者在应用分发前收到输入法组合和消费标记。消费事件后,后续观察者和命令不能使用它。焦点、指针、窗口失焦、输入法组合、模态变化和局部停止传播会重置待完成的 DOM 序列。原生双键状态在窗口失焦、焦点 frame 或配置变化、输入法组合、录制和修饰键释放时重置。Electron 在原生拦截后可能省略 keyup;原生绑定完成时立即清空按住状态,因此每次触发双键组合都需要重新按下两个键。释放修饰键会清空按住状态,因为 macOS 在按住 Command 时可能省略字符键的 keyup。适配器释放时移除监听器和模态观察者;单个功能观察者失败不妨碍其他观察者接收输入。 -Desktop 原生菜单和内嵌 frame 使用与 DOM 输入相同的命令注册表。Preload 将获得焦点的 iframe 名称与 Electron 报告的嵌入 frame 匹配;Client 拒绝旧配置消息。原生菜单选择是显式操作,不依赖快捷键绑定。双键组合没有原生菜单快捷键,其键位仍显示在快捷键速查中。Windows 和 macOS Desktop 之外的终端 Ctrl+W/R 保留为局部输入。显式原生菜单操作保留模态检查;键盘绑定使用对应设备的分发策略。 +Desktop 原生菜单和内嵌 frame 使用与 DOM 输入相同的命令注册表。Preload 和 Client 将获焦 iframe 的名称或浏览器 webview 租约与原生输入匹配;Client 拒绝旧配置消息。浏览器 guest 的输入监听在租约释放时结束,无需等待 guest 销毁完成。原生菜单选择是显式操作,不依赖快捷键绑定。双键组合没有原生菜单快捷键,其键位仍显示在快捷键速查中。Windows 和 macOS Desktop 之外的终端 Ctrl+W/R 保留为局部输入。显式原生菜单操作保留模态检查;键盘绑定使用对应设备的分发策略。 diff --git a/packages/client/shortcuts/src/client/native.ts b/packages/client/shortcuts/src/client/native.ts index 4ac1a1a895..faf162f63c 100644 --- a/packages/client/shortcuts/src/client/native.ts +++ b/packages/client/shortcuts/src/client/native.ts @@ -1,4 +1,4 @@ -/** Desktop gestures use live focus, modal state, and verified iframe ownership. */ +/** Desktop gestures use live focus, modal state, and verified embedding ownership. */ import type { DesktopKeyboardApi, ShortcutConfigSnapshot } from '../protocol.ts' import type { ShortcutRegistry } from './registry.ts' @@ -17,7 +17,9 @@ export function installNativeKeyboard(window: Window, keyboard: DesktopKeyboardA if (input.revision !== snapshot().revision) return reset?.() let target = window.document.activeElement - while (target?.shadowRoot?.activeElement != null) target = target.shadowRoot.activeElement + while (target?.shadowRoot?.activeElement != null && !target.matches('webview[data-sidebar-browser-frame]')) { + target = target.shadowRoot.activeElement + } const top = [...window.document.querySelectorAll('[role="dialog"][aria-modal="true"], [role="menu"]')].at(-1) const region = target?.closest('.xterm') ? 'terminal' as const : target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]') ? 'editable' as const : 'page' as const @@ -28,6 +30,12 @@ export function installNativeKeyboard(window: Window, keyboard: DesktopKeyboardA return } // Renderer focus and embedding identity may change after preload verification. + if (input.kind === 'webview') { + if (target?.matches('webview[data-sidebar-browser-frame]') !== true || !target.isConnected + || input.frameName === '' || target.getAttribute('name') !== input.frameName) return + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, { ...context, source: 'webview' }, () => {}) + return + } if (!(target instanceof HTMLIFrameElement) || !target.isConnected || !target.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]') || input.frameName === '' || target.name !== input.frameName) return diff --git a/packages/client/shortcuts/src/client/types.ts b/packages/client/shortcuts/src/client/types.ts index 5b74faeeb0..d374b89618 100644 --- a/packages/client/shortcuts/src/client/types.ts +++ b/packages/client/shortcuts/src/client/types.ts @@ -6,7 +6,7 @@ import type { BindingIssue, NormalizedBinding, ShortcutConfigSnapshot, ShortcutE export type ShortcutRegion = 'page' | 'editable' | 'terminal' /** Synchronous DOM context; owners capture business targets in resolve(). */ export interface ShortcutContext { - readonly source?: 'keyboard' | 'menu' | 'iframe' + readonly source?: 'keyboard' | 'menu' | 'iframe' | 'webview' readonly region: ShortcutRegion readonly modal: string | null /** Original input element, or null when no document target is available. */ diff --git a/packages/client/shortcuts/src/native.ts b/packages/client/shortcuts/src/native.ts index 9b5628b05e..45e0817488 100644 --- a/packages/client/shortcuts/src/native.ts +++ b/packages/client/shortcuts/src/native.ts @@ -2,11 +2,11 @@ import type { ShortcutRevision } from './persistence.ts' import type { ShortcutCommandId } from './binding.ts' -/** Native input carries the accepted configuration identity; iframe names identify the focused embedding element. */ +/** Native input carries the accepted configuration identity; frame names identify the focused iframe or browser guest embedding element. */ export type DesktopShortcutInput = { readonly revision: ShortcutRevision } & ({ readonly kind: 'menu'; readonly commandId: ShortcutCommandId } - | { readonly kind: 'keyboard' | 'iframe' + | { readonly kind: 'keyboard' | 'iframe' | 'webview' readonly frameName: string readonly code: string readonly secondCode?: string diff --git a/packages/client/shortcuts/tests/native.client.spec.ts b/packages/client/shortcuts/tests/native.client.spec.ts index 9d8b32c986..03bd2d378a 100644 --- a/packages/client/shortcuts/tests/native.client.spec.ts +++ b/packages/client/shortcuts/tests/native.client.spec.ts @@ -125,3 +125,43 @@ it.each(['macos', 'windows'] as const)('runs a custom %s chord across text field expect(run).toHaveBeenCalledTimes(2) } finally { off() } }) + +it('checks the focused browser guest lease before dispatch and retains its embedding context across shadow focus', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const run = vi.fn() + const resolve = vi.fn(() => ({ status: 'handled' as const, run })) + registry.register({ id: 'browser.new' as ShortcutCommandId, label: () => 'Browser', aliases: [], + defaults: { 'desktop:macos': { code: 'KeyT', modifiers: ['primary'] } }, regions: ['page'], modals: [], resolve }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot()) + const input: DesktopShortcutInput = { kind: 'webview', frameName: 'guest', revision: registry.config.getSnapshot().revision, + code: 'KeyT', control: false, alt: false, shift: false, meta: true, repeat: false } + const frame = document.createElement('webview') + frame.tabIndex = 0 + frame.setAttribute('name', 'guest') + document.body.append(frame) + frame.focus() + const shadow = document.createElement('div').attachShadow({ mode: 'open' }) + Object.defineProperty(shadow, 'activeElement', { value: document.createElement('input') }) + const shadowRoot = vi.spyOn(frame, 'shadowRoot', 'get').mockReturnValue(shadow) + try { + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.setAttribute('data-sidebar-browser-frame', 'webview') + deliver({ ...input, frameName: '' }) + deliver({ ...input, frameName: 'old' }) + deliver({ ...input, revision: 'old' as typeof input.revision }) + expect(run).not.toHaveBeenCalled() + deliver(input) + expect(run).toHaveBeenCalledOnce() + expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ source: 'webview', target: frame })) + deliver({ ...input, repeat: true }) + frame.remove() + deliver(input) + expect(run).toHaveBeenCalledOnce() + } finally { + shadowRoot.mockRestore() + off() + } +}) diff --git a/packages/client/shortcuts/tsconfig.client.json b/packages/client/shortcuts/tsconfig.client.json index 06afec5c65..f795e9fd2e 100644 --- a/packages/client/shortcuts/tsconfig.client.json +++ b/packages/client/shortcuts/tsconfig.client.json @@ -16,7 +16,7 @@ "path": "../../util/brand" }, { - "path": "../locale" + "path": "../locale/tsconfig.client.json" }, { "path": "../store" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index c562723cd5..96e6192341 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 1cba7ffd9ec211b34f822afd3032263b2879b8e1 -README.zh.md: 350634cc2c7510af1203fe3eed286f707aa398ba +README.md: cef9e7cfa0498d1b8eb9d81bb7596f385621e39c +README.zh.md: 4365739aa5e9d18ef538fd349d9b6dd1c32ed130 diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index c4e435e80a..87195baa7d 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -23,7 +23,7 @@ export type OpenTargetOperation = { readonly kind: 'default' } | { readonly kind /** Inputs shared by both target adapters and the file empty-state action. */ export interface OpenTargetButtonProps { - readonly shortcut?: Pick + readonly shortcut?: Pick | undefined readonly kind: 'file' | 'directory' readonly applications: readonly OpenTargetApplication[] readonly defaultId: string | undefined diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index 90592df06a..53bd485ebf 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -51,7 +51,7 @@ async function bench() { 'deliverables.review.file.actions': { kind: 'list', scope: 'session' }, }, } as never, () => null) - const list = createSnapshotStore({ ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} }) + const list = createSnapshotStore({ ids: [], byId: {}, phase: 'ready', projectionsBySession: {} }) const commands = new Map() const layoutStore = createLayoutStore().create() const layout = new LayoutController(layoutStore.actions, id => id === 'plugins', { diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index ce6a761fe1..9534cb204a 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -81,6 +81,8 @@ describe('ui-plan browser apply', () => { store.actions.open(parent) const unbind = controller.bind({ sessionId: parent, actions: store.actions, surfaces: store.getSnapshot().bySession, canSplitPane: () => true, + closeWithFocus: (_paneId, close) => { close() }, + openWithFocus: (open) => { open() }, }) b.openResource.mockImplementation(controller.openResource.bind(controller)) b.openResourceIn.mockImplementation(controller.openResourceIn.bind(controller)) diff --git a/packages/client/ui-shortcuts/tsconfig.json b/packages/client/ui-shortcuts/tsconfig.json index 29c023f7cd..79242fd4a6 100644 --- a/packages/client/ui-shortcuts/tsconfig.json +++ b/packages/client/ui-shortcuts/tsconfig.json @@ -18,7 +18,7 @@ "path": "../shortcuts/tsconfig.client.json" }, { - "path": "../locale" + "path": "../locale/tsconfig.client.json" }, { "path": "../store" @@ -36,7 +36,7 @@ "path": "../ui-layout" }, { - "path": "../ui-settings" + "path": "../ui-settings/tsconfig.client.json" } ] } diff --git a/packages/client/ui-sidebar-browser/README.i18n.yaml b/packages/client/ui-sidebar-browser/README.i18n.yaml index 5711eb390c..6e6f65df4c 100644 --- a/packages/client/ui-sidebar-browser/README.i18n.yaml +++ b/packages/client/ui-sidebar-browser/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-browser/README.md -README.md: 33ef2d8e4dd82a67f826b4b446604ac88d3458ea -README.zh.md: 1d822796ed33cc6ac27c6d75e27a7d44039c7711 +README.md: 46d08fd74e4f4605812c8d6e014a9fc887e76def +README.zh.md: b8cd0c3213fbf1514d1d9d5c97bf7d6f0e865099 diff --git a/packages/client/ui-sidebar-browser/README.md b/packages/client/ui-sidebar-browser/README.md index 33ef2d8e4d..46d08fd74e 100644 --- a/packages/client/ui-sidebar-browser/README.md +++ b/packages/client/ui-sidebar-browser/README.md @@ -70,7 +70,7 @@ Each tab's `BrowserController` owns address validation, commands and explicit re Desktop's main process approves guest leases and enforces attachment, navigation and permission policy. Preload exposes only scoped Browser operations. Shared declarations use the standard `/types` export with `import type`; the Host and Client compile through separate tsconfig files. Desktop Browser tabs declare `keepMounted`, so Sidebar preserves their DOM across tab changes, Session switches, collapse and floating. -The page refresh shortcut calls the same reload operation as the toolbar. Its tooltip and ARIA key combination follow the effective binding. Desktop can route that shortcut from the embedded page; Web leaves browser-reserved combinations unchanged. +The page refresh shortcut calls the same reload operation as the toolbar. Its tooltip and ARIA key combination follow the effective binding. Desktop routes accepted shortcuts from an approved guest through its owning window; the focused webview must still carry that guest’s lease. Web leaves browser-reserved combinations unchanged. diff --git a/packages/client/ui-sidebar-browser/README.zh.md b/packages/client/ui-sidebar-browser/README.zh.md index 1d822796ed..b8cd0c3213 100644 --- a/packages/client/ui-sidebar-browser/README.zh.md +++ b/packages/client/ui-sidebar-browser/README.zh.md @@ -70,7 +70,7 @@ Web 记录 toolbar 提交和 typed tab 打开。导航状态机把每个受控 r Desktop 主进程批准 guest 租约,并执行挂载、导航和权限策略。preload 只暴露限定范围的 Browser 操作。共享声明通过标准 `/types` 出口配合 `import type` 引入;Host 与 Client 使用独立 tsconfig 编译。Desktop Browser tab 声明 `keepMounted`,Sidebar 因而在切 tab、切 Session、收起与浮动期间保留其 DOM。 -页面刷新快捷键调用工具栏使用的同一重载操作。其 Tooltip 和 ARIA 组合随有效绑定更新。桌面可从内嵌页面路由该快捷键;Web 保留浏览器专用组合。 +页面刷新快捷键调用工具栏使用的同一重载操作。其 Tooltip 和 ARIA 组合随有效绑定更新。Desktop 通过所属窗口路由已批准 guest 中的有效快捷键;获焦 webview 必须仍持有该 guest 的租约。Web 保留浏览器专用组合。 diff --git a/packages/client/ui-sidebar-browser/src/client/electron/ElectronWebviewPresentation.ts b/packages/client/ui-sidebar-browser/src/client/electron/ElectronWebviewPresentation.ts index 4fc6798e50..5d3ff43d40 100644 --- a/packages/client/ui-sidebar-browser/src/client/electron/ElectronWebviewPresentation.ts +++ b/packages/client/ui-sidebar-browser/src/client/electron/ElectronWebviewPresentation.ts @@ -53,6 +53,7 @@ export class ElectronWebviewPresentation implements BrowserPresentation { const element = document.createElement('webview') as WebviewElement element.className = css.webview as string element.dataset.sidebarBrowserFrame = 'webview' + element.setAttribute('name', reservation.lease) element.setAttribute('partition', reservation.partition) element.setAttribute('allowpopups', '') element.setAttribute('src', 'about:blank#' + reservation.lease) diff --git a/packages/client/ui-sidebar-browser/tests/electron-lifecycle.client.spec.ts b/packages/client/ui-sidebar-browser/tests/electron-lifecycle.client.spec.ts index ef14e6de07..4cfed5aad2 100644 --- a/packages/client/ui-sidebar-browser/tests/electron-lifecycle.client.spec.ts +++ b/packages/client/ui-sidebar-browser/tests/electron-lifecycle.client.spec.ts @@ -24,6 +24,7 @@ it('waits for a mounted container and observes native history, titles and new-ta expect(h.bridge.acquire).not.toHaveBeenCalled() h.mount() const guest = await h.guest() + expect(guest.element.getAttribute('name')).toBe(h.reservation.lease) expect(guest.element.getAttribute('partition')).toBe(h.reservation.partition) expect(guest.element.getAttribute('src')).toBe(`about:blank#${h.reservation.lease}`) guest.emit('dom-ready') diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index d632694cae..98696d0f60 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -171,7 +171,10 @@ export interface SidebarRightTabInfo { readonly tab: TabRecord & { /** Effective refresh binding for this page's controls. */ readonly refreshShortcut?: ShortcutCatalogEntry | undefined - /** Only the foreground Session is visible. Docked bodies require expansion and selection; expanded titles include inactive tabs. Floats survive collapse. */ + /** + * Only the foreground Session is visible. Docked bodies require expansion and selection; + * expanded titles include inactive tabs. Floats survive collapse. + */ readonly visible: boolean readonly navigation: SidebarRightTabNavigation /** Aborted only when the record disappears or this plugin unloads, not on hide or session switch. */ diff --git a/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx b/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx index d50375e299..6a2d338b13 100644 --- a/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx +++ b/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx @@ -8,7 +8,9 @@ import type { ForkSessionInjected, SessionMenuItemProps } from '../contract/slot * @param props - owner share, menu open state, and the fork share. * @returns the row. */ -export function ForkSessionMenuItem({ sessionId, useMenuOpenState, useShortcuts, forkSession, t }: SessionMenuItemProps) { +export function ForkSessionMenuItem({ + sessionId, useMenuOpenState, useShortcuts, forkSession, t, +}: SessionMenuItemProps) { const [, setMenuOpen] = useMenuOpenState() const shortcut = useShortcuts(rows => rows.find(row => row.id === 'session.fork')) return ( diff --git a/packages/client/ui-workspace/tests/shortcuts.client.spec.ts b/packages/client/ui-workspace/tests/shortcuts.client.spec.ts index 750c70393b..c46645cbc6 100644 --- a/packages/client/ui-workspace/tests/shortcuts.client.spec.ts +++ b/packages/client/ui-workspace/tests/shortcuts.client.spec.ts @@ -48,9 +48,13 @@ async function bench(runtime: 'web' | 'desktop' = 'desktop') { locale.register('workspace', { en, zh }) locale.setLocale('en') ctx.provide('locale', locale) - const navigation = { startSession: vi.fn(), forkSession: vi.fn(async () => {}), archiveSession: vi.fn(async () => {}) } + const navigation = { + startSession: vi.fn(), forkSession: vi.fn(async () => {}), archiveSession: vi.fn(async (_sessionId: SessionId) => {}), + } const controls = createWorkspaceShortcutControls() - const fiber = ctx.plugin((scoped) => { installWorkspaceShortcuts(scoped, navigation as unknown as UiWorkspace, controls, navigation.archiveSession) }) + const fiber = ctx.plugin((scoped) => { + installWorkspaceShortcuts(scoped, navigation as unknown as UiWorkspace, controls, (id) => { void navigation.archiveSession(id) }) + }) await fiber.await() const select = (id: string) => { list.set({ ...list.getSnapshot(), byId: { [sid('a')]: row('a', id === 'a'), [sid('b')]: row('b', id === 'b'), diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e4008309a3..96abd99b33 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -696,7 +696,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:99', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:101', }, { key: 'conversation.input.activity', @@ -1760,7 +1760,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main\', () => ctx.slots.register(\n { name: \'main\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:66', + source: 'packages/client/ui-layout/src/client/index.ts:73', }, { key: 'main.conversation', @@ -2147,7 +2147,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'rightbar\', () => ctx.slots.register(\n { name: \'rightbar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:80', + source: 'packages/client/ui-layout/src/client/index.ts:87', }, { key: 'rightbar.session', @@ -2189,7 +2189,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'rightbar.session\', () => ctx.slots.register(\n { name: \'rightbar.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:42', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:43', }, { key: 'root', @@ -2355,6 +2355,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-permission-presets PermissionRow id \'permission\'', 'client-ui-settings-general DeveloperToolsRow id \'developer-tools\'', 'client-ui-settings-general CurrentVersionRow id \'current-version\'', + 'client-ui-shortcuts ShortcutsRow id \'shortcuts\'', 'client-ui-theme AppearanceRow id \'appearance\'', 'client-ui-theme FontSizeRow id \'font-size\'', ], @@ -2752,7 +2753,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.leading\', () => ctx.slots.register(\n { name: \'shell.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:105', + source: 'packages/client/ui-layout/src/client/index.ts:112', }, { key: 'shell.overlay', @@ -2796,13 +2797,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', occupants: [ + 'client-ui-shortcuts ShortcutReference id \'shortcuts\'', 'client-ui-workspace SessionRenameDialog id \'workspace.session-rename\'', 'client-ui-workspace SessionArchiveConfirmDialog id \'workspace.session-archive\'', 'client-ui-workspace RowActionToast id \'workspace.row-toast\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:91', + source: 'packages/client/ui-layout/src/client/index.ts:98', }, { key: 'sidebar', @@ -2833,7 +2835,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar\', () => ctx.slots.register(\n { name: \'sidebar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:61', + source: 'packages/client/ui-layout/src/client/index.ts:68', }, { key: 'sidebar.brand.mark', @@ -2864,7 +2866,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.mark\', () => ctx.slots.register(\n { name: \'sidebar.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:24', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:25', }, { key: 'sidebar.brand.name', @@ -2895,7 +2897,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n { name: \'sidebar.brand.name\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:29', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:30', }, { key: 'sidebar.chat.conversation', @@ -2982,7 +2984,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.footer.action\', () => ctx.slots.register(\n { name: \'sidebar.footer.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:52', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:53', }, { key: 'sidebar.panellist', @@ -3032,7 +3034,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.panellist\', () => ctx.slots.register(\n { name: \'sidebar.panellist\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:34', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:35', }, { key: 'sidebar.right.pane.tab', @@ -3083,7 +3085,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:58', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:59', }, { key: 'sidebar.right.pane.tab.title', @@ -3132,7 +3134,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab.title\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab.title\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:72', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:73', }, { key: 'sidebar.right.tab.document', @@ -3437,7 +3439,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.guide\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.guide\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:83', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:84', }, { key: 'sidebar.right.tab.guide.entry', @@ -3483,7 +3485,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.guide.entry\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.guide.entry\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:90', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:91', }, { key: 'sidebar.right.tab.menu.item', @@ -3541,7 +3543,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.menu.item\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.menu.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:102', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:103', }, { key: 'sidebar.settings', @@ -3572,7 +3574,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.settings\', () => ctx.slots.register(\n { name: \'sidebar.settings\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:47', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:48', }, { key: 'sidebar.toggle.badge', @@ -3601,7 +3603,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.toggle.badge\', () => ctx.slots.register(\n { name: \'sidebar.toggle.badge\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:18', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:19', }, { key: 'sidebar.workspaces', @@ -3632,7 +3634,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces\', () => ctx.slots.register(\n { name: \'sidebar.workspaces\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:41', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:42', }, { key: 'sidebar.workspaces.directoryFlow', @@ -3664,7 +3666,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:101', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:103', }, { key: 'sidebar.workspaces.session.menu.item', @@ -3709,7 +3711,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], keyDomain: '', hookContext: 'MenuOpenState', - slotInject: '{ hooks: { menuOpenState: SlotHookFactory<\'sidebar.workspaces.session.menu.item\', UseMenuOpenState> } }', + slotInject: '{ hooks: { menuOpenState: SlotHookFactory<\'sidebar.workspaces.session.menu.item\', UseMenuOpenState> shortcuts: HostObservable } }', declaredBy: 'an entry in \'sidebar.workspaces\' (client-ui-workspace), so it exists while that entry is mounted', occupants: [ 'client-ui-workspace PinSessionMenuItem id \'pin\'', @@ -3719,7 +3721,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n const copyLabel = \'Copy Session ID\' // Localize in the contributing package.\n ctx.slots.inject(\'sidebar.workspaces.session.menu.item\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.session.menu.item\', id: \'copy-session-id\', order: 500 },\n ({ sessionId, useMenuOpenState }) => {\n const [, setMenuOpen] = useMenuOpenState()\n return React.createElement(\n \'button\',\n { type: \'button\', role: \'menuitem\', onClick: () => { setMenuOpen(false); void navigator.clipboard.writeText(sessionId) } },\n copyLabel,\n )\n },\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:133', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:135', }, { key: 'sidebar.workspaces.session.row.action', @@ -3772,7 +3774,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.session.row.action\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.session.row.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:148', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:153', }, { key: 'tool.call.images', diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 018e5ce938..1ce0249ee8 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -63,6 +63,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-client-locale` | no | Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries | | `@deepseek-ai/dsh-client-modules` | no | Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam | | `@deepseek-ai/dsh-client-resources` | no | Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook | +| `@deepseek-ai/dsh-client-shortcuts` | yes | Application keyboard command registry and physical-key routing | | `@deepseek-ai/dsh-client-ui-agent-preset` | no | Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor | | `@deepseek-ai/dsh-client-ui-approval` | no | Approval composer takeover over the scoped Remote Event waterfall | | `@deepseek-ai/dsh-client-ui-attachment` | no | Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots | @@ -97,6 +98,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-client-ui-settings-shell` | no | Settings page of the shell executor on the dsh web client's Plugins page: the command timeout and the per-stream output cap of the shell namespace | | `@deepseek-ai/dsh-client-ui-settings-subagent` | no | Settings page of Subagent delegation on the dsh web client's Plugins page: recursion depth, parallel capacity, and the models agents may choose for subagents | | `@deepseek-ai/dsh-client-ui-settings-web-search` | no | Settings page of the DeepSeek web-search provider on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget | +| `@deepseek-ai/dsh-client-ui-shortcuts` | no | Keyboard shortcut reference, recording, and local preference editing | | `@deepseek-ai/dsh-client-ui-sidebar` | no | Sidebar plugin: session multi-level tree, search, grouping, state dots | | `@deepseek-ai/dsh-client-ui-sidebar-browser` | no | Sandboxed Web browser tabs for the right Sidebar | | `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` | yes | Extensible Sidebar previews for Office documents, spreadsheets, Markdown, code, images, PDF, HTML, and plain text |