From 31a83ebc4bb97217126430000d2ce8dd947d56f4 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 19:01:15 +0800 Subject: [PATCH 1/6] refactor(client): let chrome rows own the macOS window drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS window's draggable area was a hand-written band: a 52px strip, deepened to 76px by a marker the Conversation header had to keep in step with its own height. It drifted from the layout — blank runs inside a chrome row did not drag, and a control a few pixels away dragged the window instead. Chrome rows now mark their own element (`data-window-drag`) and the shell declares the surface once (`html[data-platform='darwin'] [data-window-drag]`), so a row's own box is its draggable geometry and no fixed band has to match any row's height. The frame band, its 52/76 figures, the marker, and the per-sheet drag rules are gone; interactive controls and overlays portal beside `#root` where one rule subtracts them. Recollection becomes one shell watcher (`window-drag/recall.ts`): Electron rebuilds the window's drag rects only when a computed app-region value changes (electron#32341), so the watcher measures every marked row each frame while the surface moves, sets `data-window-drag-recall` for the frames whose geometry changed, and clears it on the frame that finds the geometry settled. The right panel's hand-written pulse is deleted with it. Coverage is a contract with executable parts. `window-drag/regions.ts` publishes the composition rule and the interactive selector that both the sheet and its spec compare against; the ui-theme gate asserts the package tree declares drag in exactly two places and pairs every row's mark with its sheet, selector, and authored height; and the browser lane boots the shipped composition in Chromium with the darwin marker to assert per-row geometry, a content body under a marked head row, a covering overlay, and the watcher's pulse. The plugin manager's detail views mark the head row they share rather than the pane they render in, so their text and form labels stay content. Both real machines agree: the macOS matrix ran on a real `hiddenInset` window with HID-level pointer input and window-frame readback, and the Windows matrix on a Windows 11 ARM64 VM — chrome rows drag, content and the covering overlay do not, and the controls keep their clicks. Refs #4619 --- ...19-window-drag-coverage-contract.i18n.yaml | 6 + ...026-09-19-window-drag-coverage-contract.md | 63 +++ ...-09-19-window-drag-coverage-contract.zh.md | 63 +++ ...3-macos-hidden-titlebar-vibrancy.i18n.yaml | 4 +- ...26-09-13-macos-hidden-titlebar-vibrancy.md | 8 +- ...09-13-macos-hidden-titlebar-vibrancy.zh.md | 8 +- apps/web/tests/window-drag-coverage.e2e.ts | 492 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/skeleton/ConversationHeader.tsx | 2 +- .../skeleton/ConversationRoot.module.css | 14 +- .../client/skeleton/ConversationSession.tsx | 6 +- .../tests/skeleton.client.spec.tsx | 2 +- .../ui-dockkit/src/components/TabPanel.tsx | 2 +- .../src/components/dockkit.module.css | 21 +- .../tests/app-region-styles.client.spec.ts | 50 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 2 +- .../ui-layout/src/client/AppFrame.module.css | 40 +- .../client/ui-layout/src/client/AppFrame.tsx | 23 - .../ui-layout/tests/app-frame.client.spec.tsx | 8 +- .../src/client/PluginManagerPage.module.css | 19 +- .../src/client/PluginManagerPage.tsx | 6 +- .../client/ui-settings-general/package.json | 4 +- .../src/client/SettingsRoot.module.css | 9 +- .../src/client/SettingsRoot.tsx | 9 +- .../src/client/shell/SidebarRight.module.css | 28 +- .../src/client/shell/SidebarRight.tsx | 33 +- .../tests/region-nudge-styles.client.spec.ts | 46 -- .../tests/seat.client.spec.tsx | 41 -- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/SidebarRoot.module.css | 25 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 4 +- .../sidebar-snapshot.client.spec.tsx.snap | 5 + .../tests/app-region-styles.client.spec.ts | 272 ++++++++-- .../client/ui-theme/tests/stylesheet-scan.ts | 21 +- packages/client/web/src/base.css | 44 +- packages/client/web/src/boot.ts | 9 + packages/client/web/src/window-drag/recall.ts | 171 ++++++ .../client/web/src/window-drag/regions.ts | 106 ++++ .../web/tests/base-styles.client.spec.ts | 36 +- .../tests/window-drag-recall.client.spec.ts | 375 +++++++++++++ .../tests/window-drag-regions.client.spec.ts | 69 +++ pnpm-lock.yaml | 170 +++--- tsconfig.host.json | 4 +- 50 files changed, 1931 insertions(+), 412 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md create mode 100644 .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md create mode 100644 apps/web/tests/window-drag-coverage.e2e.ts delete mode 100644 packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts create mode 100644 packages/client/web/src/window-drag/recall.ts create mode 100644 packages/client/web/src/window-drag/regions.ts create mode 100644 packages/client/web/tests/window-drag-recall.client.spec.ts create mode 100644 packages/client/web/tests/window-drag-regions.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml new file mode 100644 index 0000000000..1138c29772 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.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/architecture/2026-09-19-window-drag-coverage-contract.md +2026-09-19-window-drag-coverage-contract.md: 71d9b192de7b10e10462c3f9360b1e2f56644b77 +2026-09-19-window-drag-coverage-contract.zh.md: edbcc010e69e1b6b9f7e02761efaa8798d338801 diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md new file mode 100644 index 0000000000..71d9b192de --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md @@ -0,0 +1,63 @@ +# Agent Note: Window drag coverage as a contract + +Status: implemented + +English | [中文](2026-09-19-window-drag-coverage-contract.zh.md) + +## Problem + +The macOS desktop window's draggable area is authored twice. The layout decides where chrome rows are — their heights and what they contain — while hand-written app-region declarations decide what drags: one row declaration per chrome row (the sidebar's top strip and logo row, ui-dockkit's strip row, the Conversation header, the plugin manager's page heads) plus per-box subtractions spread across ui-web, ui-settings-general and ui-sidebar-right. The two descriptions are kept in step by hand, so they drift: a row whose box does not match the band leaves chrome outside the drag surface (the reported "the header drags, but some blank runs and controls do not") or puts content inside it (a press that misses a control by a few pixels drags the window, and a double click there runs the system's title-bar action). + +## Decision + +Coverage becomes a contract with four executed parts: + +1. ui-web's `window-drag/regions.ts` publishes the composition rule as an executable model — a point is draggable when the last collected app-region box containing it declares `drag`, the geometry-in-DOM-order composition the native window applies — together with `INTERACTIVE_SELECTOR`, the single source for the interactive subtraction ui-web base.css declares. The base-styles spec asserts the sheet declares exactly that list, so the two cannot drift. +2. The shell declares the darwin drag surface exactly once: `html[data-platform='darwin'] [data-window-drag]` in ui-web base.css. Every chrome row marks its own element in markup, so the row's box is the window's draggable geometry and no fixed band has to match any row's height. The ui-theme app-region gate holds a manifest that pairs each row's markup mark with its sheet, its class selector and its authored height, and refuses a mark on any element the manifest does not name — so a row that stops marking itself, an unmarked row added to the composition, and a mark on a content container all fail the same gate. +3. The browser lane gains `window-drag-coverage`, which boots the shipped composition in Chromium with `data-platform='darwin'` and asserts, through the model, that no interactive box lies inside the drag surface, plus per-row probes for the chrome rows and a page whose head row marks itself while its scrollable body stays content. +4. The shell owns the one recollection watcher — ui-web's `window-drag/recall.ts`, installed by the boot kernel. It measures every marked row once per frame while the surface can be moving, sets `data-window-drag-recall` on the body for each frame whose geometry changed, and clears it on the frame that finds the geometry settled. No chrome row carries a pulse of its own. + +The native half — whether a press really drags or reaches the page — stays a checklist, because no keyless CI lane reaches the window server: the state matrix below is run for any change to this layer. + +## Alternatives considered + +**Publishing measured rectangles from JavaScript as synthetic drag boxes.** Geometry would have to be re-derived at pointer cadence, and one stale frame is exactly the swallow class this work removes; CSS-derived regions cannot be stale by construction. + +**A global background drag (the `movableByWindowBackground` reading).** Dragging anywhere that is not a control would break text selection and blank-area clicks. + +**One drag declaration per row in each row's own sheet.** That was this work's first shape, with a manifest pinning each row's height and the band over it. Every sheet then restates the window's geometry, and a row whose box moves in layout alone still has to be re-declared in CSS; one shell rule over a markup mark keeps the declaration count at one, and the manifest pairs the marks with the geometry that the gate can still check. + +**Pinning current coverage as one golden snapshot.** That would freeze the two failure classes next to the correct behaviour; the manifest separates the invariant (no swallowed control) from the gaps (a budget that only shrinks). + +## Consequences + +- A new control needs no drag declaration: the interactive selector subtracts it. A new chrome row marks its element `data-window-drag` and adds a manifest entry pinning its height, so no band arithmetic and no other sheet changes. +- A row's own box is the whole draggable geometry, and a content container is never one. The plugin manager's detail views mark the head row they share, not the detail container they are rendered in: marking the container would drag the window over its text and form labels, and the lane samples the body below that head to keep it so. +- No coverage gap remains: every chrome row owns its run, and the gate asserts the whole package tree declares the drag surface in exactly two places — the shell's mark rule and the Windows caption row. The plugin manager's page head and its detail views' shared head row include the 48px clearance inside the row, so the entry-page gap closed with them. +- The settings overlay portals beside `#root` (like the Modal primitive) and dropped its per-sheet `no-drag` rule: a covering surface inside the root precedes the columns' chrome, so a drag row declared later would override its subtraction, and ui-web base.css's `body > :not(#root)` rule subtracts a portalled one wherever it overlaps a drag row. The browser lane asserts that placement with the overlay open. +- A row that slides across a hidden/visible edge cannot rely on the layout change alone: Electron recollects the window's drag rects only when a computed app-region value changes (electron#32341), and Blink skips hidden boxes when it collects them. The watcher closes that gap for every row at once — it re-arms on a DOM change that touches a marked row or on a marked row's box resizing, then pulses each frame in which a box moved — so the right panel carries no pulse of its own. + +## Testing + +- `packages/client/web/tests/window-drag-regions.client.spec.ts` — the composition rule, including order sensitivity and box edges. +- `packages/client/web/tests/base-styles.client.spec.ts` — base.css declares the model's interactive selector, the one darwin drag rule, and the recall mark's subtraction. +- `packages/client/ui-theme/tests/app-region-styles.client.spec.ts` — drag ownership, the row manifest, and each row's mark; a mark outside the manifest or a changed row height fails. +- `packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts` — the strip row's own subtraction; its drag is the markup mark's, so the sheet declares none. +- `packages/client/web/tests/window-drag-recall.client.spec.ts` — the watcher through its two seams: what re-arms it, what it ignores, the settle loop, the shell defaults, and disposal. +- `apps/web/tests/window-drag-coverage.e2e.ts` — real Chromium: no swallowed control, per-row probes, a detail page whose head row drags while its body does not, and the recall pulse the shell fires while the right panel slides. +- Real-machine verification (2026-09-20, both platforms, HID-level pointer input plus window-frame readback): the macOS matrix above ran on a real `hiddenInset` window and the Windows cells ran on a Windows 11 ARM64 VM — chrome rows drag, content and the covering overlay do not, controls keep their clicks, and the right panel's strip row drags after its slide. + +## Manual state matrix + +Run on the packaged macOS app for any change to this layer. Each cell means both halves: drag the row's blank run (the window must move) and click the row's controls (they must act rather than drag). + +| State | Rows to walk | +| --- | --- | +| Sidebar expanded, Conversation selected, view tabs shown | sidebar top strip, sidebar logo row, conversation title row, conversation tab-strip row, every control in those rows | +| Sidebar expanded, Conversation selected, single view (no tabs) | same rows, plus the transcript's first rows below the header | +| Sidebar collapsed | Conversation header over the centre, `shell.leading` seat controls, reopen and New session | +| Right sidebar pushed | panel strip row and its controls, the pane body run below the strip, the pane divider's top run | +| Right sidebar fullscreen | panel strip row at the traffic lights, first pane's strip inset, its controls | +| Entry page (guide, plugin manager, settings) | page head with its 48px clearance, its first control row, and a detail page's head row over its body | +| Window fullscreen on/off | every row above with the traffic lights hidden, in both sidebar states | +| Double click in a chrome row's blank run | on Windows the caption row takes the system title-bar action (maximize/restore); on macOS the darwin rows are app-region areas rather than a title bar, so no system action is expected there — only the controls keep their own double click | diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md new file mode 100644 index 0000000000..edbcc010e6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md @@ -0,0 +1,63 @@ +# Agent Note: 把窗口拖拽覆盖变成契约 + +Status: implemented + +[English](2026-09-19-window-drag-coverage-contract.md) | 中文 + +## Problem + +macOS 桌面窗口的可拖区域被写了两遍。布局决定 chrome 行在哪——它们多高、装了什么;手写的 app-region 声明决定什么能拖:每个 chrome 行一条行声明(侧栏顶条与 logo 行、ui-dockkit 的 strip 行、会话 header、插件管理的页头),加上散落在 ui-web、ui-settings-general、ui-sidebar-right 的逐盒减除。两份描述靠人肉保持一致,于是会漂:盒子与 band 不匹配的行,要么有 chrome 落在拖拽面之外(就是反馈里的"header 能拖,但有些空白与控制不能"),要么有内容落进拖拽面(点偏几个像素就变成拖窗口,双击那里还会触发系统标题栏行为)。 + +## Decision + +覆盖变成由四部分组成的、可执行的契约: + +1. ui-web 的 `window-drag/regions.ts` 把合成规则发布成可执行模型——当包含某点的最后一个 app-region 盒声明 `drag` 时该点可拖,这正是原生窗口施加的"按 DOM 顺序的几何合成"——并发布 `INTERACTIVE_SELECTOR`,作为 ui-web base.css 交互减除的唯一来源。base-styles spec 断言样式表声明的正是该列表,两者无法漂移。 +2. 外壳只声明一次 darwin 拖拽面:ui-web base.css 里的 `html[data-platform='darwin'] [data-window-drag]`。每个 chrome 行在 markup 里给自己的元素打标,于是该行自己的盒子就是窗口的可拖几何,不需要任何固定 band 去匹配行高。ui-theme 的 app-region 门禁持有一份清单,把每行的 markup 标记与其样式表、类选择器和写死的高度配成一条,并拒绝清单未点名的元素上的标记——行不再打标、新增未打标的行、把标记打在内容容器上,都会在同一条门禁上失败。 +3. 浏览器车道增加 `window-drag-coverage`:以 `data-platform='darwin'` 在 Chromium 里启动真实组合,用模型断言没有任何交互盒落在拖拽面内,并对各 chrome 行逐一探测,另加一个"页头行打标、其可滚动正文保持内容"的页面。 +4. 外壳持有唯一那个重收集 watcher——ui-web 的 `window-drag/recall.ts`,由引导内核安装。在表面可能移动期间,它每帧测量每个被打标的行,凡该帧几何发生变化就在 body 上打 `data-window-drag-recall`,并在第一次发现几何稳定的那一帧清掉它。任何 chrome 行都不再自带脉冲。 + +原生那一半——一次按下到底是拖窗口还是到达页面——仍是清单,因为没有无密钥 CI 车道能到达窗口服务器:对本层的任何改动都要走下面的状态矩阵。 + +## Alternatives considered + +**用 JavaScript 测量矩形并发布合成 drag 盒。** 几何得按指针节拍重新推导,而一帧陈旧正是这项工作要消除的吞点击类别;CSS 推导的区域在构造上不可能陈旧。 + +**全局背景拖拽(`movableByWindowBackground` 的读法)。** 只要不是控件就能拖,会破坏文本选择与空白处点击。 + +**每个行在自己的样式表里各声明一条 drag。** 这是本项工作最初的样子,并配一份清单钉住每行高度与覆盖它的 band。那样每个样式表都要复述窗口几何,而且只在布局里移动的盒子仍得在 CSS 里重新声明;改成"markup 打标 + 外壳一条规则"后声明只有一处,清单则继续把标记与门禁能查的几何配起来。 + +**把当前覆盖钉成一张金标准快照。** 那会把两类故障和正确行为一起冻住;清单把不变量(不得吞掉控件)与缺口(只许缩小的预算)分开。 + +## Consequences + +- 新控件不需要任何 drag 声明:交互选择器会减除它。新 chrome 行只需给元素打上 `data-window-drag` 并在清单里加一条钉住高度,无需 band 算术,也不牵动其它样式表。 +- 行自己的盒子就是完整的可拖几何,内容容器永远不是。插件管理的详情视图给它们共用的页头行打标,而不是给它所在的详情容器打标:标记容器会让窗口在它的正文与表单 label 上拖动;车道会采样该页头下方的正文来守住这一点。 +- 不再有任何缺口:每个 chrome 行自持其拖拽段,门禁断言整个包树只在两处声明拖拽面——外壳的标记规则与 Windows 标题栏行。插件管理页头与其详情视图共用的页头行都把 48px 避让量包含在行内,入口页那条缺口也随之关闭。 +- settings 浮层现在与 Modal primitive 一样 portal 到 `#root` 之外,并删掉了它自己那条 no-drag:挂在 root 内的覆盖层先于各列 chrome,后声明的 drag 行会盖掉它的减除;portal 之后由 ui-web base.css 的 `body > :not(#root)` 规则在它与 drag 行重叠处减除。车道在浮层打开时会断言这个归属。 +- 跨越隐藏/显示边界滑动的行不能只靠布局变化:只有当计算出的 app-region 值变化时,Electron 才会重新收集窗口的拖拽矩形(electron#32341),而 Blink 收集时会跳过隐藏的盒子。watcher 一次性把这个缺口对所有行补上——它在"触及被打标行的 DOM 变化"或"被打标行的盒子尺寸变化"时重新启动,随后在盒子仍在移动的每一帧发脉冲——因此右栏不再自带任何脉冲。 + +## Testing + +- `packages/client/web/tests/window-drag-regions.client.spec.ts` —— 合成规则,含顺序敏感与盒子边界。 +- `packages/client/web/tests/base-styles.client.spec.ts` —— base.css 声明的正是模型的交互选择器、唯一那条 darwin drag 规则,以及 recall 标记的减除。 +- `packages/client/ui-theme/tests/app-region-styles.client.spec.ts` —— 拖拽所有权、行清单与每行的标记;清单之外的标记或行高变化即失败。 +- `packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts` —— strip 行自己的减除;它的 drag 属于 markup 标记,因此样式表一条都不声明。 +- `packages/client/web/tests/window-drag-recall.client.spec.ts` —— 通过两个 seam 驱动 watcher:什么会重新启动它、什么被它忽略、收敛循环、外壳默认值,以及销毁。 +- `apps/web/tests/window-drag-coverage.e2e.ts` —— 真 Chromium:无控件被吞、逐行探测、一个页头行可拖而正文不可拖的详情页,以及右栏滑动期间外壳发出的 recall 脉冲。 +- 真机验收(2026-09-20,双平台,HID 级指针输入 + 窗口矩形读数):上面的 macOS 矩阵在真实 `hiddenInset` 窗口上跑过,Windows 各格在 Windows 11 ARM64 虚拟机上跑过——chrome 行可拖、正文与覆盖浮层不可拖、控件保留点击、右栏 strip 行在滑入后可拖。 + +## Manual state matrix + +对本层的任何改动都要在打包 macOS 应用上跑。每个格子包含两半:拖该行的空白段(窗口必须移动),点该行的控件(必须生效而不是拖动)。 + +| 状态 | 要走的行 | +| --- | --- | +| 侧栏展开、选中会话、显示视图 tab | 侧栏顶条、侧栏 logo 行、会话标题行、会话 tab 条行,以及这些行里的每个控件 | +| 侧栏展开、选中会话、单视图(无 tab) | 同上,外加 header 之下的转录区首行 | +| 侧栏收起 | 覆盖中列的 会话 header、`shell.leading` 座的控件、重新打开与新会话 | +| 右栏 push | 面板 strip 行及其控件、strip 下方的 pane body 段、分栏分隔条的顶部一段 | +| 右栏 fullscreen | 红绿灯处的面板 strip 行、首格 strip 内边距、其控件 | +| 入口页(开始页、插件管理、设置) | 含 48px 避让量的页头、它的首个控件行,以及详情页页头行与其正文 | +| 窗口全屏开关 | 以上各行在红绿灯隐藏时、两种侧栏状态各一遍 | +| 在 chrome 行空白段双击 | Windows 上 caption 行走系统标题栏行为(最大化/还原);macOS 上 darwin 行只是 app-region 区域而非标题栏,因此那里没有系统动作——只有控件保留自己的双击 | diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml index d15870537e..99ecd869cb 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml +++ b/.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: 807c79d06c468e01b8b9df3f18a32e5fbaba1e90 -2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: a521aff7624b1f9dbab18e7d0f02f61af0bdc24a +2026-09-13-macos-hidden-titlebar-vibrancy.md: 005bf783bf7396446313b598b25255bff3a8ad7a +2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 17b746962d5ffd02f23769f17c7b71170cb9689e diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md index 807c79d06c..005bf783bf 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md @@ -18,11 +18,11 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o **Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`. -**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights and carries the collapse toggle; the frame's `.leadingBand` drag band covers the same 52px geometry, so the strip declares no app-region of its own and the toggle subtracts itself through the base.css interactive rule. Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances live in the frame's root-scoped `shell.leading` window-chrome seat, which the AppFrame mounts only while the column is fully hidden; ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. The seat placement, the `--dsh-frame-leading-clearance` variable panels pad by, and the removal of the earlier conversation-header seat are owned by the [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md). +**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights and carries the collapse toggle; the strip marks itself `data-window-drag`, so its own 52px box is the window's drag region (ui-web base.css declares the one darwin drag rule) and the toggle subtracts itself through the base.css interactive rule. Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances live in the frame's root-scoped `shell.leading` window-chrome seat, which the AppFrame mounts only while the column is fully hidden; ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. The seat placement, the `--dsh-frame-leading-clearance` variable panels pad by, and the removal of the earlier conversation-header seat are owned by the [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md). **Window fullscreen marker.** macOS fullscreen hides the traffic lights, so layouts built around them must relax. The main process relays `isFullScreen()` over `dsh-desktop:window-fullscreen` on `enter-full-screen`, `leave-full-screen`, and each `did-finish-load`; the app preload's `syncWindowFullscreen()` mirrors it onto `html[data-fullscreen]`. Consumers: the AppFrame drops `--dsh-frame-leading-clearance` to 84px and moves the seat to `left: 12px`, the sidebar's `.topStrip` moves the toggle to the strip's left edge, and the right sidebar's fullscreen panel resets its first pane's dockkit strip inset to the ordinary 10px. -**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so `-webkit-app-region: drag` is declared only inside the two allowlisted stylesheets: ui-layout `AppFrame.module.css` (the darwin `.leadingBand`, a 52px full-width `pointer-events: none` strip rendered as the frame's first child, and the Windows caption `.frame::before`) and ui-sidebar `SidebarRoot.module.css` (`.logoRow`, which extends the band below 52px). Everything interactive subtracts itself through two global ui-web base.css rules: one `:is(...)` selector opting out every interactive element (buttons, links, inputs, roles, `[tabindex]`), and one `body > :not(#root)` rule covering every overlay portaled beside `#root`. Because the drag bands mount before all content, any later no-drag box wins; content containers never declare drag, which would override earlier-mounted overlays. +**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so the shell declares `-webkit-app-region: drag` once, over the mark a chrome row puts on its own box, each row's box being its draggable geometry: the sidebar's top strip and logo row (ui-sidebar), the right sidebar's dockkit strip (ui-dockkit), the Conversation header (ui-conversation), and the plugin manager's page head and shared detail head (ui-plugin-manager). The frame declares none — the Windows caption row is that platform's own chrome — so no fixed band has to match any row's height. Everything interactive subtracts itself through two global ui-web base.css rules: one `:is(...)` selector opting out every interactive element (buttons, links, inputs, roles, `[tabindex]`), and one `body > :not(#root)` rule covering every overlay portaled beside `#root`. The ordering rule those two rest on: a surface that covers the window and must stay clickable portals beside `#root` (the settings overlay does), which puts it after every drag row in document order; content containers never declare drag, which would override earlier-mounted overlays. The [coverage contract note](../architecture/2026-09-19-window-drag-coverage-contract.md) owns the manifest, the browser lane, and the manual state matrix. **Fullscreen traffic-light clearance.** ui-dockkit publishes the tab strip's start inset as `--dsh-dockkit-strip-inline-start` (fallback the design's own 10px). The right sidebar's fullscreen presentation on darwin sets 88px on the panel body and resets 10px for every non-first split cell's subtree, so exactly the pane touching the window's top-left corner clears the lights at any split depth. @@ -43,9 +43,9 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o - The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets. - The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering. - The reopen controls occupy the frame's `shell.leading` seat, a public slot in the client catalog; the earlier `conversation.session.header.leading` seat is removed ([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md)). -- Drag-region ownership is a window-global invariant: `-webkit-app-region: drag` appears only inside the two allowlisted stylesheets (ui-layout `AppFrame.module.css`: darwin `.leadingBand` and Windows caption `.frame::before`; ui-sidebar `SidebarRoot.module.css`: `.logoRow`); other packages rely on the base.css opt-outs or add a scoped `no-drag`, never a new drag surface. The ui-theme app-region stylesheet gate enforces the allowlist over every package stylesheet. +- Drag-region ownership is a window-global invariant: `-webkit-app-region: drag` appears only in ui-web `base.css` (the one darwin rule over `data-window-drag`) and in ui-layout `AppFrame.module.css` (the Windows caption `.frame::before`); other packages rely on the base.css opt-outs or add a scoped `no-drag`, never a new drag surface. The ui-theme app-region gate pairs each chrome row's markup mark with its sheet, selector, and authored height, and refuses a mark anywhere else. - The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash. ## Testing -ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on teardown; the ui-layout app-frame spec pins the seat's darwin mount. The desktop main-startup spec pins the fullscreen relay (transitions, reloads, destroyed-window guard, darwin-only registration) and the preload-platform spec pins the `html[data-fullscreen]` mirror. The ui-theme corner-shape and full-round style gates cover the new stylesheet, and its app-region style gate pins drag-region ownership to the two allowlisted stylesheets across every package stylesheet. +ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on teardown; the ui-layout app-frame spec pins the seat's darwin mount. The desktop main-startup spec pins the fullscreen relay (transitions, reloads, destroyed-window guard, darwin-only registration) and the preload-platform spec pins the `html[data-fullscreen]` mirror. The ui-theme corner-shape and full-round style gates cover the new stylesheet, and its app-region gate pins the one darwin drag rule and every chrome row's markup mark. diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md index a521aff762..17b746962d 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md @@ -18,11 +18,11 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig **原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。 -**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯并承载收起按钮;框架的 `.leadingBand` 拖拽带覆盖同一 52px 几何,顶部条自身不声明 app-region,按钮经 base.css 交互规则自行减除。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口位于框架 root 作用域的 `shell.leading` 窗口 chrome 座,由 AppFrame 仅在整列隐藏时挂载;ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。座的位置、面板用于避让的 `--dsh-frame-leading-clearance` 变量,以及早先会话头部座的移除,归 [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) 所有。 +**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯并承载收起按钮;顶部条自己打上 `data-window-drag`,于是它自身那 52px 盒子就是窗口的拖拽区(唯一的 darwin drag 规则由 ui-web base.css 声明),按钮经 base.css 交互规则自行减除。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口位于框架 root 作用域的 `shell.leading` 窗口 chrome 座,由 AppFrame 仅在整列隐藏时挂载;ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。座的位置、面板用于避让的 `--dsh-frame-leading-clearance` 变量,以及早先会话头部座的移除,归 [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) 所有。 **窗口全屏标记。** macOS 全屏隐藏红绿灯,围绕它们构建的布局必须放松。主进程在 `enter-full-screen`、`leave-full-screen` 与每次 `did-finish-load` 时经 `dsh-desktop:window-fullscreen` 转发 `isFullScreen()`;应用 preload 的 `syncWindowFullscreen()` 把它镜像到 `html[data-fullscreen]`。消费者:AppFrame 把 `--dsh-frame-leading-clearance` 降到 84px 并把座移到 `left: 12px`,侧边栏 `.topStrip` 把收起按钮移到条带左端,右侧边栏全屏面板把首格 pane 的 dockkit strip 内边距重置回常规 10px。 -**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此 `-webkit-app-region: drag` 只在两个允许清单样式表内声明:ui-layout `AppFrame.module.css`(darwin 的 `.leadingBand`——52px 全宽、`pointer-events: none`、作为框架第一个子元素渲染的条带——与 Windows 顶栏 `.frame::before`)和 ui-sidebar `SidebarRoot.module.css`(`.logoRow`,把拖拽带向 52px 以下延伸)。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。拖拽带先于全部内容挂载,任何靠后的 no-drag 盒子必然生效;内容容器永不声明 drag——那会覆盖更早挂载的浮层。 +**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此外壳只声明一次 `-webkit-app-region: drag`,作用于 chrome 行打在自身盒子上的标记,行自身的盒子就是它的可拖几何:侧栏顶条与 logo 行(ui-sidebar)、右侧边栏的 dockkit strip(ui-dockkit)、会话 header(ui-conversation)、插件管理的页头与共用的详情页头(ui-plugin-manager)。框架不再声明任何 drag——Windows 标题栏行是那个平台自己的 chrome——因此没有一条固定带子需要匹配某一行的高度。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。这两条规则依赖的顺序约定:覆盖窗口且必须可点的表面 portal 到 `#root` 之外(settings 浮层即如此),从而在文档顺序上晚于所有 drag 行;内容容器永不声明 drag——那会覆盖更早挂载的浮层。清单、浏览器车道与人工状态矩阵归 [覆盖契约 note](../architecture/2026-09-19-window-drag-coverage-contract.zh.md)。 **全屏避开红绿灯。** ui-dockkit 把 tab 条起始内边距发布为 `--dsh-dockkit-strip-inline-start`(回退为设计自身的 10px)。右侧边栏全屏形态在 darwin 上对面板主体设 88px,并对每个非首格 split 单元的子树重置为 10px,使得任意分屏深度下恰好只有触及窗口左上角的 pane 避开红绿灯。 @@ -43,9 +43,9 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig - macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。 - 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。 - 重新打开控件占据框架的 `shell.leading` 座,是客户端 catalog 中的公开 slot;早先的 `conversation.session.header.leading` 座已移除([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md))。 -- 拖拽区归属是窗口级全局不变量:`-webkit-app-region: drag` 只出现在两个允许清单样式表内(ui-layout `AppFrame.module.css`:darwin `.leadingBand` 与 Windows 顶栏 `.frame::before`;ui-sidebar `SidebarRoot.module.css`:`.logoRow`);其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 样式门禁对全部包内样式表强制该允许清单。 +- 拖拽区归属是窗口级全局不变量:`-webkit-app-region: drag` 只出现在 ui-web `base.css`(作用于 `data-window-drag` 的唯一 darwin 规则)与 ui-layout `AppFrame.module.css`(Windows 顶栏 `.frame::before`)内;其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 门禁把每个 chrome 行的 markup 标记与它的样式表、选择器和写死高度配成一条,并拒绝别处的标记。 - 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。 ## Testing -ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。desktop main-startup 测试钉住全屏转发(状态切换、重载、已销毁窗口守卫、仅 darwin 注册),preload-platform 测试钉住 `html[data-fullscreen]` 镜像。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 样式门在全部包内样式表上把拖拽区归属钉在两个允许清单样式表内。 +ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。desktop main-startup 测试钉住全屏转发(状态切换、重载、已销毁窗口守卫、仅 darwin 注册),preload-platform 测试钉住 `html[data-fullscreen]` 镜像。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 门禁钉住唯一那条 darwin drag 规则与每个 chrome 行的 markup 标记。 diff --git a/apps/web/tests/window-drag-coverage.e2e.ts b/apps/web/tests/window-drag-coverage.e2e.ts new file mode 100644 index 0000000000..80df040862 --- /dev/null +++ b/apps/web/tests/window-drag-coverage.e2e.ts @@ -0,0 +1,492 @@ +// Browser coverage for the macOS window drag surface: which chrome rows drag, +// which pixels stay content, and that no control is swallowed by the band. +// +// The lane boots the real composition in Chromium and marks the document root +// darwin — the one platform where these rules exist (ui-web base.css and +// ui-layout AppFrame scope every app-region rule to it). CDP-injected clicks +// cannot decide this class: the swallow happens in Electron's native window, so +// the claims here are geometry claims decided by the shared composition model +// (ui-web window-drag/regions.ts). +// +// Probes are anchored to stable data hooks and derived from live rects, so a +// layout change moves them rather than invalidating them. The ui-theme app-region +// gate holds the other half of the claim: it pins each row's mark, its sheet, and +// its authored height, and refuses a `data-window-drag` mark anywhere else. A +// probe whose expectation is not yet met carries `gap` and fails loudly, so a +// recorded gap shrinks here first and the manifest follows. +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + INTERACTIVE_SELECTOR, RECALL_MARK, isDraggableAt, type RegionRect, +} from '@deepseek-ai/dsh-client-web/src/window-drag/regions.ts' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +/** One collected box: its app-region value, whether it is interactive, and where it is. */ +interface CollectedRegion extends RegionRect { + /** Whether the element itself matches the interactive selector. */ + readonly interactive: boolean + /** Sample points at which the browser's hit test reaches this element or its content. */ + readonly hits: readonly (readonly [number, number])[] +} + +/** A point the coverage table claims something about. */ +interface CoverageProbe { + /** Row the probe speaks for, for the failure message. */ + readonly row: string + /** What the point is, for the failure message. */ + readonly what: string + /** Resolves the point from the live rects captured for the state under test. */ + readonly at: () => readonly [number, number] + /** Whether the window would drag there. */ + readonly drag: boolean + /** Set when the expectation is a recorded gap the refactor must close. */ + readonly gap?: string +} + +/** A viewport rect. */ +interface Rect { + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +const EMPTY: Rect = { x: 0, y: 0, width: 0, height: 0 } + +/** A browser-half bundle whose row registers configuration into the plugin page. */ +const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url)) + +/** Read one hook's viewport rect, or a zero rect while it is not rendered. */ +async function rectOf(page: Page, selector: string): Promise { + const locator = page.locator(selector).first() + if (await locator.count() === 0) return EMPTY + return locator.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }) +} + +/** + * Collect every visible app-region box in DOM order. + * @param page - the page under test. + * @returns the boxes, in document order, with the interactive flag of each element. + */ +async function collectedRegions(page: Page, selector: string): Promise { + return page.evaluate((interactiveSelector) => { + const collected: { + x: number + y: number + width: number + height: number + draggable: boolean + interactive: boolean + hits: [number, number][] + }[] = [] + for (const element of Array.from(document.querySelectorAll('*'))) { + const style = getComputedStyle(element) + const region = style.getPropertyValue('-webkit-app-region') + if (region !== 'drag' && region !== 'no-drag') continue + if (style.visibility === 'hidden' || style.display === 'none') continue + const rect = element.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) continue + const interactive = element.matches(interactiveSelector) + const insetX = Math.min(2, rect.width / 4) + const insetY = Math.min(2, rect.height / 4) + const samples: [number, number][] = [ + [rect.x + rect.width / 2, rect.y + rect.height / 2], + [rect.x + insetX, rect.y + insetY], + [rect.x + rect.width - insetX, rect.y + insetY], + [rect.x + insetX, rect.y + rect.height - insetY], + [rect.x + rect.width - insetX, rect.y + rect.height - insetY], + ] + const hits = interactive + ? samples.filter(([x, y]) => { + const top = document.elementFromPoint(x, y) + return top !== null && (top === element || element.contains(top)) + }) + : [] + collected.push({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + draggable: region === 'drag', + interactive, + hits, + }) + } + return collected + }, selector) +} + +/** + * Interactive boxes whose press cannot reach the page, as printable descriptions. + * A sample counts only where the browser's own hit test reaches the control: a + * box clipped away by its column (the collapsed sidebar's hidden controls sit at + * negative x) is not hittable anywhere it overlaps a drag row, and a press there + * belongs to whatever is painted. + * @param regions - collected boxes in DOM order. + * @returns one description per interactive box with a hittable sample inside the drag surface. + */ +function swallowedBoxes(regions: readonly CollectedRegion[]): string[] { + return regions + .filter(region => region.interactive) + .filter(region => region.hits.some(([x, y]) => isDraggableAt(regions, x, y))) + .map(region => `${Math.round(region.x)},${Math.round(region.y)} ${Math.round(region.width)}x${Math.round(region.height)}`) +} + +/** + * Settle when the shell first pulses the drag recall mark, or false when 15s pass + * without one. Start it before the gesture under test and await it after. + * @param page - the page under test. + * @returns the pending observation of the first pulse. + */ +function firstRecallPulse(page: Page): Promise { + return page.evaluate((mark: string) => new Promise((resolve) => { + const observer = new MutationObserver((records) => { + const pulsed = records.some(record => record.attributeName === mark + && (record.target as Element).hasAttribute(mark)) + if (!pulsed) return + observer.disconnect() + resolve(true) + }) + observer.observe(document.body, { attributes: true, attributeFilter: [mark] }) + setTimeout(() => { + observer.disconnect() + resolve(false) + }, 15_000) + }), RECALL_MARK) +} + +/** + * Wait until nothing under the page is animating, so a probe measures the settled + * layout instead of a frame of a column slide. + * @param page - the page under test. + */ +async function settled(page: Page): Promise { + await expect.poll(() => page.evaluate(() => document.getAnimations() + .every(animation => animation.playState === 'finished' || animation.playState === 'idle')), + { timeout: 20_000 }).toBe(true) +} + +describe('web e2e: macOS window drag coverage', () => { + let scaffold: WebScaffold + let browser: Browser + + beforeAll(async () => { + // A manageable profile: the plugin page draws its card list — and so its + // detail views — only when the deployment runs a profile it can manage. + scaffold = await launchWebScaffold({ + profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] }, + }) + browser = await chromium.launch() + }, 180_000) + + afterAll(async () => { + try { await browser?.close() } + finally { await scaffold?.close() } + }) + + /** + * Open a darwin-marked page in the shipped composition. + * @returns the page and its console tripwire. + */ + async function darwinPage(): Promise<{ page: Page; tripwire: ReturnType }> { + const page = await newEnglishPage(browser) + await page.addInitScript((value: string) => { + const mark = (): void => { document.documentElement.setAttribute('data-platform', value) } + if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true }) + else mark() + }, 'darwin') + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + return { page, tripwire } + } + + it('leaves no interactive box inside the drag surface, and keeps the panel strip row draggable', async () => { + const { page, tripwire } = await darwinPage() + try { + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('connected workspace did not create an Agent') + agent.session.append('turn/start', { turn: 1 }) + const pulsing = firstRecallPulse(page) + await page.locator('[data-sidebar-right-expand]').click() + await page.locator('[data-sidebar-right-panel][data-sidebar-right-open]').waitFor({ timeout: 15_000 }) + // The panel slides in from the frame's right edge; probing before the slide + // settles measures the hidden position, where the band does not reach. + await expect.poll(async () => page.locator('[data-sidebar-right-panel]').evaluate((element) => { + const rect = element.getBoundingClientRect() + return Math.round(innerWidth - rect.right) === 0 + && element.getAnimations({ subtree: true }) + .every(animation => animation.playState === 'finished' || animation.playState === 'idle') + }), { timeout: 15_000 }).toBe(true) + + // The shell's watcher, not the panel, is what keeps the native drag rects in + // step with the slide: it must have pulsed while the panel moved and must be + // settled now — a mark left set would subtract the whole surface. + expect(await pulsing, 'the shell pulses the drag recall mark while the panel slides').toBe(true) + // The watcher clears the mark on the frame after the geometry settles. + await expect.poll( + () => page.evaluate((mark: string) => document.body.hasAttribute(mark), RECALL_MARK), + { timeout: 5_000 }, + ).toBe(false) + + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + const strip = await rectOf(page, '[data-dockkit-strip]') + const panel = await rectOf(page, '[data-sidebar-right-panel]') + const tabs = await rectOf(page, '[data-conversation-tabs]') + const header = await rectOf(page, '[data-slot="conversation.header"] > header') + const seat = await rectOf(page, '[data-shell-leading]') + // The sidebar column drags through its own rows (ui-sidebar), so its + // coverage is the rows' geometry rather than a frame band's height. + const toggle = await rectOf(page, 'button[aria-label="Collapse sidebar"]') + + const probes: CoverageProbe[] = [ + { + row: 'right sidebar panel strip', + what: 'the strip row top inset, above the controls', + at: () => [strip.x + strip.width / 2, strip.y + 4], + drag: true, + }, + { + row: 'right sidebar panel strip', + what: 'the run below the strip row, over the pane body', + at: () => [panel.x + panel.width / 2, strip.y + strip.height + 4], + drag: false, + }, + { + row: 'conversation header', + what: 'the blank run right of the view tabs, on the tab strip row', + at: () => [tabs.x + tabs.width + 8, tabs.y + tabs.height / 2], + drag: true, + }, + { + row: 'conversation header', + what: 'the title row blank run', + at: () => [header.x + header.width / 2, header.y + 4], + drag: true, + }, + { + row: 'conversation header', + what: 'the transcript run below the header block', + at: () => [header.x + header.width / 2, header.y + header.height + 4], + drag: false, + }, + ] + if (toggle.width > 0) { + probes.push( + { + row: 'sidebar top strip', + what: 'the blank run left of the collapse toggle', + at: () => [toggle.x - 40, toggle.y + toggle.height / 2], + drag: true, + }, + { + row: 'sidebar column', + what: 'the content run below the chrome rows', + at: () => [toggle.x - 40, toggle.y + toggle.height + 70], + drag: false, + }, + ) + } + if (seat.width > 0) { + probes.push({ + row: 'shell.leading seat', + what: 'the row of window-chrome controls', + at: () => [seat.x + 4, seat.y + seat.height / 2], + drag: false, + }) + } + + for (const probe of probes) { + const [x, y] = probe.at() + const expected = `${probe.row}: ${probe.what} at ${Math.round(x)},${Math.round(y)}` + + (probe.gap === undefined ? '' : ` (recorded gap — ${probe.gap})`) + expect(isDraggableAt(regions, x, y), expected).toBe(probe.drag) + } + + // The invariant that has no gap budget: a press inside any control box must + // reach the page instead of dragging the window. + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage') + throw error + } finally { + await page.close() + } + }, 180_000) + + it('keeps the Conversation header draggable over the collapsed column, and the seat out of it', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + await page.locator('[data-shell-leading]').waitFor({ state: 'visible', timeout: 15_000 }) + await settled(page) + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + const header = await rectOf(page, '[data-slot="conversation.header"] > header') + const seat = await rectOf(page, '[data-shell-leading]') + + // With the column hidden the centre starts at the frame's left edge, where + // the Conversation header owns the run. + expect( + isDraggableAt(regions, header.x + 40, header.y + 4), + 'the Conversation header over the frame’s left edge', + ).toBe(true) + expect( + isDraggableAt(regions, seat.x + seat.width / 2, seat.y + seat.height / 2), + 'the seat holding the reopen controls', + ).toBe(false) + + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-collapsed') + throw error + } finally { + await page.close() + } + }, 120_000) + + it('keeps an entry page’s chrome row draggable', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + const head = page.locator('[class*="pageHead"]').first() + await head.waitFor({ timeout: 15_000 }) + await settled(page) + const rect = await head.evaluate((element) => { + const box = element.getBoundingClientRect() + return { x: box.x, y: box.y, width: box.width, height: box.height } + }) + + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + // The page head owns its run, clearance included: that is the entry page's + // window strip, with no frame band behind it. + expect( + isDraggableAt(regions, rect.x + 40, rect.y + 8), + 'the plugin manager page head', + ).toBe(true) + expect( + isDraggableAt(regions, rect.x + 40, rect.y + rect.height + 40), + 'the page body below the head', + ).toBe(false) + + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-entry-page') + throw error + } finally { + await page.close() + } + }, 120_000) + + it('keeps a detail page’s head row draggable and its body out of the drag surface', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + const cards = page.locator('[data-plugin-package], [data-plugin-item]') + await cards.first().waitFor({ timeout: 30_000 }) + await settled(page) + // The card's head carries the one control that opens its page. + await cards.first().locator('button').first().click() + // The mark sits on the head row the detail views share, not on the detail + // container: marking the container would drag the window over its text and + // form labels wherever no control covers them. + const detail = page.locator('[data-plugin-item-detail], [data-plugin-detail], [data-plugin-row-detail]').first() + await detail.waitFor({ timeout: 15_000 }) + const geometry = await detail.evaluate((element) => { + const detailBox = element.getBoundingClientRect() + const headBox = element.firstElementChild?.getBoundingClientRect() + return { + detail: { x: detailBox.x, y: detailBox.y, width: detailBox.width, height: detailBox.height }, + head: headBox === undefined + ? null + : { x: headBox.x, y: headBox.y, width: headBox.width, height: headBox.height }, + viewport: { width: innerWidth, height: innerHeight }, + } + }) + if (geometry.head === null) throw new Error('the detail view rendered no head row') + const { detail: detailRect, head: headRect, viewport } = geometry + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + + // The head's top inset is the window clearance above the crumb: blank, so + // it drags like the entry page's head. + expect( + isDraggableAt(regions, headRect.x + headRect.width / 2, headRect.y + 4), + 'the detail head row’s top inset', + ).toBe(true) + + // Below the head, a point that no control covers is content: pressing the + // description, a label, or a gap in the form must reach the page. + const interactive = regions.filter(region => region.interactive) + const bodyTop = headRect.y + headRect.height + const bodyBottom = Math.min(detailRect.y + detailRect.height, viewport.height - 4) + const dragging: string[] = [] + for (let row = 0; row < 8; row += 1) { + for (let column = 0; column < 5; column += 1) { + const x = detailRect.x + (detailRect.width * (column + 0.5)) / 5 + const y = bodyTop + ((bodyBottom - bodyTop) * (row + 0.5)) / 8 + if (y <= bodyTop || y > viewport.height - 4 || x > viewport.width - 4) continue + if (interactive.some(region => x >= region.x && x < region.x + region.width + && y >= region.y && y < region.y + region.height)) continue + if (isDraggableAt(regions, x, y)) dragging.push(`${Math.round(x)},${Math.round(y)}`) + } + } + expect(dragging, 'content points inside the detail body that drag the window').toEqual([]) + + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-detail-page') + throw error + } finally { + await page.close() + } + }, 120_000) + + it('keeps a covering overlay out of the drag surface', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.locator('button[aria-haspopup="dialog"]').first().click() + const dialog = page.locator('[role="dialog"]').first() + await dialog.waitFor({ timeout: 15_000 }) + await settled(page) + const layer = await dialog.evaluate((element) => { + const overlay = element.parentElement + const rect = overlay?.getBoundingClientRect() + return { + parentIsBody: overlay?.parentElement === document.body, + coversWindow: rect !== undefined && rect.top <= 0 && rect.bottom >= innerHeight, + } + }) + // A covering surface inside #root precedes the columns' chrome, so a drag + // row declared later would override its subtraction; beside the root, + // ui-web base.css's body rule subtracts it instead. + expect(layer.parentIsBody, 'the covering overlay portals beside #root').toBe(true) + expect(layer.coversWindow).toBe(true) + + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + const header = await rectOf(page, '[data-slot="conversation.header"] > header') + expect( + isDraggableAt(regions, header.x + header.width / 2, 8), + 'the window top behind an open covering overlay', + ).toBe(false) + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-overlay') + throw error + } finally { + await page.close() + } + }, 120_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4508e03def..67463cc363 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -143,6 +143,7 @@ "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", "tests/composer-tab-geometry.e2e.ts", + "tests/window-drag-coverage.e2e.ts", "tests/complex-history.perf.ts", "tests/pwsh-terminal.e2e.ts", "tests/workflow-run.e2e.ts" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b871029e8d..55087f1601 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 16e821ed4caabae491d552a823bd69a2e976e2bd -README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe +README.md: 2ab123cc67854bbac9631f74517633dcb625384d +README.zh.md: c852cdbad545aee75497bde79a3a6f50c4fc168e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 16e821ed4c..2ab123cc67 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -52,7 +52,7 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. -A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident container reserves a 40px drag band on macOS desktop and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. +A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps a 40px window strip on macOS desktop that it owns as its drag row, and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 7316b00c69..c852cdbad5 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -52,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 -blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻容器在 macOS 桌面保留 40px 拖拽区域,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。 +blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在 macOS 桌面保留 40px 的窗口顶条,由它自己作为拖拽行持有,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx index a8f2e6b122..b3b07c2b6b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx @@ -15,7 +15,7 @@ export function ConversationHeader({ sessionId, useSession, useConversation, ren const blank = session === undefined || conversation === undefined || (session.blank && conversationPhase(session, conversation) === 'blank') return ( -
+
{renderSlot('conversation.header.leading', {})}
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 25530eef99..ed85896510 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -47,13 +47,13 @@ min-height: 0; } -/* macOS hiddenInset titlebar: window dragging comes from ui-layout's - .leadingBand (the frame's first child), never from this header. Electron - composes app-regions in document order ignoring stacking, so a drag rule - here would keep eating clicks through overlays mounted earlier in the DOM - (the settings modal lives in the sidebar column). The title-row clusters - overlap the band and subtract their whole boxes; individual controls are - covered by the base.css interactive-element rule. */ +/* macOS hiddenInset titlebar: the header block owns the window drag over its + own box — title row plus the view tab strip, so the draggable geometry is the + header's geometry and no frame band has to match its height. The header marks + itself `data-window-drag` (ConversationHeader); below it the title-row clusters + subtract their whole boxes, and individual controls are covered by the base.css + interactive-element rule. A covering overlay lives beside #root + (ui-settings-general), which puts it after this row in document order. */ :global([data-platform='darwin']) .headerLeading, :global([data-platform='darwin']) .headerActions, :global([data-platform='darwin']) .headerUtilities, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index d17f52e3f8..f1179cf32b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -75,7 +75,7 @@ export function ConversationSessionHeader({ const last = index === ancestry.length - 1 // The current crumb has no navigation, so it is plain text // rather than a disabled button: on darwin desktop a button - // would subtract itself from the window drag band (ui-web + // would subtract itself from the header's drag row (ui-web // base.css) and leave the title inert for dragging too. const title = last ? ( @@ -138,8 +138,8 @@ export function ConversationSessionHeader({ {showTabs && ( - // data-conversation-tabs: ui-layout's window drag band matches this - // marker (:has) to deepen only while the tab strip adds header height. + // data-conversation-tabs: marks the tab strip, which the window-chrome + // geometry and the browser coverage lane anchor on.
{tabs.map(viewTab => (
- + ) } @@ -1140,7 +1140,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
{showsCards ? ( -
+

{t('title')}

{t('intro')}

diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index b0d0e950b6..b5eaf35c06 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -64,8 +64,10 @@ "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", - "react": "^18.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 8e7569b3ca..333f5fdafe 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -239,8 +239,7 @@ white-space: nowrap; } -/* macOS: overlays covering the window drag bands must subtract themselves - (Electron app-regions are geometry, not stacking). */ -:global([data-platform='darwin']) .overlay { - -webkit-app-region: no-drag; -} +/* No app-region rule here: the overlay portals beside #root (SettingsRoot), so + ui-web base.css's `body > :not(#root)` subtraction covers it wherever it + overlaps a chrome row's drag — including above every column, which an in-root + overlay could not promise once a chrome row declares drag itself. */ diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index 6e82913513..90a1eceb66 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -11,6 +11,7 @@ * to the step, so a mounted-but-deciding step paints nothing here. */ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { ConnectionIndicator, @@ -67,7 +68,11 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP const closeButton = useRef(null) useEffect(() => { closeButton.current?.focus() }, []) - return ( + // Portalled beside #root like the Modal primitive: a covering surface mounted + // inside the root would precede the columns' chrome in document order, so a + // chrome row that declares window drag after it would override its subtraction. + // Beside the root, base.css's `body > :not(#root)` rule subtracts it instead. + return createPortal((
- ) + ), document.body) } /** diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css index 890b897cc8..eaee49200a 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css @@ -46,19 +46,6 @@ transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -/* One-frame app-region pulse at each open/close edge (RightbarSeat): changing - this computed value is what makes Electron recollect the window's drag - rects, which the slide's transform/visibility flips alone never dirty - (electron#32341). The panel's steady state sets no app-region in either - presentation, so this write is a real change at both edges — including a - hidden panel opening straight into fullscreen (a narrow window's first - open), whose rects were skipped while hidden. While set, the whole panel is - out of the drag band for that frame; the removal frame recollects the - steady-state geometry below. */ -:global(html[data-platform='darwin']) .panel[data-sidebar-right-region-nudge] { - -webkit-app-region: no-drag; -} - .panel[data-sidebar-right-panel='fullscreen'] { position: fixed; inset: 0; @@ -66,16 +53,11 @@ border: none; } -/* No whole-box subtraction here: Electron drag regions are window geometry, - not stacking, so the frame's leading band does keep acting through this - fixed overlay — but the kit already subtracts every box that must stay - usable (tabs, strip-end chrome, pane bodies, dividers; ui-dockkit), and - ui-web base.css subtracts interactive elements and portalled overlays. - What remains of the band under the strips' blank runs is the window drag - affordance, the same native-tab-bar reading as the push presentation. A - panel-wide no-drag would trade it away and, worse, make no-drag the - steady state, turning the open/close pulse above into a computed-value - no-op exactly when a hidden panel opens straight into fullscreen. */ +/* No whole-box subtraction here: the strip rows inside mark themselves + `data-window-drag`, and ui-web base.css subtracts interactive elements and + portalled overlays, so a strip's blank runs stay draggable through this + fixed overlay while its tabs and strip-end chrome keep their clicks. A + panel-wide no-drag would trade that drag affordance away. */ /* Windows keeps caption controls above fullscreen file and terminal panels. */ :global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] { diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 6b388b219f..b4853ca237 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -375,36 +375,9 @@ export function RightbarSeat({ if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false) }, [actions, sessionId, shown, fullscreen, canShow]) - // Electron rebuilds the window's -webkit-app-region rects only when a style - // or layout pass dirties an app-region value (electron#32341), and Blink - // skips hidden boxes when it collects them. The open/close slide flips only - // transform and visibility — neither dirties the rects — so the strip's - // no-drag boxes (ui-dockkit) are missing right after the first open, leaving - // the tabs under the window drag band (ui-layout .leadingBand) swallowed by - // it, and they would linger after a close. Pulsing an app-region rule - // (SidebarRight.module.css) on the panel forces a recollection at both edges - // of the gesture: once at the flip, once after the slide settles and the - // stylesheet's delayed visibility flip (0.3s) has landed. - useEffect(() => { - if (document.documentElement.dataset.platform !== 'darwin') return - const panel = panelRef.current - if (panel === null) return - let raf: number | null = null - const nudge = (): void => { - panel.setAttribute('data-sidebar-right-region-nudge', '') - raf = requestAnimationFrame(() => { - raf = null - panel.removeAttribute('data-sidebar-right-region-nudge') - }) - } - nudge() - const settle = setTimeout(nudge, 400) - return () => { - clearTimeout(settle) - if (raf !== null) cancelAnimationFrame(raf) - panel.removeAttribute('data-sidebar-right-region-nudge') - } - }, [shown]) + // The open/close slide needs no pulse of its own: the shell's window drag + // watcher (ui-web) measures the marked rows every frame the surface moves and + // sets the recall mark itself (electron#32341). // Fullscreen leaves the previous column report in force until its own slide // completes. Normal presentation and zero-duration transitions report before paint. diff --git a/packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts b/packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts deleted file mode 100644 index e095849e1d..0000000000 --- a/packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * App-region nudge pulse, asserted against SidebarRight.module.css on disk: - * Electron recollects the window's drag rects only when a style pass changes - * a computed app-region value (electron#32341), so the panel's steady state - * must declare NO app-region in either presentation — the open/close no-drag - * pulse is then a real computed-value change at both edges, including a - * hidden panel opening straight into fullscreen (a narrow window's first - * open, whose rects were skipped while hidden). A steady-state value on the - * panel (the old fullscreen whole-box no-drag) makes the pulse a silent - * no-op and leaves the frame's drag band swallowing the strip's top-right - * controls; fine-grained subtraction belongs to ui-dockkit (tabs, strip-end - * chrome, pane bodies, dividers), leaving the strips' blank runs draggable. - */ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const css = readFileSync(new URL('../src/client/shell/SidebarRight.module.css', import.meta.url), 'utf8') - -/** - * Every ` -> value` pair of rules declaring `-webkit-app-region`. - * @returns one entry per declaring rule, in source order. - */ -function appRegionRules(): [selector: string, value: string][] { - const rules: [string, string][] = [] - for (const match of css.matchAll(/(?[^{}]+)\{(?[^{}]*)\}/g)) { - const value = /-webkit-app-region:\s*([^;]+);/.exec(match.groups!['body']!)?.[1] - if (value !== undefined) rules.push([match.groups!['sel']!.trim(), value.trim()]) - } - return rules -} - -describe('app-region nudge pulse styles', () => { - it('pulses the panel to no-drag', () => { - const pulses = appRegionRules() - .filter(([selector]) => selector.includes('[data-sidebar-right-region-nudge]')) - expect(pulses).toHaveLength(1) - expect(pulses[0]![0]).toMatch(/\.panel\[data-sidebar-right-region-nudge\]\s*$/) - expect(pulses[0]![1]).toBe('no-drag') - }) - - it('declares no steady-state app-region, so the pulse is a real change', () => { - const steady = appRegionRules() - .filter(([selector]) => !selector.includes('[data-sidebar-right-region-nudge]')) - expect(steady).toEqual([]) - }) -}) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 417e3742ca..b1b8866bf9 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -268,47 +268,6 @@ describe('RightbarSeat presentation', () => { expect(h.frame.closeRightbar).toHaveBeenCalled() }) - it('skips the nudge while the darwin seat has no surface to render', async () => { - // The seat's first render returns null (the open effect has not created - // the surface yet), so the nudge effect fires with an unattached panel ref. - document.documentElement.dataset.platform = 'darwin' - try { - const h = await mountSeat() - const panel = element(h.view.container, '[data-sidebar-right-panel]') - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(false) - } finally { - delete document.documentElement.dataset.platform - } - }) - - it('pulses the app-region nudge at each open and close edge on macOS only', async () => { - const h = await mountSeat() - const panel = element(h.view.container, '[data-sidebar-right-panel]') - const marks = vi.spyOn(panel, 'setAttribute') - const nudges = () => marks.mock.calls.filter(([name]) => name === 'data-sidebar-right-region-nudge').length - // Web and Windows compose no app-regions: the slide needs no nudge. - act(() => { h.controller.toggleExpanded() }) - act(() => { h.controller.toggleExpanded() }) - expect(nudges()).toBe(0) - document.documentElement.dataset.platform = 'darwin' - try { - act(() => { h.controller.toggleExpanded() }) - expect(nudges()).toBe(1) - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(true) - // A frame later the mark lifts (the pulse itself is the recollection - // trigger), and the settle pulse re-marks after the 0.3s slide. - await act(async () => { await new Promise(resolve => setTimeout(resolve, 450)) }) - expect(nudges()).toBe(2) - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(false) - // The close edge pulses again so the hidden panel's stale rects drop. - act(() => { h.controller.toggleExpanded() }) - expect(nudges()).toBe(3) - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(true) - } finally { - delete document.documentElement.dataset.platform - } - }) - it('fills the viewport without replacing the content tree or releasing the wide track', async () => { const h = await mountSeat() const tab = h.open() diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 72b5b20666..7d64e76cb3 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/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: 94bb3a70ca8c06b5838f41436bb0f2ba2751501e -README.zh.md: 86f54882ba1c96b9b50898993931757f7afef4e5 +README.md: 9ee8d9eb95d0991f1cdfcee3b5beee4d249d3f50 +README.zh.md: c858362b4b8a8c5e41b337253454d41001f926fa diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 94bb3a70ca..9ee8d9eb95 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -45,7 +45,7 @@ On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in t ### 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 and carries the collapse toggle; the frame's own drag band (ui-layout `.leadingBand`) covers that strip, and the logo row below extends the drag surface, so the brand wordmark is not a New Session shortcut there — the dedicated New Session button keeps the action — and collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the frame's `shell.leading` window-chrome seat (ui-layout), which mounts it — the open-sidebar and New Session controls beside the traffic lights — only while the column is hidden, over every main panel. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md). +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 and carries the collapse toggle; the strip and the logo row below each mark themselves `data-window-drag`, so each row's own box is the window's drag region (ui-web base.css declares the one darwin drag rule), and the brand wordmark is not a New Session shortcut there — the dedicated New Session button keeps the action — and collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the frame's `shell.leading` window-chrome seat (ui-layout), which mounts it — the open-sidebar and New Session controls beside the traffic lights — only while the column is hidden, over every main panel. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md). ### Scrollbars diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 86f54882ba..c858362b4b 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -45,7 +45,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 ### macOS 桌面 -在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯并承载收起按钮;框架自身的拖拽带(ui-layout `.leadingBand`)覆盖这条顶部条,其下的 logo 行延伸这块拖拽面,因此品牌 wordmark 在 macOS 上不再是 New Session 快捷入口——专用的 New Session 按钮保留该操作;收起时整列隐藏而非保留轨道。本包向框架的 `shell.leading` 窗口 chrome 座(ui-layout)注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,由框架仅在列隐藏时挂载于红绿灯旁,覆盖所有主面板。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。 +在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯并承载收起按钮;顶部条与其下的 logo 行各自打上 `data-window-drag`,于是每行自己的盒子就是窗口的拖拽区(唯一的 darwin drag 规则由 ui-web base.css 声明),因此品牌 wordmark 在 macOS 上不再是 New Session 快捷入口——专用的 New Session 按钮保留该操作;收起时整列隐藏而非保留轨道。本包向框架的 `shell.leading` 窗口 chrome 座(ui-layout)注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,由框架仅在列隐藏时挂载于红绿灯旁,覆盖所有主面板。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。 ### 滚动条 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 144070ba3f..972be46d74 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -176,8 +176,9 @@ /* macOS hiddenInset titlebar: the strip spans the column's top edge (negative margins cancel the root padding) so it clears the traffic lights at - (16, 18). Window dragging over this run comes from ui-layout's .leadingBand - (same 52px geometry); the toggle subtracts via the base.css rule. */ + (16, 18). The strip marks itself `data-window-drag` in markup (SidebarRoot), so + its own run is the column's draggable geometry and no frame band has to match + the strip's height; the toggle inside subtracts via the base.css rule. */ .topStrip { flex: none; display: flex; @@ -207,7 +208,15 @@ /* Logo row (figma pad (4,8,4,8)): expanded keeps the panel toggle at the right edge; the rail gives every control the same base left anchor before - the shared entry translation is applied. */ + the shared entry translation is applied. macOS hiddenInset titlebar: the row + is marked `data-window-drag` in markup (SidebarRoot), so ui-web base.css's + single darwin rule carries the drag over its own box — below the top strip + (40–100px: the strip's 52px foot with the row's -12px tuck). The brand rides + that box (SidebarRoot renders the wordmark as a plain span there, so the + global interactive no-drag rule never subtracts it); the dedicated New + Session button below keeps the pointer and keyboard paths. Safe as a drag + source: it precedes the sidebar's overlay slots in document order, so their + no-drag still wins. */ .logoRow { flex: none; display: flex; @@ -228,16 +237,6 @@ justify-content: flex-start; } -/* macOS hiddenInset titlebar: the logo row extends the frame band's drag - below 52px, and the brand rides it — SidebarRoot renders the wordmark as a - plain span there, so the global interactive no-drag rule never subtracts - it; the dedicated New Session button below keeps the pointer and keyboard - paths. Safe as a drag source: it precedes the sidebar's overlay slots in - document order, so their no-drag still wins. */ -:global([data-platform='darwin']) .logoRow { - -webkit-app-region: drag; -} - /* Brand group (figma I133:7632): the full wordmark rides the text ink (figma flows: the main-screen instance is black; blue is brand emphasis only). A button only in behavior (New Session shortcut): the diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 12bcbe8ca8..90a978f03b 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -208,8 +208,8 @@ export function SidebarRoot({ > {/* macOS hiddenInset titlebar: the strip shares the row with the traffic lights and keeps the toggle at the sidebar's top-right. */} - {darwinDesktop &&
{toggle}
} -
+ {darwinDesktop &&
{toggle}
} +
{/* Expanded, the brand doubles as a New Session shortcut — except on macOS, where it stays part of the logo row's window-drag surface (a button would subtract itself through the global no-drag rule); diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index d3f6bbfc56..a467c3dbba 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -11,6 +11,7 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded >