fix(shortcuts): integrate current desktop guests and client APIs

This commit is contained in:
Xinyu Yang committed 2026-09-21 23:57:50 +08:00
1 parent 069c370987
commit 7b564f61e7
43 files changed
+468 -172

No files matched your search

@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 的显示效果。
+6 -2
View File
@@ -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)
+131 -104
View File
@@ -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<WebContents, { window: BrowserWindow; reset(): void }>()
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<string>()
const consumed = new Map<string, 'press' | 'repeat'>()
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<Electron.WebContentsDidStartNavigationEventParams>): 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<string>()
const consumed = new Map<string, 'press' | 'repeat'>()
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<Electron.WebContentsDidStartNavigationEventParams>): 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()
+1 -1
View File
@@ -836,7 +836,7 @@ async function main(): Promise<void> {
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 })
+5
View File
@@ -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)
+127 -2
View File
@@ -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<ShortcutConfigSnapshot>(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<ShortcutConfigSnapshot>(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<ShortcutConfigSnapshot>(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)
})
+32
View File
@@ -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()
})
+2 -2
View File
@@ -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
+15
View File
@@ -481,6 +481,20 @@ export interface Config {
Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
<a id="deepseek-aidsh-client-shortcuts"></a>
## `@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)
<a id="deepseek-aidsh-client-ui-settings-account"></a>
## `@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))
+15
View File
@@ -483,6 +483,20 @@ export interface Config {
来源: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
<a id="deepseek-aidsh-client-shortcuts"></a>
## `@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)
<a id="deepseek-aidsh-client-ui-settings-account"></a>
## `@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))
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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) |
+1 -1
View File
@@ -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) |
+2 -2
View File
@@ -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
+4
View File
@@ -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` | — |
+4
View File
@@ -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` | — |
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
</details>
+1 -1
View File
@@ -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 保留为局部输入。显式原生菜单操作保留模态检查;键盘绑定使用对应设备的分发策略。
</details>
+10 -2
View File
@@ -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<HTMLElement>('[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
@@ -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. */
+2 -2
View File
@@ -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
@@ -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()
}
})
@@ -16,7 +16,7 @@
"path": "../../util/brand"
},
{
"path": "../locale"
"path": "../locale/tsconfig.client.json"
},
{
"path": "../store"
@@ -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
@@ -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<ShortcutCatalogEntry, 'keys' | 'aria'>
readonly shortcut?: Pick<ShortcutCatalogEntry, 'keys' | 'aria'> | undefined
readonly kind: 'file' | 'directory'
readonly applications: readonly OpenTargetApplication[]
readonly defaultId: string | undefined
@@ -51,7 +51,7 @@ async function bench() {
'deliverables.review.file.actions': { kind: 'list', scope: 'session' },
},
} as never, () => null)
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, phase: 'ready', projectionsBySession: {} })
const commands = new Map<string, ShortcutCommand>()
const layoutStore = createLayoutStore().create()
const layout = new LayoutController(layoutStore.actions, id => id === 'plugins', {
@@ -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))
+2 -2
View File
@@ -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"
}
]
}
@@ -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
+1 -1
View File
@@ -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.
</details>
@@ -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 保留浏览器专用组合。
</details>
@@ -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)
@@ -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')
@@ -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. */
@@ -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<ForkSessionInjected>) {
export function ForkSessionMenuItem({
sessionId, useMenuOpenState, useShortcuts, forkSession, t,
}: SessionMenuItemProps<ForkSessionInjected>) {
const [, setMenuOpen] = useMenuOpenState()
const shortcut = useShortcuts(rows => rows.find(row => row.id === 'session.fork'))
return (
@@ -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'),
@@ -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: \'<one key the owner dispatches>\' },\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: \'<one key the owner dispatches>\' },\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: \'<one key the owner dispatches>\' },\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: \'<one key the owner dispatches>\' },\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<readonly ShortcutCatalogEntry[]> } }',
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',
@@ -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 |