Преглед на файлове

feat(desktop): integrate Windows caption navigation and localized menus

07akioni преди 3 дни
родител
ревизия
5c2cc19efd
променени са 37 файла, в които са добавени 892 реда и са изтрити 49 реда
  1. 2 2
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
  3. 1 1
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.i18n.yaml
  5. 29 0
      .agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md
  6. 29 0
      .agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md
  7. 2 2
      apps/desktop/README.i18n.yaml
  8. 3 3
      apps/desktop/README.md
  9. 3 3
      apps/desktop/README.zh.md
  10. 5 0
      apps/desktop/src/ipc.ts
  11. 18 0
      apps/desktop/src/locale.ts
  12. 91 17
      apps/desktop/src/main.ts
  13. 2 0
      apps/desktop/src/preload-app.ts
  14. 77 0
      apps/desktop/src/preload-menu.ts
  15. 40 0
      apps/desktop/src/preload-windows.ts
  16. 5 0
      apps/desktop/tests/__snapshots__/preload-menu.client.spec.ts.snap
  17. 83 6
      apps/desktop/tests/main-startup.spec.ts
  18. 1 0
      apps/desktop/tests/preload-app.spec.ts
  19. 64 0
      apps/desktop/tests/preload-menu.client.spec.ts
  20. 46 0
      apps/desktop/tests/preload-windows.client.spec.ts
  21. 2 2
      packages/client/ui-layout/README.i18n.yaml
  22. 2 0
      packages/client/ui-layout/README.md
  23. 2 0
      packages/client/ui-layout/README.zh.md
  24. 32 0
      packages/client/ui-layout/src/client/AppFrame.module.css
  25. 3 4
      packages/client/ui-layout/src/client/AppFrame.tsx
  26. 13 0
      packages/client/ui-layout/tests/app-frame.client.spec.tsx
  27. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  28. 2 0
      packages/client/ui-sidebar-right/README.md
  29. 2 0
      packages/client/ui-sidebar-right/README.zh.md
  30. 11 0
      packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css
  31. 2 2
      packages/client/ui-sidebar/README.i18n.yaml
  32. 2 0
      packages/client/ui-sidebar/README.md
  33. 2 0
      packages/client/ui-sidebar/README.zh.md
  34. 83 0
      packages/client/ui-sidebar/src/client/SidebarRoot.module.css
  35. 5 4
      packages/client/ui-sidebar/src/client/SidebarRoot.tsx
  36. 204 0
      packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap
  37. 15 0
      packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx

+ 2 - 2
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.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-13-macos-hidden-titlebar-vibrancy.md
-2026-09-13-macos-hidden-titlebar-vibrancy.md: b6b2f249a1843f937cf73d4eb193d47e1b9e8ad5
-2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 8d4a4d17bda2e8c7845b66443cc883c6177b7343
+2026-09-13-macos-hidden-titlebar-vibrancy.md: 1bc531953cedac8633c4baa6da45aa9bcd77a728
+2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: bbc593badbabdccaa037cca836a9554929d614bc

+ 1 - 1
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md

@@ -12,7 +12,7 @@ The desktop app drew the stock macOS titlebar: an opaque bar above the web UI th
 
 The Electron main process opens the main window on darwin with `titleBarStyle: 'hiddenInset'`, `trafficLightPosition: { x: 16, y: 18 }`, `vibrancy: 'sidebar'`, `visualEffectState: 'active'`, and a transparent `backgroundColor`. `'active'` keeps the material stable behind an unfocused window; `'followWindow'` washed the sidebar out on blur.
 
-Every web-side adjustment keys off `html[data-platform]`, which only the desktop preloads set (`document.documentElement.dataset.platform = process.platform`). The plain web and non-darwin desktop render exactly as before.
+Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which only the desktop preloads set (`document.documentElement.dataset.platform = process.platform`). These rules do not apply to plain Web or other desktop platforms. The [Windows caption decision](2026-09-16-windows-desktop-titlebar.md) owns its separate presentation.
 
 **Transparency chain.** Vibrancy shows only through transparent pixels: on darwin `html`/`body` (ui-web base.css) and the AppFrame are transparent, the center column paints `--dsw-alias-bg-base` opaque, and the sidebar column paints a translucent `color-mix` tint of the sidebar fill so the material reads through it. SidebarRoot's own opaque fill moves to the frame column for the same reason.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLightPosition: { x: 16, y: 18 }`、`vibrancy: 'sidebar'`、`visualEffectState: 'active'` 与透明 `backgroundColor` 打开主窗口。`'active'` 让窗口失焦时材质保持稳定;`'followWindow'` 会在失焦时把侧边栏冲淡。
 
-所有 Web 侧调整均以 `html[data-platform]` 为开关,该属性仅由桌面 preload 设置(`document.documentElement.dataset.platform = process.platform`)。纯 Web 与非 darwin 桌面的渲染与之前完全一致
+所有 macOS Web 侧调整均以 `html[data-platform='darwin']` 为开关,该属性仅由桌面 preload 设置(`document.documentElement.dataset.platform = process.platform`)。这些规则不适用于纯 Web 或其他桌面平台。[Windows 顶栏决策](2026-09-16-windows-desktop-titlebar.zh.md)负责其独立呈现
 
 **透明链。** 毛玻璃只透过透明像素显现:darwin 上 `html`/`body`(ui-web base.css)与 AppFrame 透明,中间列铺不透明的 `--dsw-alias-bg-base`,侧边栏列铺侧边栏底色的半透明 `color-mix`,让材质透出。SidebarRoot 自身的不透明底色出于同一原因移到框架列。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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

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

@@ -0,0 +1,29 @@
+# Agent Note: Windows desktop caption and application menus
+
+Status: implemented
+
+English | [中文](2026-09-16-windows-desktop-titlebar.zh.md)
+
+## Problem
+
+Windows needs a compact caption that keeps sidebar navigation available when the sidebar is hidden or files fill the content area. A separate Application/Edit menu consumes another row and can show a different language from the application. The shared client must preserve ordinary Web and macOS presentation.
+
+## Decision
+
+The Windows main window combines Electron's hidden titlebar with a native window-controls overlay. Only its local application preload publishes `data-windows-titlebar`; shared layout, sidebar, and fullscreen-panel rules require that marker. Native caption colors and context-menu language follow the application document. Renderer messages are accepted only from the primary window's main frame.
+
+Windows removes the separate native menu row. The application preload mounts localized Application and Edit caption entries in an isolated shadow root, and the main process opens native popup menus for validated requests. Application uses the same command template as other platforms; Edit preserves the Windows native command set with explicit locale-owned labels. Ctrl+, retains the Desktop Plugins shortcut. Existing sidebar callbacks supply caption navigation without another navigation store, and the collapsed New Session control sits between the sidebar toggle and the menus. The [macOS caption decision](2026-09-13-macos-hidden-titlebar-vibrancy.md) remains independently applicable to macOS.
+
+## Alternatives considered
+
+An Alt-revealed native row consumes additional vertical space and hides discovery behind a keyboard convention. Removing its commands entirely loses manual updates and Desktop package management; the runtime plugin panel cannot replace the independent Desktop Plugins window. Caption entries preserve those operations without the extra row. Native popups retain command execution and keyboard navigation instead of duplicating editor actions in Web components.
+
+Custom HTML window controls would make the application responsible for native caption interactions. The native overlay preserves those controls while allowing application content beside them. Global CSS changes would affect Web and macOS; the preload-owned marker restricts the presentation to the Windows main window.
+
+## 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.
+
+## Testing
+
+Desktop startup and preload tests cover platform isolation, language changes, palette messages, sender validation, native command mapping, menu lifecycle, and the Desktop Plugins shortcut. Owner-local menu and sidebar snapshots cover localized entries and expanded/collapsed controls; layout tests cover zero-width collapse. Window verification covers native popups, plugin-window opening, caption navigation, and fullscreen clearance. Ordinary-browser verification checks the absence of desktop controls and retention of its sidebar rail.

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

@@ -0,0 +1,29 @@
+# Agent Note: Windows 桌面顶栏与应用菜单
+
+Status: implemented
+
+[English](2026-09-16-windows-desktop-titlebar.md) | 中文
+
+## Problem
+
+Windows 需要紧凑的顶栏,在侧栏隐藏或文件铺满内容区时仍保留侧栏导航。独立的应用/编辑菜单多占一行,且可能与应用语言不同。共享客户端必须保留普通 Web 和 macOS 的呈现。
+
+## Decision
+
+Windows 主窗口组合 Electron 隐藏标题栏与原生窗口按钮覆盖层。只有本地应用 preload 发布 `data-windows-titlebar`;共享布局、侧栏和全屏面板规则均要求该标记。原生顶栏颜色与右键菜单语言跟随应用文档。渲染器消息仅接受主窗口的主框架来源。
+
+Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root 中挂载本地化“应用”和“编辑”顶栏入口,主进程为通过校验的请求打开原生弹出菜单。“应用”与其他平台共用命令模板;“编辑”保留 Windows 原生命令集合,并显式提供由语言字典管理的标签。Ctrl+, 保留桌面插件快捷键。顶栏导航复用现有侧栏回调,不另建导航存储,收起态的新建会话控件位于侧栏开关和菜单之间。[macOS 顶栏决策](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)仍独立适用于 macOS。
+
+## Alternatives considered
+
+按 Alt 显示的原生菜单行额外占用纵向空间,且依赖键盘惯例才能发现。完全移除命令会丢失手动更新和 Desktop 包管理;运行时插件面板不能替代独立的桌面插件窗口。顶栏入口保留这些操作而不增加菜单行。原生弹出菜单保留命令执行与键盘导航,无需在 Web 组件中重复编辑器操作。
+
+自定义 HTML 窗口按钮会让应用承担原生顶栏交互。原生覆盖层保留这些按钮,同时允许应用内容显示在旁边。全局 CSS 修改会影响 Web 和 macOS;由 preload 持有的标记将呈现限制在 Windows 主窗口。
+
+## Consequences
+
+顶栏在全屏文件面板上方保持可用,收起的导航不再占用纵向轨道。顶栏菜单保留应用与编辑命令,普通 Web 文档不获得桌面控件。编辑操作向当前编辑器发送对应按键,以使用编辑器自身的撤销历史。指针激活保留编辑器焦点;菜单关闭后清除入口激活态。主进程校验拒绝其他框架、未知菜单名称和无效锚点坐标。原生弹出菜单外观跟随操作系统。
+
+## Testing
+
+桌面启动与 preload 测试覆盖平台隔离、语言变更、调色板消息、来源校验、原生命令映射、菜单生命周期和桌面插件快捷键。菜单与侧栏本地快照覆盖本地化入口及展开、收起控件;布局测试覆盖零宽度收起。窗口验证覆盖原生弹出菜单、插件窗口打开、顶栏导航和全屏避让。普通浏览器验证检查桌面控件缺失及侧栏轨道保留。

+ 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: c16e56f511cee6a875fcf2a5e8871c8e174bd2a2
-README.zh.md: 42d687ad46ff47f6032e2c322d64e04ecd88ac28
+README.md: bdfd7beb34e31fab6a486d059771ee9303efc1ea
+README.zh.md: 9e782eca57258e6d3a8ef51b42713460bcc4788c

+ 3 - 3
apps/desktop/README.md

@@ -48,9 +48,9 @@ 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. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these 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 context menus and update prompts. The repository Client UI i18n gate checks desktop sources.
 
-Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy.
+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.
 
 ### Runtime and plugin activation
 
@@ -217,7 +217,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn
 
 ## Updates
 
-A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater.
+A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually, including from the Windows caption's Application menu. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater.
 
 Signed packaging emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
 

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

@@ -48,9 +48,9 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 
 
 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
 
-Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。菜单、原生对话框和插件管理渲染器使用同一份语言数据;仓库 Client UI i18n 检查覆盖这些桌面端源码。
+Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新右键菜单和更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
 
-Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。
+Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。“应用”提供桌面插件、检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。Ctrl+, 打开独立的桌面插件窗口,管理 Desktop 包,与侧栏的运行时插件面板不同。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
 
 ### 运行时与插件激活
 
@@ -217,7 +217,7 @@ pnpm run prepare:desktop
 
 ## 更新
 
-打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。
+打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项手动触发同一检查,Windows 从顶栏的“应用”菜单进入。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。
 
 签名打包为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
 

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

@@ -19,8 +19,13 @@ export const DESKTOP_IPC = {
   updatesInstall: 'dsh-desktop:updates-install',
   updatesState: 'dsh-desktop:updates-state',
   nativeThemeSet: 'dsh-desktop:native-theme-set',
+  windowsAppearance: 'dsh-desktop:windows-appearance',
+  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'

+ 18 - 0
apps/desktop/src/locale.ts

@@ -2,6 +2,15 @@
 
 export const en = {
   application: 'Application',
+  edit: 'Edit',
+  menuBar: 'Application menu',
+  delete: 'Delete',
+  undo: 'Undo',
+  redo: 'Redo',
+  cut: 'Cut',
+  copy: 'Copy',
+  paste: 'Paste',
+  selectAll: 'Select All',
   startupFailed: 'DeepSeek Harness is unavailable',
   fatalSummary: 'The application could not start or stopped unexpectedly.',
   diagnosticTruncated: '… Error details shortened. The full diagnostic was written to the Electron console.',
@@ -52,6 +61,15 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
 
 export const zh = {
   application: '应用',
+  edit: '编辑',
+  menuBar: '应用菜单',
+  delete: '删除',
+  undo: '撤销',
+  redo: '重做',
+  cut: '剪切',
+  copy: '复制',
+  paste: '粘贴',
+  selectAll: '全选',
   startupFailed: 'DeepSeek Harness 无法使用',
   fatalSummary: '应用无法启动或已意外停止。',
   diagnosticTruncated: '… 错误详情已截短,完整诊断已写入 Electron 控制台。',

+ 91 - 17
apps/desktop/src/main.ts

@@ -21,7 +21,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, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
+import { DESKTOP_IPC, SCHEME, WINDOWS_TITLEBAR_HEIGHT, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
@@ -31,6 +31,7 @@ import { DesktopFatalRecovery } from './fatal-recovery.ts'
 let focusPrimaryWindow = (): void => {}
 let stopForRecovery = async (): Promise<void> => {}
 let shuttingDown = false
+let windowsLanguage: string | undefined
 const recovery = new DesktopFatalRecovery({
   messages: () => resolveDesktopLocale(app.getLocale()).messages,
   show: options => dialog.showMessageBox(options),
@@ -97,13 +98,17 @@ function developmentHostInspectPort(enabled: boolean): number | undefined {
   return port
 }
 
-function createWindow(preload: string, show = false): BrowserWindow {
+function createWindow(preload: string, show = false, primary = false): BrowserWindow {
   const window = new BrowserWindow({
     width: 1280,
     height: 840,
     minWidth: 880,
     minHeight: 600,
     show,
+    ...(process.platform === 'win32' && primary ? {
+      titleBarStyle: 'hidden' as const,
+      titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: '#f9fafb', symbolColor: '#0f1115' },
+    } : {}),
     // hiddenInset places traffic lights inside the sidebar; sidebar vibrancy
     // needs a transparent window background to show through the page.
     ...(process.platform === 'darwin' ? {
@@ -144,7 +149,15 @@ function createWindow(preload: string, show = false): BrowserWindow {
       items.push({ role: 'copy', enabled: editFlags.canCopy })
     }
     // Empty accelerators suppress Electron's default shortcut labels for native roles.
-    if (items.length > 0) Menu.buildFromTemplate(items.map(item => ({ ...item, accelerator: '' }))).popup({ window })
+    if (items.length > 0) {
+      const messages = resolveDesktopLocale(windowsLanguage ?? app.getLocale()).messages
+      Menu.buildFromTemplate(items.map(item => ({
+        ...item,
+        ...(process.platform === 'win32' && item.role !== undefined && item.role in messages
+          ? { label: messages[item.role as keyof typeof messages] } : {}),
+        accelerator: '',
+      }))).popup({ window })
+    }
   })
   window.webContents.on('will-navigate', (event, url) => {
     const destination = new URL(url)
@@ -190,8 +203,8 @@ async function main(): Promise<void> {
   let pluginWindow: BrowserWindow | undefined
   let shellInstallerOwnsQuit = false
   let updateState: DesktopUpdateState = { phase: 'idle' }
-  const locale = resolveDesktopLocale(app.getLocale())
-  const messages = locale.messages
+  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/`
@@ -427,23 +440,84 @@ async function main(): Promise<void> {
     void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
   }
 
-  Menu.setApplicationMenu(Menu.buildFromTemplate([{
+  const applicationItems = (): MenuItemConstructorOptions[] => [
+    {
+      label: messages.pluginsMenu,
+      accelerator: 'CmdOrCtrl+,',
+      click: openPluginWindow,
+    },
+    { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
+    { type: 'separator' },
+    { role: 'quit', ...(process.platform === 'win32' ? { label: messages.exitApplication } : {}) },
+  ]
+  Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{
     label: process.platform === 'darwin' ? app.name : messages.application,
-    submenu: [
-      {
-        label: messages.pluginsMenu,
-        accelerator: 'CmdOrCtrl+,',
-        click: openPluginWindow,
-      },
-      { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
-      { type: 'separator' },
-      { role: 'quit' },
-    ],
+    submenu: applicationItems(),
   }, { role: 'editMenu' }]))
 
+  if (process.platform === 'win32') {
+    ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => {
+      assertDesktopSender(event, ['app'])
+      if (mainWindow === undefined || event.sender !== mainWindow.webContents
+        || event.senderFrame !== mainWindow.webContents.mainFrame) throw new Error('desktop menu: rejected sender')
+      if ((name !== 'application' && name !== 'edit')
+        || typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)
+        || 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 => ({
+        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'] })
+        },
+      })
+      const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [
+        editItem(messages.undo, 'Z', 'Ctrl+Z'),
+        editItem(messages.redo, 'Y', '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'),
+        { type: 'separator' },
+        editItem(messages.selectAll, 'A', 'Ctrl+A'),
+      ]
+      const zoom = mainWindow.webContents.getZoomFactor()
+      return new Promise<void>((resolve) => {
+        Menu.buildFromTemplate(items).popup({ window, x: Math.round(x * zoom), y: Math.round(y * zoom), callback: resolve })
+      })
+    })
+    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
+      }
+      // Empty colors precede client stylesheet installation; only CSS color values cross IPC.
+      const validColor = (value: unknown): value is string => typeof value === 'string'
+        && /^(?:#[\da-f]{3,8}|rgba?\([\d.,%\s]+\))$/iu.test(value)
+      if (validColor(color) && validColor(symbolColor)) mainWindow.setTitleBarOverlay({ color, symbolColor })
+    })
+  }
+
   const createMainWindow = (): BrowserWindow => {
-    const window = createWindow(appPreload, true)
+    const window = createWindow(appPreload, true, true)
     mainWindow = window
+    if (process.platform === 'win32') {
+      window.webContents.on('before-input-event', (event, input) => {
+        if (input.type === 'keyDown' && input.control && !input.alt && !input.shift && input.key === ',') {
+          event.preventDefault()
+          openPluginWindow()
+        }
+      })
+    }
     window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
     window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {
       if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) {

+ 2 - 0
apps/desktop/src/preload-app.ts

@@ -4,8 +4,10 @@ import { contextBridge, ipcRenderer } from 'electron'
 import { DESKTOP_IPC, SCHEME } from './ipc.ts'
 import { markDocumentPlatform } from './preload-platform.ts'
 import { syncNativeTheme } from './preload-theme.ts'
+import { syncWindowsAppearance } from './preload-windows.ts'
 
 if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
+  syncWindowsAppearance()
   contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', {
     pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise<string | null>,
   })

+ 77 - 0
apps/desktop/src/preload-menu.ts

@@ -0,0 +1,77 @@
+/** Windows caption menu labels and native popup anchors, isolated from the Web client. */
+import { ipcRenderer } from 'electron'
+import { DESKTOP_IPC } from './ipc.ts'
+import { resolveDesktopLocale } from './locale.ts'
+
+/**
+ * Mount the Windows caption menubar without moving focus out of the active editor.
+ * @returns Language refresh and document teardown operations.
+ */
+export function installWindowsMenu(): { update(): void; dispose(): void } {
+  const host = document.createElement('div')
+  host.dataset.windowsMenu = ''
+  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;
+      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; }
+    button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px;
+      background: transparent; color: var(--dsw-alias-label-secondary);
+      font: inherit; font-size: 14px; cursor: default; }
+    button:hover, button[aria-expanded=true] { background: var(--dsw-alias-interactive-bg-hover);
+      color: var(--dsw-alias-label-primary); }
+    button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: -2px; }
+  `
+  const bar = document.createElement('div')
+  bar.setAttribute('role', 'menubar')
+  const createButton = (name: 'application' | 'edit', index: 0 | 1): HTMLButtonElement => {
+    const button = document.createElement('button')
+    button.type = 'button'
+    button.setAttribute('role', 'menuitem')
+    button.setAttribute('aria-haspopup', 'menu')
+    button.setAttribute('aria-expanded', 'false')
+    button.tabIndex = index === 0 ? 0 : -1
+    button.addEventListener('pointerdown', (event) => { event.preventDefault() })
+    button.addEventListener('mousedown', (event) => { event.preventDefault() })
+    const open = async (): Promise<void> => {
+      if (button.getAttribute('aria-expanded') === 'true') return
+      const rect = button.getBoundingClientRect()
+      button.setAttribute('aria-expanded', 'true')
+      try { await ipcRenderer.invoke(DESKTOP_IPC.windowsMenu, name, rect.left, rect.bottom) }
+      finally { button.setAttribute('aria-expanded', 'false') }
+    }
+    button.addEventListener('click', () => { void open() })
+    button.addEventListener('keydown', (event) => {
+      if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+        event.preventDefault()
+        const next = buttons[index === 0 ? 1 : 0]
+        button.tabIndex = -1
+        next.tabIndex = 0
+        next.focus()
+      } else if (event.key === 'ArrowDown') {
+        event.preventDefault()
+        void open()
+      }
+    })
+    bar.append(button)
+    return button
+  }
+  const buttons = [createButton('application', 0), createButton('edit', 1)] as const
+  shadow.append(style, bar)
+  document.body.append(host)
+  const update = (): void => {
+    const { messages } = resolveDesktopLocale(document.documentElement.lang)
+    bar.setAttribute('aria-label', messages.menuBar)
+    buttons[0].textContent = messages.application
+    buttons[1].textContent = messages.edit
+  }
+  update()
+  return {
+    update,
+    dispose: () => {
+      host.remove()
+    },
+  }
+}

+ 40 - 0
apps/desktop/src/preload-windows.ts

@@ -0,0 +1,40 @@
+/** 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 { 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 root = document.documentElement
+    root.dataset.windowsTitlebar = ''
+    root.style.setProperty('--dsh-windows-titlebar-height', `${WINDOWS_TITLEBAR_HEIGHT}px`)
+    const menu = installWindowsMenu()
+    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 values = [root.lang, color, symbolColor]
+      const current = JSON.stringify(values)
+      if (current === previous) return
+      previous = current
+      menu.update()
+      ipcRenderer.send(DESKTOP_IPC.windowsAppearance, ...values)
+    }
+    const observer = new MutationObserver(send)
+    observer.observe(root, { attributes: true, attributeFilter: ['lang'] })
+    observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme', 'style'] })
+    observer.observe(document.head, { childList: true, subtree: true, characterData: true })
+    document.head.addEventListener('load', send, true)
+    window.addEventListener('pagehide', () => {
+      observer.disconnect()
+      menu.dispose()
+      document.head.removeEventListener('load', send, true)
+    }, { once: true })
+    send()
+  }
+  if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', install, { once: true })
+  else install()
+}

+ 5 - 0
apps/desktop/tests/__snapshots__/preload-menu.client.spec.ts.snap

@@ -0,0 +1,5 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`localizes caption entries and removes the menu on disposal > chinese 1`] = `"<div role="menubar" aria-label="应用菜单"><button type="button" role="menuitem" aria-haspopup="menu" aria-expanded="false" tabindex="0">应用</button><button type="button" role="menuitem" aria-haspopup="menu" aria-expanded="false" tabindex="-1">编辑</button></div>"`;
+
+exports[`localizes caption entries and removes the menu on disposal > english 1`] = `"<div role="menubar" aria-label="Application menu"><button type="button" role="menuitem" aria-haspopup="menu" aria-expanded="false" tabindex="0">Application</button><button type="button" role="menuitem" aria-haspopup="menu" aria-expanded="false" tabindex="-1">Edit</button></div>"`;

+ 83 - 6
apps/desktop/tests/main-startup.spec.ts

@@ -1,7 +1,7 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import type { IpcMainInvokeEvent } from 'electron'
 import { join } from 'node:path'
-import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
+import type { MenuItemConstructorOptions, MessageBoxOptions, MessageBoxReturnValue } from 'electron'
 import { DESKTOP_IPC } from '../src/ipc.ts'
 
 vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
@@ -34,11 +34,17 @@ const harness = await vi.hoisted(async () => {
       setWindowOpenHandler: vi.fn(),
       openDevTools: vi.fn(),
       getURL: () => this.urls.at(-1) ?? '',
+      getZoomFactor: () => 1,
+      focus: vi.fn(),
+      sendInputEvent: vi.fn(),
       send: vi.fn(),
     })
     readonly show = vi.fn()
     readonly focus = vi.fn()
     readonly restore = vi.fn()
+    readonly setSize = vi.fn()
+    readonly setTitle = vi.fn()
+    readonly setTitleBarOverlay = vi.fn()
     constructor(readonly options: { show: boolean }) { super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) }
     isDestroyed() { return this.destroyed }
     isMinimized() { return false }
@@ -83,13 +89,14 @@ const harness = await vi.hoisted(async () => {
       if (event.preventDefault.mock.calls.length === 0) quitCompleted.resolve()
     }),
   })
-  const popup = vi.fn()
+  const popup = vi.fn<(options: { window: FakeWindow; x?: number; y?: number; callback?: () => void }) => void>()
   return {
     windows, hosts, handlers, app, FakeWindow, FakeHost,
     failWindow(error: Error) { windowFailure = error },
     popup,
     socketHeaders: vi.fn(),
-    menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) },
+    ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
+    menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn((_items: MenuItemConstructorOptions[]) => ({ popup })) },
     dialog: {
       showOpenDialog: vi.fn(),
       showErrorBox: vi.fn(),
@@ -124,7 +131,7 @@ vi.mock('electron', () => ({
   shell: { openExternal: harness.openExternal },
   nativeTheme: { themeSource: 'system' },
   ipcMain: {
-    on: vi.fn(),
+    on: harness.ipcOn,
     handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { if (harness.handlers.has(channel)) throw new Error(`duplicate IPC handler ${channel}`); harness.handlers.set(channel, handler) },
   },
   Menu: harness.menu,
@@ -187,7 +194,7 @@ afterEach(async () => {
 })
 
 describe('desktop main startup', () => {
-  it.each(['darwin', 'win32', 'linux'] as const)('limits native titlebar styling to macOS on %s', async (platform) => {
+  it.each(['darwin', 'win32', 'linux'] as const)('uses platform-owned titlebar styling on %s', async (platform) => {
     vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -195,6 +202,10 @@ describe('desktop main startup', () => {
     expect(window.urls).toEqual(['dsh-app://app/'])
     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).not.toHaveProperty('vibrancy')
+      expect(harness.menu.setApplicationMenu).toHaveBeenCalledWith(null)
     } else {
       expect(window.options).not.toHaveProperty('titleBarStyle')
       expect(window.options).not.toHaveProperty('vibrancy')
@@ -202,6 +213,71 @@ describe('desktop main startup', () => {
     expect(harness.hosts).toHaveLength(0)
   })
 
+  it('follows the Windows primary document language and palette without trusting other frames', async () => {
+    vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    const window = harness.windows[0]!
+    const listener = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.windowsAppearance)![1]
+    const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
+    listener({ ...event, senderFrame: { url: 'dsh-app://app/' } }, 'zh-CN', '#ffffff', '#000000')
+    expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
+    listener(event, 'zh-CN', 'rgb(249, 250, 251)', '#0f1115')
+    expect(window.setTitleBarOverlay).toHaveBeenCalledWith({ color: 'rgb(249, 250, 251)', symbolColor: '#0f1115' })
+    window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags: { canCopy: true } })
+    expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: '复制', accelerator: '' }])
+    listener(event, 'en', '#1b1b1c', '#f9fafb')
+    window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags: { canCopy: true } })
+    expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: 'Copy', accelerator: '' }])
+    window.setTitleBarOverlay.mockClear()
+    listener(event, 'en', 'url(file:///bad)', '#fff')
+    listener(event, {}, '#fff', '#000')
+    expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
+    expect(harness.menu.setApplicationMenu).toHaveBeenCalledExactlyOnceWith(null)
+  })
+
+  it('maps Windows caption menus to localized native commands and rejects foreign popup requests', async () => {
+    vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    const window = harness.windows[0]!
+    const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
+    const appearance = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.windowsAppearance)![1]
+    appearance(event, 'zh-CN', '#fff', '#000')
+    const handler = harness.handlers.get(DESKTOP_IPC.windowsMenu)!
+    const foreignEvent = { ...event, sender: {} }
+    expect(() => handler(foreignEvent, 'application', 48, 34)).toThrow('rejected sender')
+    expect(() => handler(event, 'arbitrary-command', 48, 34)).toThrow('invalid popup request')
+    expect(() => handler(event, 'application', NaN, 34)).toThrow('invalid popup request')
+    const application = handler(event, 'application', 48, 34)
+    expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([
+      '桌面插件…', '检查更新…', 'separator', '退出',
+    ])
+    expect(harness.popup.mock.lastCall![0]).toMatchObject({ window, x: 48, y: 34 })
+    expect(harness.popup.mock.lastCall![0].callback).toBeTypeOf('function')
+    harness.popup.mock.lastCall![0].callback!()
+    await application
+    const edit = handler(event, 'edit', 104, 34)
+    expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([
+      '撤销', '重做', 'separator', '剪切', '复制', '粘贴', '删除', 'separator', '全选',
+    ])
+    const commands = harness.menu.buildFromTemplate.mock.lastCall![0].filter(item => item.type !== 'separator')
+    for (const [index, keyCode] of ['Z', 'Y', 'X', 'C', 'V', 'Delete', 'A'].entries()) {
+      const click = commands[index]!.click as () => void
+      click()
+      const modifiers = keyCode === 'Delete' ? [] : ['control']
+      expect(window.webContents.sendInputEvent).toHaveBeenNthCalledWith(index * 2 + 1, { type: 'keyDown', keyCode, modifiers })
+      expect(window.webContents.sendInputEvent).toHaveBeenNthCalledWith(index * 2 + 2, { type: 'keyUp', keyCode, modifiers })
+    }
+    harness.popup.mock.lastCall![0].callback!()
+    await edit
+    const preventDefault = vi.fn()
+    window.webContents.emit('before-input-event', { preventDefault }, { type: 'keyDown', control: true, key: ',' })
+    expect(preventDefault).toHaveBeenCalledOnce()
+    expect(harness.windows[1]!.urls).toEqual(['dsh-app://shell/plugin-manager.html'])
+    expect(harness.windows[1]!.options).not.toHaveProperty('titleBarStyle')
+  })
+
   it('attaches Host socket credentials only to the owned application origin and window', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -259,7 +335,8 @@ describe('desktop main startup', () => {
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 
-  it('offers native editing actions on right-click and only copy for selected read-only text', async () => {
+  it('retains macOS native editing actions on right-click and only copy for selected read-only text', async () => {
+    vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
     await import('../src/main.ts')
     await harness.preparing.promise
     const window = harness.windows[0]!

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

@@ -8,6 +8,7 @@ const electron = vi.hoisted(() => ({
 vi.mock('electron', () => electron)
 vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() }))
 vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
+vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
 
 afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
 

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

@@ -0,0 +1,64 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { installWindowsMenu } from '../src/preload-menu.ts'
+import { DESKTOP_IPC } from '../src/ipc.ts'
+
+const invoke = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<void>>())
+vi.mock('electron', () => ({ ipcRenderer: { invoke } }))
+let menu: ReturnType<typeof installWindowsMenu> | undefined
+
+beforeEach(() => {
+  document.documentElement.lang = 'en'
+  invoke.mockResolvedValue(undefined)
+})
+afterEach(() => {
+  menu?.dispose()
+  menu = undefined
+  document.body.replaceChildren()
+  document.documentElement.lang = ''
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+  vi.clearAllMocks()
+})
+
+it('localizes caption entries and removes the menu on disposal', () => {
+  menu = installWindowsMenu()
+  const host = document.querySelector('[data-windows-menu]')!
+  const bar = host.shadowRoot!.querySelector('[role=menubar]')!
+  expect(bar.outerHTML).toMatchSnapshot('english')
+  document.documentElement.lang = 'zh-CN'
+  menu.update()
+  expect(bar.outerHTML).toMatchSnapshot('chinese')
+  menu.dispose()
+  menu = undefined
+  expect(document.querySelector('[data-windows-menu]')).toBeNull()
+})
+
+it('opens native menus without stealing pointer focus and resets popup state when closed', async () => {
+  let close!: () => void
+  invoke.mockImplementation(() => new Promise<void>((resolve) => { close = resolve }))
+  menu = installWindowsMenu()
+  const button = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelector('button')!
+  vi.spyOn(button, 'getBoundingClientRect').mockReturnValue(new DOMRect(48, 6, 90, 28))
+  const pointer = new MouseEvent('pointerdown', { cancelable: true })
+  button.dispatchEvent(pointer)
+  expect(pointer.defaultPrevented).toBe(true)
+  const mouse = new MouseEvent('mousedown', { cancelable: true })
+  button.dispatchEvent(mouse)
+  expect(mouse.defaultPrevented).toBe(true)
+  button.click()
+  expect(invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.windowsMenu, 'application', 48, 34)
+  expect(button.getAttribute('aria-expanded')).toBe('true')
+  close()
+  await vi.waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') })
+})
+
+it('moves between menu entries with arrow keys and opens the focused entry with ArrowDown', () => {
+  menu = installWindowsMenu()
+  const buttons = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelectorAll('button')
+  buttons[0]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true }))
+  expect(buttons[0]!.tabIndex).toBe(-1)
+  expect(buttons[1]!.tabIndex).toBe(0)
+  buttons[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }))
+  expect(invoke).toHaveBeenCalledWith(DESKTOP_IPC.windowsMenu, 'edit', 0, 0)
+})

+ 46 - 0
apps/desktop/tests/preload-windows.client.spec.ts

@@ -0,0 +1,46 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from 'vitest'
+import { DESKTOP_IPC } from '../src/ipc.ts'
+import { syncWindowsAppearance } from '../src/preload-windows.ts'
+
+const send = vi.hoisted(() => vi.fn())
+vi.mock('electron', () => ({ ipcRenderer: { send } }))
+vi.mock('../src/preload-menu.ts', () => ({ installWindowsMenu: () => ({ update: vi.fn(), dispose: vi.fn() }) }))
+
+afterEach(() => {
+  window.dispatchEvent(new Event('pagehide'))
+  document.documentElement.removeAttribute('data-windows-titlebar')
+  document.documentElement.style.removeProperty('--dsh-windows-titlebar-height')
+  document.documentElement.lang = 'en'
+  document.body.removeAttribute('data-ds-dark-theme')
+  vi.restoreAllMocks()
+  send.mockClear()
+})
+
+it.each(['darwin', 'linux'] as const)('does not install Windows controls on %s', (platform) => {
+  vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
+  syncWindowsAppearance()
+  expect(document.documentElement.hasAttribute('data-windows-titlebar')).toBe(false)
+  expect(send).not.toHaveBeenCalled()
+})
+
+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(globalThis, 'getComputedStyle').mockImplementation(() => ({
+    getPropertyValue: (name: string) => name === '--dsw-specific-sidebar-fill'
+      ? document.body.hasAttribute('data-ds-dark-theme') ? '#1b1b1c' : '#f9fafb' : '#0f1115',
+  }) as CSSStyleDeclaration)
+  document.documentElement.lang = 'en'
+  syncWindowsAppearance()
+  expect(document.documentElement.style.getPropertyValue('--dsh-windows-titlebar-height')).toBe('40px')
+  expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.windowsAppearance, 'en', '#f9fafb', '#0f1115')
+  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') })
+  window.dispatchEvent(new Event('pagehide'))
+  send.mockClear()
+  document.documentElement.lang = 'en'
+  await new Promise<void>((resolve) => { queueMicrotask(resolve) })
+  expect(send).not.toHaveBeenCalled()
+})

+ 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: f150416b7fa2712bd3aae0096c9e6c823a7ddce4
-README.zh.md: 05e49b554770f6927ce92e9b42f26769facaa1ba
+README.md: a48d87bcfdc16c48d06e8d5e6ef5ef9ec8e2bcd0
+README.zh.md: ee9136a7f4b39558188f8be3a548bcc514233f46

+ 2 - 0
packages/client/ui-layout/README.md

@@ -29,6 +29,8 @@ 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.
+
 ### Theme presentation
 
 The presenter consumes resolved theme snapshots and projects them onto the document: `html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, the theme's alias tokens and `--dsh-content-font-size` as inline variables on body, and one owned `<meta name="theme-color">` whose content follows the computed body background. Disposing the presenter removes its metadata node with its other global writes.

+ 2 - 0
packages/client/ui-layout/README.zh.md

@@ -29,6 +29,8 @@ kind: "package-reference"
 
 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。
 
+Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶栏高度,并移除收起后的侧栏轨道。内容区仅左上角保留 16px 圆角,其余角和内部交界处保持直角。普通 Web 文档不会获得该标记;macOS 保留其独立布局。
+
 ### 主题呈现
 
 呈现器消费解析后的主题快照,并投影到 document:`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,把主题的别名 token 与 `--dsh-content-font-size` 设为 body 上的内联变量,并持有一个 `<meta name="theme-color">`,其内容随计算后的 body 背景色更新。对呈现器执行 dispose(资源释放)时,它会连同其他全局写入一起移除自己的元数据节点。

+ 32 - 0
packages/client/ui-layout/src/client/AppFrame.module.css

@@ -16,6 +16,38 @@
   transition: none;
 }
 
+/* The Windows caption row remains outside every conversation and file panel. */
+:global([data-windows-titlebar]) .frame {
+  --dsh-windows-content-radius: 16px;
+  box-sizing: border-box;
+  padding-top: var(--dsh-windows-titlebar-height);
+  grid-template-rows: minmax(0, 1fr);
+  background: var(--dsw-specific-sidebar-fill);
+}
+
+:global([data-windows-titlebar]) .centerCol {
+  background: var(--dsw-alias-bg-base);
+  border-radius: var(--dsh-windows-content-radius) 0 0 0;
+  corner-shape: round;
+}
+
+:global([data-windows-titlebar]) .frame::before {
+  content: '';
+  position: absolute;
+  inset: 0 0 auto;
+  height: var(--dsh-windows-titlebar-height);
+  background: var(--dsw-specific-sidebar-fill);
+  -webkit-app-region: drag;
+}
+
+:global([data-windows-titlebar]) .sidebarCol {
+  border-right: none;
+}
+
+:global([data-windows-titlebar]) .handle {
+  top: var(--dsh-windows-titlebar-height);
+}
+
 @media (prefers-reduced-motion: reduce) {
   .frame {
     transition: none;

+ 3 - 4
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -163,10 +163,9 @@ export function AppFrame({
     ? 0
     : layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : layoutInfo.sidebar
   const rightbarPreference = layoutInfo.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO
-  // macOS desktop (data-platform from the Electron preload) hides the closed
-  // sidebar entirely: its reopen controls live in the session header instead
-  // of an icon rail.
-  const collapsedWidth = document.documentElement.dataset.platform === 'darwin' ? 0 : SIDEBAR_COLLAPSED
+  // Desktop reopen controls occupy the macOS session header or Windows caption row.
+  const collapsedWidth = document.documentElement.dataset.platform === 'darwin'
+    || document.documentElement.hasAttribute('data-windows-titlebar') ? 0 : SIDEBAR_COLLAPSED
   // Opening on a narrow frame collapses the left sidebar. Eligibility must
   // include that space before the occupant's first shown report arrives.
   const normal = computeColumns(viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference, collapsedWidth)

+ 13 - 0
packages/client/ui-layout/tests/app-frame.client.spec.tsx

@@ -221,6 +221,19 @@ describe('AppFrame', () => {
     expect(getByTestId('rightbar-content')).toBeTruthy()
   })
 
+  it('keeps Windows caption controls mounted with a zero-width collapsed column', () => {
+    document.documentElement.setAttribute('data-windows-titlebar', '')
+    try {
+      const { frame, instance, sidebarOwner, getByTestId } = mountFrame()
+      act(() => { instance.actions.toggleSidebar() })
+      expect(tracks(frame)[0]).toBe(0)
+      expect(sidebarOwner()).toMatchObject({ collapsed: true, width: 0 })
+      expect(getByTestId('sidebar-content')).toBeTruthy()
+    } finally {
+      document.documentElement.removeAttribute('data-windows-titlebar')
+    }
+  })
+
   it('keeps the closed sidebar mounted at its 56px rail without a handle', () => {
     const { frame, instance, sidebarOwner, getByTestId } = mountFrame()
     act(() => { instance.actions.toggleSidebar() })

+ 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: a2ad8e17e58e04f10efb97236951cd172c654f95
-README.zh.md: 5ce03a0f712aa4ca8ef29404ff1a75467d2ec317
+README.md: cdc6f56738bad422537980db53bbd2f1e1bc97ee
+README.zh.md: cf9a3fc72f4ce187598702296168ae521e1f8335

+ 2 - 0
packages/client/ui-sidebar-right/README.md

@@ -43,6 +43,8 @@ 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.
+
 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.
 
 The panel has no header row. Its two controls — the presentation switch and the collapse button — ride the kit's chrome seat at the far end of the top-right pane's tab strip, so the strip is the panel's whole top edge. Each strip reads, left to right: the tab capsules with close controls where allowed, the add control (drawn only while that pane holds no guide tab; it opens the guide there through `ctx.sidebarRight.openTab`), the pane's split control, and in the top-right pane the two panel controls. Only the chips give way in a narrow pane; the controls after them never shrink or clip.

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

@@ -43,6 +43,8 @@ kind: "package-reference"
 | `push`(默认) | 面板宽度:会话区让出空间 | 在轨道内;它的左缘与会话区的右缘沿框架自己的曲线一起移动 |
 | `fullscreen` | 保留宽屏普通轨道;窄屏自动全屏不占轨道 | 覆盖整个窗口 |
 
+在 Windows Electron 中,`html[data-windows-titlebar]` 让全屏面板保持在顶栏下方,侧栏快捷按钮和原生窗口按钮仍可操作。
+
 席位通过 `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()` 报告呈现,框架不注入本包。宽屏切换全屏不改变中栏宽度;宽度拖拽区只在普通展开态显示。独立浮窗及 `float`/`dock` 操作保持可用。
 
 面板没有标题行。它的两个控件——形态切换与折叠按钮——搭在套件 chrome 席位上,位于右上格 tab 条的最末端,因此 tab 条就是面板的整条上边。每条 tab 条从左到右读作:在允许时带关闭按钮的 tab 胶囊,添加控件(只在该格没有引导 tab 时绘制;它通过 `ctx.sidebarRight.openTab` 在该格打开引导页),该格的分栏控件,以及右上格里的两个面板控件。窄格里只有 chip 让位;其后的控件从不收缩或被裁切。

+ 11 - 0
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css

@@ -59,6 +59,17 @@
   -webkit-app-region: no-drag;
 }
 
+/* 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'] {
+  top: var(--dsh-windows-titlebar-height);
+  border-radius: var(--dsh-windows-content-radius) 0 0 0;
+  corner-shape: round;
+}
+
 /* macOS fullscreen: the panel reaches the window's top-left corner, where the
    hiddenInset traffic lights (x 16..~68) float over the first pane's tab
    strip. Widen that strip's start inset (dockkit publishes the variable);

+ 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: 783ef070442922ec4c0b56143b1131a4f381aa11
-README.zh.md: 4bd5e822b41a1b9a305a4605cfa6e0529962d232
+README.md: 2fe0daf7202ccd080727b04b53c234c64e59cff4
+README.zh.md: 8757e782891375f333652e08706555fa9b62c3a3

+ 2 - 0
packages/client/ui-sidebar/README.md

@@ -39,6 +39,8 @@ 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.
+
 ### macOS desktop
 
 Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights, carries the collapse toggle, and acts as the window drag region; collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the conversation header's `conversation.session.header.leading` seat — the open-sidebar and New Session controls shown, purely via CSS against the AppFrame-published `data-sidebar-collapsed` attribute, only while the column is hidden. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md).

+ 2 - 0
packages/client/ui-sidebar/README.zh.md

@@ -39,6 +39,8 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
 
 实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。
 
+在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。
+
 ### macOS 桌面
 
 在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯、承载收起按钮,并作为窗口拖拽区;收起时整列隐藏而非保留轨道。本包向会话头部的 `conversation.session.header.leading` 座注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性仅在列隐藏时显示。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。

+ 83 - 0
packages/client/ui-sidebar/src/client/SidebarRoot.module.css

@@ -35,6 +35,89 @@
   padding: 18px 10px 6px;
 }
 
+/* The brand stays in the sidebar; fixed caption controls survive zero-width collapse. */
+:global([data-windows-titlebar]) .root .logoRow {
+  height: 40px;
+  margin: 0;
+  padding: 0;
+}
+
+:global([data-windows-titlebar]) .toggle {
+  position: fixed;
+  top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
+  left: 12px;
+  z-index: 30;
+  -webkit-app-region: no-drag;
+}
+
+:global([data-windows-titlebar]) .root.collapsed .logoRow {
+  height: 0;
+}
+
+:global([data-windows-titlebar]) .root:not(.collapsed) .brand {
+  padding-left: 4px;
+}
+
+:global([data-windows-titlebar]) .brandIdentity {
+  transform: translateY(1px);
+}
+
+:global([data-windows-titlebar]) .brandMark {
+  transform: translateX(1px);
+}
+
+:global([data-windows-titlebar]) .brandName {
+  font-weight: 400;
+}
+
+:global([data-windows-titlebar]) .root.collapsed {
+  padding: 0;
+}
+
+:global([data-windows-titlebar]) .root:not(.collapsed) .newSession {
+  margin-top: 8px;
+  margin-left: 0;
+}
+
+:global([data-windows-titlebar]) .collapsed .panelList,
+:global([data-windows-titlebar]) .collapsed .regionArea,
+:global([data-windows-titlebar]) .collapsed .footArea {
+  display: none;
+}
+
+:global(html[data-windows-titlebar]:has([data-sidebar-collapsed='true'])) {
+  --dsh-windows-menu-start: 84px;
+}
+
+:global([data-windows-titlebar]) .collapsed .newSession {
+  position: fixed;
+  top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
+  left: 48px;
+  margin: 0;
+  padding: 0;
+  border: none;
+  z-index: 30;
+  -webkit-app-region: no-drag;
+}
+
+:global([data-windows-titlebar]) .railIn .iconButton,
+:global([data-windows-titlebar]) .railIn .newSession {
+  animation: none;
+}
+
+:global([data-windows-titlebar]) .collapsed .toggle,
+:global([data-windows-titlebar]) .collapsed .newSession {
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  corner-shape: round;
+  color: var(--dsw-alias-label-secondary);
+}
+
+:global([data-windows-titlebar]) .collapsed .toggle .panelIcon {
+  display: inline;
+}
+
 /* Scrollbars in the column are a pointer affordance: the shell adds this
    class whenever the pointer is not inside (SidebarRoot.tsx owns the linger),
    and rebinding ui-theme's indirection pair to `transparent` takes the thumb

+ 5 - 4
packages/client/ui-sidebar/src/client/SidebarRoot.tsx

@@ -105,7 +105,8 @@ export function SidebarRoot({
     const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS)
     return () => { window.clearTimeout(timer) }
   }, [collapsed])
-  const wide = !collapsed || !settled
+  const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar')
+  const wide = windowsTitlebar ? !collapsed : !collapsed || !settled
 
   // Freeze the content at its expanded width while it fades out (collapsed
   // && wide): the sliding column then clips it instead of reflowing it. The
@@ -175,13 +176,13 @@ export function SidebarRoot({
         aria-label={collapsed ? t('toggle.open') : t('toggle.collapse')}
         onClick={() => { toggleSidebar() }}
       >
-        {!wide && (
+        {!wide && !windowsTitlebar && (
           <span className={css.railMark} aria-hidden="true">
             {renderSlot('sidebar.brand.mark', { size: 24 }, { fallback: <FishLogo size={24} /> })}
           </span>
         )}
         {/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
-        <IconPanelLeftOutline16 className={css.panelIcon} size={wide ? 16 : 18} />
+        <IconPanelLeftOutline16 className={css.panelIcon} size={wide || windowsTitlebar ? 16 : 18} />
       </button>
     </Tooltip>
   )
@@ -243,7 +244,7 @@ export function SidebarRoot({
           aria-label={t('session.new.label')}
           onClick={() => { startSession() }}
         >
-          <IconNewChatOutline16 size={wide ? 14 : 18} />
+          <IconNewChatOutline16 size={wide ? 14 : windowsTitlebar ? 16 : 18} />
           {wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
         </button>
       </Tooltip>

+ 204 - 0
packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap

@@ -1,5 +1,209 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
+exports[`sidebar shell snapshots > renders Windows caption controls in expanded and collapsed states > windows collapsed 1`] = `
+<div
+  data-slot="sidebar"
+  style="display: contents;"
+>
+  <div
+    class="root collapsed railIn quietBars"
+    style=""
+  >
+    <div
+      class="logoRow"
+    >
+      <button
+        aria-label="Open sidebar"
+        class="iconButton toggle"
+        type="button"
+      >
+        <svg
+          class="panelIcon"
+          data-content="35f95b0c"
+          fill="none"
+          height="16"
+          viewBox="0 0 16 16"
+          width="16"
+          xmlns="http://www.w3.org/2000/svg"
+        />
+      </button>
+    </div>
+    <button
+      aria-label="New session"
+      class="newSession"
+      type="button"
+    >
+      <svg
+        data-content="c0ce4dcc"
+        fill="none"
+        height="16"
+        viewBox="0 0 16 16"
+        width="16"
+        xmlns="http://www.w3.org/2000/svg"
+      />
+    </button>
+    <div
+      class="regionArea"
+    >
+      <div
+        data-slot="sidebar.workspaces"
+        style="display: contents;"
+      />
+    </div>
+    <div
+      class="footArea"
+    >
+      <div
+        class="footerActions"
+      >
+        <div
+          data-slot="sidebar.footer.action"
+          style="display: contents;"
+        />
+      </div>
+      <div
+        class="settingsArea"
+      >
+        <div
+          data-slot="sidebar.settings"
+          style="display: contents;"
+        />
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`sidebar shell snapshots > renders Windows caption controls in expanded and collapsed states > windows expanded 1`] = `
+<div
+  data-slot="sidebar"
+  style="display: contents;"
+>
+  <div
+    class="root quietBars"
+    style="width: 300px;"
+  >
+    <div
+      class="logoRow"
+    >
+      <button
+        aria-label="New session"
+        class="brand wide"
+        type="button"
+      >
+        <span
+          aria-hidden="true"
+          class="brandIdentity"
+        >
+          <span
+            class="brandMark"
+          >
+            <div
+              data-slot="sidebar.brand.mark"
+              style="display: contents;"
+            >
+              <svg
+                aria-hidden="true"
+                data-content="965fe321"
+                fill="none"
+                height="17.6580310880829"
+                viewBox="0 0 23.16 17.04"
+                width="24"
+              />
+            </div>
+          </span>
+          <span
+            class="brandName"
+          >
+            <div
+              data-slot="sidebar.brand.name"
+              style="display: contents;"
+            >
+              <span
+                class="localBuildBrand"
+              >
+                <span
+                  class="localBuildTitle"
+                >
+                  DSH Local Build
+                </span>
+                <span
+                  class="buildVersion"
+                >
+                  1.2.3-rc.4-abc1234-dirty
+                </span>
+              </span>
+            </div>
+          </span>
+        </span>
+      </button>
+      <button
+        aria-label="Collapse sidebar"
+        class="iconButton toggle"
+        type="button"
+      >
+        <svg
+          class="panelIcon"
+          data-content="35f95b0c"
+          fill="none"
+          height="16"
+          viewBox="0 0 16 16"
+          width="16"
+          xmlns="http://www.w3.org/2000/svg"
+        />
+      </button>
+    </div>
+    <button
+      aria-label="New session"
+      class="newSession"
+      type="button"
+    >
+      <svg
+        data-content="c0ce4dcc"
+        fill="none"
+        height="14"
+        viewBox="0 0 16 16"
+        width="14"
+        xmlns="http://www.w3.org/2000/svg"
+      />
+      <span
+        class="newSessionLabel wide"
+      >
+        New Session
+      </span>
+    </button>
+    <div
+      class="regionArea"
+    >
+      <div
+        data-slot="sidebar.workspaces"
+        style="display: contents;"
+      />
+    </div>
+    <div
+      class="footArea"
+    >
+      <div
+        class="footerActions"
+      >
+        <div
+          data-slot="sidebar.footer.action"
+          style="display: contents;"
+        />
+      </div>
+      <div
+        class="settingsArea"
+      >
+        <div
+          data-slot="sidebar.settings"
+          style="display: contents;"
+        />
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
 exports[`sidebar shell snapshots > renders the collapsed rail after the crossfade settles, in place 1`] = `
 <div
   data-slot="sidebar"

+ 15 - 0
packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx

@@ -96,4 +96,19 @@ describe('sidebar shell snapshots', () => {
     expect(slot.view.queryByRole('button', { name: '新建会话' })).toBeNull()
     await runtime.dispose()
   })
+
+  it('renders Windows caption controls in expanded and collapsed states', async () => {
+    document.documentElement.setAttribute('data-windows-titlebar', '')
+    const { runtime } = await bench({ locale: 'en' })
+    try {
+      const slot = runtime.renderSlot('sidebar', { collapsed: false, width: 300 })
+      expect(slot.container).toMatchSnapshot('windows expanded')
+      slot.update({ collapsed: true, width: 0 })
+      expect(slot.view.getAllByRole('button', { name: 'New session' })).toHaveLength(1)
+      expect(slot.container).toMatchSnapshot('windows collapsed')
+    } finally {
+      await runtime.dispose()
+      document.documentElement.removeAttribute('data-windows-titlebar')
+    }
+  })
 })