Browse Source

fix(desktop): preserve caption editing and navigation across overlays

07akioni 5 days ago
parent
commit
e36bdaa914

+ 2 - 2
.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.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/feature/2026-09-16-windows-desktop-titlebar.md
-2026-09-16-windows-desktop-titlebar.md: 349f7b31ce0462f5dee9122918640d1c772b3a50
-2026-09-16-windows-desktop-titlebar.zh.md: db883ebd9d0c584541fd524386d0b03c1092b02a
+2026-09-16-windows-desktop-titlebar.md: 679e24b9e5b27e1f7dfce86ef35fd3d472651c37
+2026-09-16-windows-desktop-titlebar.zh.md: 6cc3c1ce35d150b8211caf5bcf350607f9b173fa

+ 1 - 1
.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md

@@ -22,7 +22,7 @@ Custom HTML window controls would make the application responsible for native ca
 
 ## Consequences
 
-The caption remains available above fullscreen file panels, and collapsed navigation consumes no vertical rail. Caption menus preserve application and editing commands while ordinary Web documents receive no desktop controls. Editing actions send the corresponding keys to the focused editor so its own undo history applies. Pointer activation preserves editor focus; menu completion clears the active entry. Main-process validation rejects foreign frames, unknown menu names, and invalid anchor coordinates. Native popup appearance follows the operating system.
+The caption remains available above fullscreen file panels, and collapsed navigation consumes no vertical rail. Caption menus preserve application and editing commands while ordinary Web documents receive no desktop controls. Editing actions send the corresponding keys to the focused editor so its own undo history applies. Caption entries stay above content modal overlays. Keyboard activation restores the last editor and selection before dispatch; pointer activation preserves editor focus; menu completion clears the active entry. Main-process validation rejects foreign frames, unknown menu names, and invalid anchor coordinates. Native popup appearance follows the operating system.
 
 ## Testing
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md

@@ -22,7 +22,7 @@ Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root
 
 ## Consequences
 
-顶栏在全屏文件面板上方保持可用,收起的导航不再占用纵向轨道。顶栏菜单保留应用与编辑命令,普通 Web 文档不获得桌面控件。编辑操作向当前编辑器发送对应按键,以使用编辑器自身的撤销历史。指针激活保留编辑器焦点;菜单关闭后清除入口激活态。主进程校验拒绝其他框架、未知菜单名称和无效锚点坐标。原生弹出菜单外观跟随操作系统。
+顶栏在全屏文件面板上方保持可用,收起的导航不再占用纵向轨道。顶栏菜单保留应用与编辑命令,普通 Web 文档不获得桌面控件。编辑操作向当前编辑器发送对应按键,以使用编辑器自身的撤销历史。顶栏入口位于内容弹窗遮罩之上。键盘激活在派发前恢复上一编辑器及选区;指针激活保留编辑器焦点;菜单关闭后清除入口激活态。主进程校验拒绝其他框架、未知菜单名称和无效锚点坐标。原生弹出菜单外观跟随操作系统。
 
 ## Testing
 

+ 2 - 2
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: c6c8b54ea1eb9d54c15eec6d140f39652dfdce9c
-README.zh.md: b88343cc5b2b7344ab13e1fe2a14771e8b2edba9
+README.md: 275aeb04e218b93f8bf0d3d5f28f3013af4657f9
+README.zh.md: 920ee27a1bc3a29c0d06396f8f2f94f468a5fd2d

+ 2 - 2
apps/desktop/README.md

@@ -48,11 +48,11 @@ The application preload exposes boot readiness and fatal startup reporting. The
 
 The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles.
 
-Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. On Windows, the main document's language updates context menus and update prompts. The repository Client UI i18n gate checks desktop sources.
+Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. On Windows, the main document's language updates desktop menus, recovery and update prompts, plugin-window titles, plugin-manager locale responses, and plugin-operation error text. The repository Client UI i18n gate checks desktop sources.
 
 Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. Application provides Desktop Plugins, Check for Updates, and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Ctrl+, opens the separate Desktop Plugins window, which manages Desktop packages rather than the sidebar's runtime plugin panel. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
 
-On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Windows and Linux keep the application and Edit menus.
+On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus.
 
 ### Runtime and plugin activation
 

+ 2 - 2
apps/desktop/README.zh.md

@@ -48,11 +48,11 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 
 
 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
 
-Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新右键菜单和更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
+Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示、插件窗口标题、插件管理器语言响应及插件操作错误文案。仓库 Client UI i18n 检查覆盖桌面端源码。
 
 Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。“应用”提供桌面插件、检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。Ctrl+, 打开独立的桌面插件窗口,管理 Desktop 包,与侧栏的运行时插件面板不同。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
 
-macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Windows 和 Linux 保留应用菜单和 Edit 菜单。
+macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
 
 ### 运行时与插件激活
 

+ 0 - 3
apps/desktop/src/ipc.ts

@@ -23,9 +23,6 @@ export const DESKTOP_IPC = {
   windowsMenu: 'dsh-desktop:windows-menu',
 } as const
 
-/** Windows application titlebar height in device-independent pixels. */
-export const WINDOWS_TITLEBAR_HEIGHT = 40
-
 /** Desktop release update state rendered by desktop-owned UI. */
 export interface DesktopUpdateState {
   readonly phase: 'idle' | 'checking' | 'available' | 'installing' | 'ready' | 'error'

+ 40 - 39
apps/desktop/src/main.ts

@@ -1,3 +1,4 @@
+import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
 /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
 
 import { readFile, writeFile } from 'node:fs/promises'
@@ -21,7 +22,7 @@ import { DesktopProjectManager, type DesktopProjectHooks } from './project-manag
 import { DesktopHostProcess } from './host-process.ts'
 import { installDesktopDirectoryPicker } from './directory-picker.ts'
 import { DesktopBackendController } from './backend-controller.ts'
-import { DESKTOP_IPC, SCHEME, WINDOWS_TITLEBAR_HEIGHT, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
+import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
@@ -32,8 +33,12 @@ let focusPrimaryWindow = (): void => {}
 let stopForRecovery = async (): Promise<void> => {}
 let shuttingDown = false
 let windowsLanguage: string | undefined
+
+function currentDesktopLocale(): ReturnType<typeof resolveDesktopLocale> {
+  return resolveDesktopLocale(windowsLanguage ?? app.getLocale())
+}
 const recovery = new DesktopFatalRecovery({
-  messages: () => resolveDesktopLocale(app.getLocale()).messages,
+  messages: () => currentDesktopLocale().messages,
   show: options => dialog.showMessageBox(options),
   stop: () => { shuttingDown = true; return stopForRecovery() },
   disablePlugins: async () => {
@@ -108,7 +113,8 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
     show,
     ...(process.platform === 'win32' && primary ? {
       titleBarStyle: 'hidden' as const,
-      titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: '#f9fafb', symbolColor: '#0f1115' },
+      titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb',
+        symbolColor: nativeTheme.shouldUseDarkColors ? '#f9fafb' : '#0f1115' },
     } : {}),
     // hiddenInset places traffic lights inside the sidebar; sidebar vibrancy
     // needs a transparent window background to show through the page.
@@ -151,7 +157,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
     }
     // Empty accelerators suppress Electron's default shortcut labels for native roles.
     if (items.length > 0) {
-      const messages = resolveDesktopLocale(windowsLanguage ?? app.getLocale()).messages
+      const messages = currentDesktopLocale().messages
       Menu.buildFromTemplate(items.map(item => ({
         ...item,
         ...(process.platform === 'win32' && item.role !== undefined && item.role in messages
@@ -204,8 +210,6 @@ async function main(): Promise<void> {
   let pluginWindow: BrowserWindow | undefined
   let shellInstallerOwnsQuit = false
   let updateState: DesktopUpdateState = { phase: 'idle' }
-  let locale = resolveDesktopLocale(app.getLocale())
-  let messages = locale.messages
   const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
   const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
   const applicationUrl = `${SCHEME}://app/`
@@ -335,7 +339,7 @@ async function main(): Promise<void> {
   const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
     assertDesktopSender(event, ['shell'])
     await startup?.catch(() => undefined)
-    if (recovery.active) throw new Error(messages.fatalSummary)
+    if (recovery.active) throw new Error(currentDesktopLocale().messages.fatalSummary)
     try {
       await manager.mutate(mutation, hooks)
     } finally {
@@ -348,7 +352,7 @@ async function main(): Promise<void> {
 
   ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
     assertDesktopSender(event, ['shell'])
-    return locale
+    return currentDesktopLocale()
   })
   // Only the main window may synchronize its palette with the native material.
   ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => {
@@ -392,8 +396,8 @@ async function main(): Promise<void> {
       if (manual) {
         await dialog.showMessageBox({
           type: 'error',
-          title: messages.updateCheckFailedTitle,
-          message: state.message ?? messages.unknownError,
+          title: currentDesktopLocale().messages.updateCheckFailedTitle,
+          message: state.message ?? currentDesktopLocale().messages.unknownError,
         })
       }
       return
@@ -402,18 +406,18 @@ async function main(): Promise<void> {
       if (manual) {
         await dialog.showMessageBox({
           type: 'info',
-          title: messages.updateCheckTitle,
-          message: state.message ?? messages.updateCurrent,
+          title: currentDesktopLocale().messages.updateCheckTitle,
+          message: state.message ?? currentDesktopLocale().messages.updateCurrent,
         })
       }
       return
     }
     const result = await dialog.showMessageBox({
       type: 'info',
-      title: messages.updateTitle,
-      message: messages.updateAvailable,
-      detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
-      buttons: [messages.installAndRestart, messages.later],
+      title: currentDesktopLocale().messages.updateTitle,
+      message: currentDesktopLocale().messages.updateAvailable,
+      detail: formatDesktopMessage(currentDesktopLocale().messages.updateDetail, { version: state.version ?? '' }),
+      buttons: [currentDesktopLocale().messages.installAndRestart, currentDesktopLocale().messages.later],
       defaultId: 0,
       cancelId: 1,
     })
@@ -422,8 +426,8 @@ async function main(): Promise<void> {
     if (installed.phase === 'error') {
       await dialog.showMessageBox({
         type: 'error',
-        title: messages.updateFailedTitle,
-        message: installed.message ?? messages.unknownError,
+        title: currentDesktopLocale().messages.updateFailedTitle,
+        message: installed.message ?? currentDesktopLocale().messages.unknownError,
       })
     }
   }
@@ -435,7 +439,7 @@ async function main(): Promise<void> {
     }
     pluginWindow = createWindow(managementPreload)
     pluginWindow.setSize(900, 620)
-    pluginWindow.setTitle(messages.pluginWindowTitle)
+    pluginWindow.setTitle(currentDesktopLocale().messages.pluginWindowTitle)
     pluginWindow.once('ready-to-show', () => { pluginWindow?.show() })
     pluginWindow.once('closed', () => { pluginWindow = undefined })
     void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
@@ -452,17 +456,17 @@ async function main(): Promise<void> {
     : []
   const applicationItems = (): MenuItemConstructorOptions[] => [
     {
-      label: messages.pluginsMenu,
+      label: currentDesktopLocale().messages.pluginsMenu,
       accelerator: 'CmdOrCtrl+,',
       click: openPluginWindow,
     },
-    { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
+    { label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
     { type: 'separator' },
     ...hideCommands,
-    { role: 'quit', ...(process.platform === 'win32' ? { label: messages.exitApplication } : {}) },
+    { role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) },
   ]
   Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{
-    label: process.platform === 'darwin' ? app.name : messages.application,
+    label: process.platform === 'darwin' ? app.name : currentDesktopLocale().messages.application,
     submenu: applicationItems(),
   }, ...platformMenus]))
 
@@ -476,25 +480,25 @@ async function main(): Promise<void> {
         || x < 0 || y < 0 || x > 100_000 || y > 100_000) throw new Error('desktop menu: invalid popup request')
       const window = mainWindow
       // Editor-owned history listens to key events rather than Chromium's native undo stack.
-      const editItem = (label: string, keyCode: string, accelerator?: string): MenuItemConstructorOptions => ({
+      const editItem = (label: string, keyCode: string, modifiers: Array<'control'>, accelerator?: string): MenuItemConstructorOptions => ({
         label,
         ...(accelerator === undefined ? {} : { accelerator }),
         click: () => {
           window.webContents.focus()
-          window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers: accelerator === undefined ? [] : ['control'] })
-          window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers: accelerator === undefined ? [] : ['control'] })
+          window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
+          window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
         },
       })
       const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [
-        editItem(messages.undo, 'Z', 'Ctrl+Z'),
-        editItem(messages.redo, 'Y', 'Ctrl+Y'),
+        editItem(currentDesktopLocale().messages.undo, 'Z', ['control'], 'Ctrl+Z'),
+        editItem(currentDesktopLocale().messages.redo, 'Y', ['control'], 'Ctrl+Y'),
         { type: 'separator' },
-        editItem(messages.cut, 'X', 'Ctrl+X'),
-        editItem(messages.copy, 'C', 'Ctrl+C'),
-        editItem(messages.paste, 'V', 'Ctrl+V'),
-        editItem(messages.delete, 'Delete'),
+        editItem(currentDesktopLocale().messages.cut, 'X', ['control'], 'Ctrl+X'),
+        editItem(currentDesktopLocale().messages.copy, 'C', ['control'], 'Ctrl+C'),
+        editItem(currentDesktopLocale().messages.paste, 'V', ['control'], 'Ctrl+V'),
+        editItem(currentDesktopLocale().messages.delete, 'Delete', []),
         { type: 'separator' },
-        editItem(messages.selectAll, 'A', 'Ctrl+A'),
+        editItem(currentDesktopLocale().messages.selectAll, 'A', ['control'], 'Ctrl+A'),
       ]
       const zoom = mainWindow.webContents.getZoomFactor()
       return new Promise<void>((resolve) => {
@@ -504,12 +508,9 @@ async function main(): Promise<void> {
     ipcMain.on(DESKTOP_IPC.windowsAppearance, (event, language: unknown, color: unknown, symbolColor: unknown) => {
       if (mainWindow === undefined || event.sender !== mainWindow.webContents
         || event.senderFrame !== mainWindow.webContents.mainFrame) return
-      if (typeof language !== 'string' || !/^[a-zA-Z]+(?:-[a-zA-Z0-9]+)*$/u.test(language)) return
-      const next = resolveDesktopLocale(language)
-      windowsLanguage = language
-      if (next.id !== locale.id) {
-        locale = next
-        messages = next.messages
+      if (!event.senderFrame.url.startsWith(`${SCHEME}://app/`)) return
+      if (typeof language === 'string' && /^[a-zA-Z]+(?:-[a-zA-Z0-9]+)*$/u.test(language)) {
+        windowsLanguage = language
       }
       // Empty colors precede client stylesheet installation; only CSS color values cross IPC.
       const validColor = (value: unknown): value is string => typeof value === 'string'

+ 27 - 1
apps/desktop/src/preload-menu.ts

@@ -13,7 +13,7 @@ export function installWindowsMenu(): { update(): void; dispose(): void } {
   const shadow = host.attachShadow({ mode: 'open' })
   const style = document.createElement('style')
   style.textContent = `
-    :host { position: fixed; top: 0; left: var(--dsh-windows-menu-start, 48px); z-index: 100;
+    :host { position: fixed; top: 0; left: var(--dsh-windows-menu-start, 48px); z-index: 1100;
       height: var(--dsh-windows-titlebar-height); display: flex; align-items: center;
       font-family: var(--dsw-font-family); -webkit-app-region: no-drag; }
     [role=menubar] { display: flex; gap: 2px; }
@@ -26,6 +26,29 @@ export function installWindowsMenu(): { update(): void; dispose(): void } {
   `
   const bar = document.createElement('div')
   bar.setAttribute('role', 'menubar')
+  let restoreEditor = (): void => {}
+  const rememberEditor = (event: FocusEvent): void => {
+    const target = event.composedPath()[0]
+    if (!(target instanceof HTMLElement) || target === host || shadow.contains(target)) return
+    if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)
+      && !target.matches('[contenteditable="true"]')) return
+    const selection = document.getSelection()
+    const ranges = selection === null ? [] : Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i).cloneRange())
+    const input = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target : undefined
+    const start = input?.selectionStart
+    const end = input?.selectionEnd
+    const direction = input?.selectionDirection
+    restoreEditor = () => {
+      if (!target.isConnected) return
+      target.focus({ preventScroll: true })
+      if (input !== undefined && start != null && end != null) input.setSelectionRange(start, end, direction ?? undefined)
+      else if (selection !== null && ranges.length > 0) {
+        selection.removeAllRanges()
+        for (const range of ranges) selection.addRange(range)
+      }
+    }
+  }
+  document.addEventListener('focusout', rememberEditor, true)
   const createButton = (name: 'application' | 'edit', index: 0 | 1): HTMLButtonElement => {
     const button = document.createElement('button')
     button.type = 'button'
@@ -39,7 +62,9 @@ export function installWindowsMenu(): { update(): void; dispose(): void } {
       if (button.getAttribute('aria-expanded') === 'true') return
       const rect = button.getBoundingClientRect()
       button.setAttribute('aria-expanded', 'true')
+      if (document.activeElement === host) restoreEditor()
       try { await ipcRenderer.invoke(DESKTOP_IPC.windowsMenu, name, rect.left, rect.bottom) }
+      catch (error) { console.error('Desktop caption menu failed', error) }
       finally { button.setAttribute('aria-expanded', 'false') }
     }
     button.addEventListener('click', () => { void open() })
@@ -71,6 +96,7 @@ export function installWindowsMenu(): { update(): void; dispose(): void } {
   return {
     update,
     dispose: () => {
+      document.removeEventListener('focusout', rememberEditor, true)
       host.remove()
     },
   }

+ 27 - 5
apps/desktop/src/preload-windows.ts

@@ -1,21 +1,42 @@
+import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
 /** Synchronizes Windows context menus and caption colors with the application document. */
 import { ipcRenderer } from 'electron'
-import { DESKTOP_IPC, WINDOWS_TITLEBAR_HEIGHT } from './ipc.ts'
+import { DESKTOP_IPC } from './ipc.ts'
 import { installWindowsMenu } from './preload-menu.ts'
 
 /** Install the Windows-only titlebar marker and observe application language and palette changes. */
 export function syncWindowsAppearance(): void {
   if (process.platform !== 'win32') return
-  const install = (): void => {
+  const mark = (): void => {
     const root = document.documentElement
     root.dataset.windowsTitlebar = ''
     root.style.setProperty('--dsh-windows-titlebar-height', `${WINDOWS_TITLEBAR_HEIGHT}px`)
+  }
+  // The root can be absent before the HTML parser creates it.
+  if ((document.documentElement as HTMLElement | null) !== null) mark()
+  const install = (): void => {
+    mark()
+    const root = document.documentElement
     const menu = installWindowsMenu()
+    const probe = document.createElement('span')
+    probe.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;background-color:var(--dsw-specific-sidebar-fill);color:var(--dsw-alias-label-primary)'
+    document.body.append(probe)
+    const canvas = document.createElement('canvas')
+    canvas.width = canvas.height = 1
+    const context = canvas.getContext('2d', { willReadFrequently: true })
+    if (context === null) throw new Error('Desktop caption requires a 2D canvas context')
+    const nativeColor = (color: string): string => {
+      context.clearRect(0, 0, 1, 1)
+      context.fillStyle = color
+      context.fillRect(0, 0, 1, 1)
+      const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data
+      return `rgba(${red}, ${green}, ${blue}, ${Number(alpha) / 255})`
+    }
     let previous = ''
     const send = (): void => {
-      const style = getComputedStyle(document.body)
-      const color = style.getPropertyValue('--dsw-specific-sidebar-fill').trim()
-      const symbolColor = style.getPropertyValue('--dsw-alias-label-primary').trim()
+      const style = getComputedStyle(probe)
+      const color = nativeColor(style.backgroundColor)
+      const symbolColor = nativeColor(style.color)
       const values = [root.lang, color, symbolColor]
       const current = JSON.stringify(values)
       if (current === previous) return
@@ -31,6 +52,7 @@ export function syncWindowsAppearance(): void {
     window.addEventListener('pagehide', () => {
       observer.disconnect()
       menu.dispose()
+      probe.remove()
       document.head.removeEventListener('load', send, true)
     }, { once: true })
     send()

+ 4 - 0
apps/desktop/src/windows-layout.ts

@@ -0,0 +1,4 @@
+/** Shared geometry for the Windows main-window caption. */
+
+/** Windows application titlebar height in device-independent pixels. */
+export const WINDOWS_TITLEBAR_HEIGHT = 40

+ 7 - 1
apps/desktop/tests/main-startup.spec.ts

@@ -1,3 +1,4 @@
+import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import type { IpcMainInvokeEvent } from 'electron'
 import { join } from 'node:path'
@@ -208,7 +209,7 @@ describe('desktop main startup', () => {
     if (platform === 'darwin') {
       expect(window.options).toMatchObject({ titleBarStyle: 'hiddenInset', vibrancy: 'sidebar', backgroundColor: '#00000000' })
     } else if (platform === 'win32') {
-      expect(window.options).toMatchObject({ titleBarStyle: 'hidden', titleBarOverlay: { height: 40 } })
+      expect(window.options).toMatchObject({ titleBarStyle: 'hidden', titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT } })
       expect(window.options).not.toHaveProperty('vibrancy')
       expect(harness.menu.setApplicationMenu).toHaveBeenCalledWith(null)
     } else {
@@ -236,7 +237,12 @@ describe('desktop main startup', () => {
     expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: 'Copy', accelerator: '' }])
     window.setTitleBarOverlay.mockClear()
     listener(event, 'en', 'url(file:///bad)', '#fff')
+    expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
     listener(event, {}, '#fff', '#000')
+    expect(window.setTitleBarOverlay).toHaveBeenLastCalledWith({ color: '#fff', symbolColor: '#000' })
+    window.setTitleBarOverlay.mockClear()
+    window.webContents.mainFrame.url = 'dsh-app://shell/plugin-manager.html'
+    listener(event, 'zh-CN', '#fff', '#000')
     expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
     expect(harness.menu.setApplicationMenu).toHaveBeenCalledExactlyOnceWith(null)
   })

+ 9 - 0
apps/desktop/tests/preload-app.spec.ts

@@ -1,4 +1,5 @@
 import { afterEach, expect, it, vi } from 'vitest'
+import { syncWindowsAppearance } from '../src/preload-windows.ts'
 import { DESKTOP_IPC } from '../src/ipc.ts'
 
 const electron = vi.hoisted(() => ({
@@ -48,3 +49,11 @@ it('exposes a directory picker only to the local application document', async ()
     expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_DIRECTORY_PICKER__')).toBe(false)
   }
 })
+
+it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://example.com/'])(
+  'installs Windows appearance only for the application document (%s)', async (url) => {
+    vi.stubGlobal('location', new URL(url))
+    await import('../src/preload-app.ts')
+    expect(syncWindowsAppearance).toHaveBeenCalledTimes(url === 'dsh-app://app/' ? 1 : 0)
+  },
+)

+ 51 - 0
apps/desktop/tests/preload-menu.client.spec.ts

@@ -62,3 +62,54 @@ it('moves between menu entries with arrow keys and opens the focused entry with
   buttons[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }))
   expect(invoke).toHaveBeenCalledWith(DESKTOP_IPC.windowsMenu, 'edit', 0, 0)
 })
+
+it('restores a text input and its selection before opening a keyboard menu', async () => {
+  const input = document.createElement('input')
+  input.value = 'editor text'
+  document.body.append(input)
+  menu = installWindowsMenu()
+  const button = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelector('button')!
+  input.focus()
+  input.setSelectionRange(2, 6, 'backward')
+  button.focus()
+  input.setSelectionRange(0, 0)
+  button.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }))
+  expect(document.activeElement).toBe(input)
+  expect([input.selectionStart, input.selectionEnd, input.selectionDirection]).toEqual([2, 6, 'backward'])
+  await vi.waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') })
+})
+
+it('reports a failed popup request and clears the active menu', async () => {
+  const error = new Error('popup rejected')
+  const log = vi.spyOn(console, 'error').mockImplementation(() => {})
+  invoke.mockRejectedValue(error)
+  menu = installWindowsMenu()
+  const button = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelector('button')!
+  button.click()
+  await vi.waitFor(() => { expect(log).toHaveBeenCalledWith('Desktop caption menu failed', error) })
+  expect(button.getAttribute('aria-expanded')).toBe('false')
+})
+
+it('restores a contenteditable selection before opening Edit with the keyboard', async () => {
+  const editor = document.createElement('div')
+  editor.contentEditable = 'true'
+  editor.setAttribute('contenteditable', 'true')
+  editor.tabIndex = 0
+  editor.textContent = 'editable text'
+  document.body.append(editor)
+  menu = installWindowsMenu()
+  const buttons = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelectorAll('button')
+  editor.focus()
+  const range = document.createRange()
+  range.setStart(editor.firstChild!, 2)
+  range.setEnd(editor.firstChild!, 8)
+  document.getSelection()!.removeAllRanges()
+  document.getSelection()!.addRange(range)
+  buttons[0]!.focus()
+  document.getSelection()!.removeAllRanges()
+  buttons[0]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true }))
+  buttons[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }))
+  expect(document.activeElement).toBe(editor)
+  expect(document.getSelection()!.toString()).toBe('itable')
+  await vi.waitFor(() => { expect(buttons[1]!.getAttribute('aria-expanded')).toBe('false') })
+})

+ 14 - 5
apps/desktop/tests/preload-windows.client.spec.ts

@@ -26,18 +26,27 @@ it.each(['darwin', 'linux'] as const)('does not install Windows controls on %s',
 
 it('synchronizes live language and palette changes and stops observing a closed document', async () => {
   vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
-  vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete')
+  vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading')
   vi.spyOn(globalThis, 'getComputedStyle').mockImplementation(() => ({
-    getPropertyValue: (name: string) => name === '--dsw-specific-sidebar-fill'
-      ? document.body.hasAttribute('data-ds-dark-theme') ? '#1b1b1c' : '#f9fafb' : '#0f1115',
+    backgroundColor: document.body.hasAttribute('data-ds-dark-theme') ? 'oklch(0.2 0 0)' : 'hsl(0 0% 100%)',
+    color: 'black',
   }) as CSSStyleDeclaration)
+  const context = {
+    fillStyle: '', clearRect: vi.fn(), fillRect: vi.fn(),
+    getImageData: () => ({ data: new Uint8ClampedArray(context.fillStyle === 'black'
+      ? [0, 0, 0, 255] : context.fillStyle.startsWith('oklch') ? [27, 27, 28, 255] : [255, 255, 255, 255]) }),
+  }
+  vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as unknown as CanvasRenderingContext2D)
   document.documentElement.lang = 'en'
   syncWindowsAppearance()
+  expect(send).not.toHaveBeenCalled()
+  expect(document.documentElement.hasAttribute('data-windows-titlebar')).toBe(true)
+  window.dispatchEvent(new Event('DOMContentLoaded'))
   expect(document.documentElement.style.getPropertyValue('--dsh-windows-titlebar-height')).toBe('40px')
-  expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.windowsAppearance, 'en', '#f9fafb', '#0f1115')
+  expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.windowsAppearance, 'en', 'rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 1)')
   document.documentElement.lang = 'zh-CN'
   document.body.setAttribute('data-ds-dark-theme', '')
-  await vi.waitFor(() => { expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.windowsAppearance, 'zh-CN', '#1b1b1c', '#0f1115') })
+  await vi.waitFor(() => { expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.windowsAppearance, 'zh-CN', 'rgba(27, 27, 28, 1)', 'rgba(0, 0, 0, 1)') })
   window.dispatchEvent(new Event('pagehide'))
   send.mockClear()
   document.documentElement.lang = 'en'

+ 1 - 0
packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx

@@ -22,6 +22,7 @@ vi.mock(desktopIpc.electron, () => ({
 }))
 vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() }))
 vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
+vi.mock('../../../../apps/desktop/src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
 
 afterEach(() => { cleanup(); vi.unstubAllGlobals() })
 

+ 2 - 2
packages/client/ui-layout/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-layout/README.md
-README.md: a48d87bcfdc16c48d06e8d5e6ef5ef9ec8e2bcd0
-README.zh.md: ee9136a7f4b39558188f8be3a548bcc514233f46
+README.md: 582c5fbea271caf0f45f9ee22c10d21f7412bf38
+README.zh.md: 14e3f429284c5c3a519fa47e1156a510b7271f91

+ 1 - 1
packages/client/ui-layout/README.md

@@ -29,7 +29,7 @@ The root slot composes the sidebar, main content, and right column. The sidebar
 
 Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition.
 
-Windows Electron's `data-windows-titlebar` marker reserves the caption height above all columns and removes the collapsed sidebar rail. Only the content area's top-left corner has a 16px radius; the other corners and the internal divider remain square. Ordinary Web documents do not receive the marker; macOS retains its separate layout.
+Windows Electron's `data-windows-titlebar` marker reserves the caption height above all columns and removes the collapsed sidebar rail. Only the content area's top-left corner has a 16px radius; the other corners and the internal divider remain square. The frame publishes `--dsh-windows-content-radius` and `--dsh-windows-sidebar-width` for ui-sidebar-right's fullscreen corner and sidebar clearance. Ordinary Web documents do not receive the marker; macOS retains its separate layout.
 
 ### Theme presentation
 

+ 1 - 1
packages/client/ui-layout/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。
 
-Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶栏高度,并移除收起后的侧栏轨道。内容区仅左上角保留 16px 圆角,其余角和内部交界处保持直角。普通 Web 文档不会获得该标记;macOS 保留其独立布局。
+Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶栏高度,并移除收起后的侧栏轨道。内容区仅左上角保留 16px 圆角,其余角和内部交界处保持直角。框架发布 `--dsh-windows-content-radius` 和 `--dsh-windows-sidebar-width`,供 ui-sidebar-right 的全屏圆角及侧栏避让使用。普通 Web 文档不会获得该标记;macOS 保留其独立布局。
 
 ### 主题呈现
 

+ 2 - 0
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -207,6 +207,8 @@ export function AppFrame({
       ref={frameRef}
       className={css.frame}
       style={{
+        ...(document.documentElement.hasAttribute('data-windows-titlebar')
+          ? { '--dsh-windows-sidebar-width': `${cols.sidebar}px` } : {}),
         gridTemplateColumns:
           `${cols.sidebar}px minmax(0, 1fr) ${cols.rightbar}px`,
       }}

+ 2 - 2
packages/client/ui-sidebar-right/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-right/README.md
-README.md: cdc6f56738bad422537980db53bbd2f1e1bc97ee
-README.zh.md: cf9a3fc72f4ce187598702296168ae521e1f8335
+README.md: e838f952530d71510c14cbb948d64c8fac319787
+README.zh.md: a529c31c8abb4c4927683e128cc0938a155e51c0

+ 1 - 1
packages/client/ui-sidebar-right/README.md

@@ -43,7 +43,7 @@ Normal and fullscreen presentations share the same content tree, so switching do
 | `push` (default) | Panel width: the conversation makes room | In the track; its left edge and the conversation's right edge travel together, on the frame's own curve |
 | `fullscreen` | Retains the wide-screen normal track; automatic narrow-screen fullscreen takes no track | Covers the entire viewport |
 
-In Windows Electron, `html[data-windows-titlebar]` keeps fullscreen panels below the caption, leaving the sidebar shortcuts and native window controls available.
+In Windows Electron, `html[data-windows-titlebar]` keeps fullscreen panels below the caption and to the right of the expanded sidebar. The frame's `--dsh-windows-sidebar-width` supplies that inset and maximum width; `--dsh-windows-content-radius` supplies the top-left radius. Only fullscreen panels clip overflow for that corner.
 
 The seat reports presentation through `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()`; the frame does not inject this package. Switching fullscreen on a wide viewport leaves the center width unchanged, and the width handle appears only in expanded normal mode. Independent floating panels and `float`/`dock` operations remain available.
 

+ 1 - 1
packages/client/ui-sidebar-right/README.zh.md

@@ -43,7 +43,7 @@ kind: "package-reference"
 | `push`(默认) | 面板宽度:会话区让出空间 | 在轨道内;它的左缘与会话区的右缘沿框架自己的曲线一起移动 |
 | `fullscreen` | 保留宽屏普通轨道;窄屏自动全屏不占轨道 | 覆盖整个窗口 |
 
-在 Windows Electron 中,`html[data-windows-titlebar]` 让全屏面板保持在顶栏下方,侧栏快捷按钮和原生窗口按钮仍可操作
+在 Windows Electron 中,`html[data-windows-titlebar]` 让全屏面板保持在顶栏下方及展开侧栏的右侧。框架的 `--dsh-windows-sidebar-width` 提供该缩进及最大宽度;`--dsh-windows-content-radius` 提供左上圆角。仅全屏面板为该圆角裁剪溢出内容
 
 席位通过 `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()` 报告呈现,框架不注入本包。宽屏切换全屏不改变中栏宽度;宽度拖拽区只在普通展开态显示。独立浮窗及 `float`/`dock` 操作保持可用。
 

+ 3 - 4
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css

@@ -60,11 +60,10 @@
 }
 
 /* Windows keeps caption controls above fullscreen file and terminal panels. */
-:global([data-windows-titlebar]) .panel {
-  overflow: hidden;
-}
-
 :global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] {
+  overflow: hidden;
+  left: var(--dsh-windows-sidebar-width);
+  max-width: calc(100% - var(--dsh-windows-sidebar-width));
   top: var(--dsh-windows-titlebar-height);
   border-radius: var(--dsh-windows-content-radius) 0 0 0;
   corner-shape: round;

+ 2 - 2
packages/client/ui-sidebar/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/README.md
-README.md: 2fe0daf7202ccd080727b04b53c234c64e59cff4
-README.zh.md: 8757e782891375f333652e08706555fa9b62c3a3
+README.md: 14ef1e69eabc4089f5d4d8279a3acb6de5fcabd7
+README.zh.md: 20b48eb01653560e0245bb84f68c842b609ee265

+ 1 - 1
packages/client/ui-sidebar/README.md

@@ -39,7 +39,7 @@ Plugins add an icon component to the root-scoped `sidebar.panellist` list with a
 
 During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation.
 
-On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region.
+On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region.
 
 ### macOS desktop
 

+ 1 - 1
packages/client/ui-sidebar/README.zh.md

@@ -39,7 +39,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
 
 实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。
 
-在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。
+在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。
 
 ### macOS 桌面