From 8019d37d3fec649cae7edba652c10d63275a69b0 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 11:08:13 +0800 Subject: [PATCH 01/59] feat(client): frame-owned macOS window chrome with single drag owner One 52px leadingBand in the AppFrame (frame's first child) plus the sidebar logoRow are the only -webkit-app-region drag surfaces; everything interactive subtracts itself through two global base.css rules (interactive elements and body > :not(#root) overlay portals), so header controls, breadcrumb chips, and strip tabs stay clickable under the band. A ui-theme stylesheet gate pins the two-file drag allowlist. The frame publishes --dsh-frame-top-clearance (48px) for entry pages (plugin manager list and detail) beside the existing --dsh-frame-leading-clearance, replacing per-page hardcoded insets. The shell.leading window-chrome seat moves the collapsed-sidebar reopen controls from the conversation header to the frame. Sidebar vibrancy tint drops the blue cast in dark mode and halves its fill opacity, light mode softens the blue mix, and the logo row tightens 4px above New Session. --- ...shell-leading-window-chrome-seat.i18n.yaml | 6 + ...-frame-shell-leading-window-chrome-seat.md | 38 ++++++ ...ame-shell-leading-window-chrome-seat.zh.md | 38 ++++++ ...3-macos-hidden-titlebar-vibrancy.i18n.yaml | 4 +- ...26-09-13-macos-hidden-titlebar-vibrancy.md | 10 +- ...09-13-macos-hidden-titlebar-vibrancy.zh.md | 10 +- apps/desktop/src/ipc.ts | 1 + apps/desktop/src/main.ts | 12 ++ apps/desktop/src/preload-app.ts | 3 +- apps/desktop/src/preload-platform.ts | 18 +++ apps/desktop/src/preload.ts | 3 +- docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 2 +- docs/subsystems/slots.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 1 - .../src/client/contract/slots.ts | 19 --- .../skeleton/ConversationRoot.module.css | 36 ++---- .../client/skeleton/ConversationSession.tsx | 3 - .../tests/skeleton.client.spec.tsx | 1 - packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 6 +- packages/client/ui-layout/README.zh.md | 6 +- .../ui-layout/src/client/AppFrame.module.css | 122 +++++++++++++++++- .../client/ui-layout/src/client/AppFrame.tsx | 69 +++++++++- packages/client/ui-layout/src/client/index.ts | 21 ++- .../ui-layout/tests/app-frame.client.spec.tsx | 87 ++++++++++++- .../ui-layout/tests/apply.client.spec.ts | 4 +- .../src/client/PluginManagerPage.module.css | 13 ++ packages/client/ui-primitives/src/Tooltip.tsx | 22 +++- .../tests/tooltip.client.spec.tsx | 26 +++- .../src/client/SettingsRoot.module.css | 6 + .../src/client/shell/SidebarRight.module.css | 18 +-- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- packages/client/ui-sidebar/package.json | 2 - .../client/HeaderLeadingControls.module.css | 30 +---- .../src/client/HeaderLeadingControls.tsx | 26 ++-- .../src/client/SidebarRoot.module.css | 44 +++++-- .../client/ui-sidebar/src/client/index.ts | 12 +- .../ui-sidebar/tests/apply.client.spec.tsx | 8 +- .../tests/sidebar-root.client.spec.tsx | 12 +- packages/client/ui-sidebar/tsconfig.json | 3 - .../tests/app-region-styles.client.spec.ts | 53 ++++++++ packages/client/web/src/base.css | 27 ++++ .../src/client/slot-catalog.ts | 100 +++++++------- pnpm-lock.yaml | 13 +- 47 files changed, 702 insertions(+), 251 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md create mode 100644 .agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md create mode 100644 packages/client/ui-theme/tests/app-region-styles.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.i18n.yaml new file mode 100644 index 0000000000..f8433a2aed --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.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-17-frame-shell-leading-window-chrome-seat.md +2026-09-17-frame-shell-leading-window-chrome-seat.md: d5724cbc65326f8d43a3154854cf2e028f758fd2 +2026-09-17-frame-shell-leading-window-chrome-seat.zh.md: 3b58bc3cfd0974fcb83b2d183db47c0f8f72d152 diff --git a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md new file mode 100644 index 0000000000..d5724cbc65 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md @@ -0,0 +1,38 @@ +# Agent Note: Frame-owned shell.leading window-chrome seat + +Status: implemented + +English | [中文](2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) + +## Problem + +The [macOS hidden-titlebar work](../feature/2026-09-13-macos-hidden-titlebar-vibrancy.md) hid the collapsed sidebar column entirely on darwin and moved the reopen and New Session controls into a conversation-header slot (`conversation.session.header.leading`), shown via CSS while `data-sidebar-collapsed` was published. Only the Conversation had that seat: with any other main panel selected (the plugin manager, or any future global panel), a collapsed window kept the floating traffic lights over the panel's content with no reopen control anywhere on screen. Each new panel would have had to grow its own leading seat and repeat the same clearance geometry. + +## Decision + +The window chrome belongs to the frame, not to one panel. ui-layout declares a fifth root-scoped child slot, `shell.leading` (single), and AppFrame renders its seat as a frame-level box over the columns — mounted only while `sidebarCollapsed && collapsedWidth === 0`, the darwin desktop full-hide state, the one state that leaves window chrome without a home. The seat sits at the frame's top-left (left 88px clears the hiddenInset traffic lights, top 11px centers the 28px controls on the conversation title row, z-index 15 above column content and below frame overlays) and carries `-webkit-app-region: no-drag`, subtracting itself from any drag band beneath it. + +Under the same collapsed condition the frame publishes `--dsh-frame-leading-clearance: 160px` — the inline band the traffic lights plus the seat's two controls occupy, measured from the frame's left edge. Main panels whose content reaches the top-left corner pad by it and subtract what they already inset: the conversation title row pads `max(0px, clearance - 20px)` (its header pads 20px), and the plugin manager's page head pads `max(0px, clearance - clamp(24px, 4vw, 48px) - max(0px, (100% - 960px) / 2))`, so its centred column only indents when a narrow window would actually collide. + +ui-sidebar registers `HeaderLeadingControls` into `shell.leading` instead of the conversation seat, reusing the shell's inject face and locale; the component renders unconditionally because the frame owns the mount condition, and its platform check and CSS collapse-gating are deleted. The conversation slot `conversation.session.header.leading` is removed outright — declaration, owner-props interface, children entry, header render seat, and CSS — per the pre-stable API rule (every consumer updated, no compatibility shim), which also drops ui-sidebar's dependency on ui-conversation. + +## Alternatives considered + +**Keeping the conversation seat and adding one per panel.** Every main panel would repeat the seat markup, the drag-region subtraction, and the traffic-light geometry, and a panel that forgot would strand a collapsed window without a reopen control. One frame-owned seat makes the guarantee structural. + +**Mounting the occupant always and gating visibility in CSS (the prior mechanism).** This kept dead DOM and a no-drag subtraction in the header on every platform and every collapse state, and it could not help non-conversation panels at all. A frame-level TSX mount keeps the condition, the seat geometry, and the clearance publication in one file. + +**Gating the seat on rightbar fullscreen as well.** Unnecessary: the fullscreen right panel (z-index 40) covers the seat (15) visually, and it already opts its whole box out of window dragging, so the seat's geometry cannot puncture it. + +**Publishing clearance as a fixed padding each consumer hardcodes.** A raw `160px` in each panel would silently desynchronize from the seat's real band; one custom property published under exactly the mount condition keeps consumers correct when the geometry changes. + +## Consequences + +- Every current and future main panel keeps the reopen and New Session controls while the sidebar is hidden, at zero per-panel cost; panels whose content reaches the top-left corner opt into `--dsh-frame-leading-clearance`. +- `conversation.session.header.leading` no longer exists in the client catalog; `shell.leading` replaces it as a public root-scoped seat any package may occupy. +- ui-sidebar no longer depends on ui-conversation (inject list, devDependency, and tsconfig reference removed). +- The seat is darwin-reachable only in the shipped composition (only the darwin collapse hides the column), but the mount condition is platform-agnostic: a platform that later hides its column gets the seat for free. + +## Testing + +The ui-layout app-frame spec pins the seat's mount and unmount across the darwin collapse and its absence beside the 56px rail; the apply spec pins the five-slot declaration and teardown. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on dispose; the sidebar-root spec pins the two controls' actions. diff --git a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md new file mode 100644 index 0000000000..3b58bc3cfd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 框架持有的 shell.leading 窗口 chrome 座 + +Status: implemented + +[English](2026-09-17-frame-shell-leading-window-chrome-seat.md) | 中文 + +## Problem + +[macOS 隐藏标题栏工作](../feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)在 darwin 上把收起的侧边栏整列隐藏,并把重新打开与 New Session 控件移入会话头部的 slot(`conversation.session.header.leading`),在 `data-sidebar-collapsed` 发布期间由 CSS 显示。但只有会话界面有这个座:选中任何其他主面板(插件管理器或未来的全局面板)时,收起的窗口只剩悬浮的红绿灯压在面板内容上,屏幕上没有任何重新打开控件。每个新面板都得自建 leading 座并重复同一套避让几何。 + +## Decision + +窗口 chrome 属于框架,不属于某个面板。ui-layout 声明第五个 root 作用域子 slot `shell.leading`(single),AppFrame 把它的座渲染为覆盖各列的框架级盒子——仅在 `sidebarCollapsed && collapsedWidth === 0`(darwin 桌面全隐藏,唯一让窗口 chrome 无处安放的状态)时挂载。座位于框架左上角(left 88px 避开 hiddenInset 红绿灯,top 11px 让 28px 控件对齐会话标题行中线,z-index 15 高于列内容、低于框架浮层),并带 `-webkit-app-region: no-drag`,把自己从下方任何拖拽带中减去。 + +在同一收起条件下框架发布 `--dsh-frame-leading-clearance: 160px`——红绿灯加座内两个控件占据的行内带宽,自框架左边缘起量。内容抵达左上角的主面板以它做内边距并减去自身已有的缩进:会话标题行 pad `max(0px, clearance - 20px)`(其头部已 pad 20px);插件管理器页头 pad `max(0px, clearance - clamp(24px, 4vw, 48px) - max(0px, (100% - 960px) / 2))`,居中列只在窗口窄到真正碰撞时才缩进。 + +ui-sidebar 把 `HeaderLeadingControls` 改注册到 `shell.leading` 而非会话座,复用 shell 的 inject face 与 locale;因挂载条件归框架所有,组件无条件渲染,其平台判断与 CSS 收起门控被删除。会话 slot `conversation.session.header.leading` 按 pre-stable API 规则整体移除——声明、owner props 接口、children 条目、头部渲染座与 CSS(更新所有消费者,不留兼容垫层),同时解除 ui-sidebar 对 ui-conversation 的依赖。 + +## Alternatives considered + +**保留会话座并为每个面板各加一个。** 每个主面板都要重复座标记、拖拽区减除与红绿灯几何,漏掉的面板会让收起的窗口失去重新打开控件。框架持有的单一座使该保证成为结构性的。 + +**始终挂载占用方、由 CSS 控制可见性(先前机制)。** 它在所有平台与所有收起状态下都保留死 DOM 和头部的 no-drag 减除,且完全帮不到非会话面板。框架级 TSX 挂载把条件、座几何与避让量发布集中在一个文件。 + +**同时以右栏全屏为座的门控条件。** 不必要:全屏右面板(z-index 40)在视觉上盖住座(15),且它已让整个盒子退出窗口拖拽,座的几何无法穿透它。 + +**把避让量作为各消费者硬编码的固定内边距。** 各面板写死 `160px` 会与座的真实带宽悄然失同步;在恰为挂载条件下发布一个自定义属性,几何变化时消费者仍保持正确。 + +## Consequences + +- 侧边栏隐藏时,现有与未来的每个主面板都免费获得重新打开与 New Session 控件;内容抵达左上角的面板自行接入 `--dsh-frame-leading-clearance`。 +- `conversation.session.header.leading` 从客户端目录中消失;`shell.leading` 取而代之,成为任何包都可占据的公开 root 作用域座。 +- ui-sidebar 不再依赖 ui-conversation(inject 列表、devDependency 与 tsconfig 引用均已移除)。 +- 在当前组合中该座仅 darwin 可达(只有 darwin 收起会隐藏整列),但挂载条件与平台无关:未来任何隐藏整列的平台都自动获得该座。 + +## Testing + +ui-layout 的 app-frame spec 钉住 darwin 收起下座的挂载/卸载及 56px 控制栏旁座的缺席;apply spec 钉住五 slot 声明与拆除。ui-sidebar 的 apply spec 钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 dispose 时的移除;sidebar-root spec 钉住两个控件的动作。 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 d01b653e42..afce7053cd 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: b6b2f249a1843f937cf73d4eb193d47e1b9e8ad5 -2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 8d4a4d17bda2e8c7845b66443cc883c6177b7343 +2026-09-13-macos-hidden-titlebar-vibrancy.md: 8a3918886d6ed88b0febe33b5ad8e40e541dbc12 +2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 9cf8fa38cbfe43bc97c2f57e426ec39e9fc07324 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 b6b2f249a1..8a3918886d 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,9 +18,9 @@ Every web-side adjustment keys off `html[data-platform]`, which only the desktop **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, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). 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 move into the conversation header: a new single, session-scoped slot `conversation.session.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The blank-session header keeps the leading seat mounted on darwin so a hidden sidebar always has a reopen control on screen. +**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). 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). -**Drag regions.** The conversation title row is a drag region on darwin with every interactive descendant opted out. Electron computes drag regions from window geometry in DOM order, not stacking: an overlay covering the title band must subtract itself or the band underneath keeps taking the pointer. The right sidebar's fullscreen panel therefore sets `-webkit-app-region: no-drag` on its whole box and restores drag on its tab strips' blank runs. +**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so `-webkit-app-region: drag` is declared in exactly two places, both early chrome bands: the AppFrame's `.leadingBand` (a 52px full-width, `pointer-events: none` strip rendered as the frame's first child) and the sidebar's `.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. **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. @@ -40,10 +40,10 @@ Every web-side adjustment keys off `html[data-platform]`, which only the desktop - 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. -- `conversation.session.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches. -- Drag-region geometry is a window-global invariant: any future overlay that covers the title band on darwin must subtract itself with `-webkit-app-region: no-drag` or its controls become unclickable. +- 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 in the two chrome bands (AppFrame `.leadingBand`, SidebarRoot `.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. - 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 leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call in the active-phase header. The ui-theme corner-shape and full-round style gates cover the new 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 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 chrome bands across every package stylesheet. 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 8d4a4d17bd..9cf8fa38cb 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,9 +18,9 @@ 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 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:新增 single、session 作用域的 slot `conversation.session.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上空白会话的头部保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。 +**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`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 上会话标题行是拖拽区,所有可交互后代退出。Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠:覆盖标题带的浮层必须自行减除,否则下层区域仍会截获指针。因此右侧边栏的全屏面板对整个盒子设 `-webkit-app-region: no-drag`,再在其 tab 条空白处恢复拖拽。 +**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此 `-webkit-app-region: drag` 只在两处声明,且都是靠前挂载的 chrome 带:AppFrame 的 `.leadingBand`(52px 全宽、`pointer-events: none`、作为框架第一个子元素渲染的条带)和侧边栏的 `.logoRow`(把拖拽带向 52px 以下延伸)。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。拖拽带先于全部内容挂载,任何靠后的 no-drag 盒子必然生效;内容容器永不声明 drag——那会覆盖更早挂载的浮层。 **全屏避开红绿灯。** ui-dockkit 把 tab 条起始内边距发布为 `--dsh-dockkit-strip-inline-start`(回退为设计自身的 10px)。右侧边栏全屏形态在 darwin 上对面板主体设 88px,并对每个非首格 split 单元的子树重置为 10px,使得任意分屏深度下恰好只有触及窗口左上角的 pane 避开红绿灯。 @@ -40,10 +40,10 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig - macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。 - 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。 -- `conversation.session.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。 -- 拖拽区几何是窗口级全局不变量:今后任何在 darwin 上覆盖标题带的浮层必须用 `-webkit-app-region: no-drag` 自行减除,否则其控件不可点击。 +- 重新打开控件占据框架的 `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` 只出现在两条 chrome 带(AppFrame `.leadingBand`、SidebarRoot `.logoRow`);其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 样式门禁对全部包内样式表强制该允许清单。 - 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。 ## Testing -ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 active 阶段头部对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。 +ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 样式门在全部包内样式表上把拖拽区归属钉在两条 chrome 带。 diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 9c7329d0d4..86d759d154 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -19,6 +19,7 @@ export const DESKTOP_IPC = { updatesInstall: 'dsh-desktop:updates-install', updatesState: 'dsh-desktop:updates-state', nativeThemeSet: 'dsh-desktop:native-theme-set', + windowFullscreen: 'dsh-desktop:window-fullscreen', } as const /** Desktop release update state rendered by desktop-owned UI. */ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4eb65c0521..9abe2c1619 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -128,6 +128,18 @@ function createWindow(preload: string, show = false): BrowserWindow { if (['http:', 'https:'].includes(new URL(url).protocol)) void shell.openExternal(url) return { action: 'deny' } }) + if (process.platform === 'darwin') { + // macOS hides the traffic lights in fullscreen; the page drops its + // clearance for them off the html[data-fullscreen] flag this feeds. + const sendFullscreen = (): void => { + if (!window.isDestroyed()) window.webContents.send(DESKTOP_IPC.windowFullscreen, window.isFullScreen()) + } + window.on('enter-full-screen', sendFullscreen) + window.on('leave-full-screen', sendFullscreen) + // Reloads and navigations re-register the preload listener; resend the + // current state so a fullscreen reload does not fall back to windowed CSS. + window.webContents.on('did-finish-load', sendFullscreen) + } window.webContents.on('context-menu', (_event, { isEditable, selectionText, editFlags }) => { const items: MenuItemConstructorOptions[] = [] if (isEditable) { diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 2ab066cc81..839a62a299 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -2,7 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { DESKTOP_IPC, SCHEME } from './ipc.ts' -import { markDocumentPlatform } from './preload-platform.ts' +import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { @@ -16,5 +16,6 @@ if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { } markDocumentPlatform() +syncWindowFullscreen() syncNativeTheme() contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 }) diff --git a/apps/desktop/src/preload-platform.ts b/apps/desktop/src/preload-platform.ts index 9fe13c4b2e..0ce5c82ef8 100644 --- a/apps/desktop/src/preload-platform.ts +++ b/apps/desktop/src/preload-platform.ts @@ -1,5 +1,8 @@ /** Marks the document root with the host platform so shared Web UI CSS can scope desktop-only rules. */ +import { ipcRenderer } from 'electron' +import { DESKTOP_IPC } from './ipc.ts' + /** * Sets `data-platform` (e.g. `darwin`) on ``, deferring to DOMContentLoaded * when the preload runs before the document root exists. @@ -12,3 +15,18 @@ export function markDocumentPlatform(): void { if (root === null) window.addEventListener('DOMContentLoaded', mark) else mark() } + +/** + * Mirrors the window's macOS fullscreen state onto `` so + * CSS drops the traffic-light clearance while the lights are hidden. The main + * process sends the state on every transition and after each load. + */ +export function syncWindowFullscreen(): void { + if (process.platform !== 'darwin') return + ipcRenderer.on(DESKTOP_IPC.windowFullscreen, (_event, fullscreen: boolean) => { + const root = document.documentElement as HTMLElement | null + if (root === null) return + if (fullscreen) root.dataset.fullscreen = 'true' + else delete root.dataset.fullscreen + }) +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c8e1eb1452..d6d98601d6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -2,7 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts' -import { markDocumentPlatform } from './preload-platform.ts' +import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts' const api: DshDesktopApi = { protocolVersion: 1, @@ -26,4 +26,5 @@ const api: DshDesktopApi = { } markDocumentPlatform() +syncWindowFullscreen() contextBridge.exposeInMainWorld('dshDesktop', api) diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index d25c088405..54286ec8dd 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md -slots.md: 177bbc5b8f56b7276f271a2ddb7ac71ac8888363 -slots.zh.md: c746ac88704c2e22e5c50ef82a893fde9f091668 +slots.md: effa3df2f7511674a4c693c2a3d053f80200ebb4 +slots.zh.md: 2493c160e9be1958367f794779f496312d0cad13 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 177bbc5b8f..effa3df2f7 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -146,7 +146,6 @@ root │ │ └─ conversation.trajectory.images │ ├─ conversation.session.header │ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.leading │ │ ├─ conversation.session.header.actions │ │ ├─ conversation.session.header.utilities │ │ └─ conversation.session.header.corner @@ -173,6 +172,7 @@ root │ │ └─ sidebar.right.tab.guide.entry │ ├─ sidebar.right.pane.tab.title │ └─ sidebar.right.tab.menu.item +├─ shell.leading └─ shell.overlay ``` diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index c746ac8870..2493c160e9 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -146,7 +146,6 @@ root │ │ └─ conversation.trajectory.images │ ├─ conversation.session.header │ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.leading │ │ ├─ conversation.session.header.actions │ │ ├─ conversation.session.header.utilities │ │ └─ conversation.session.header.corner @@ -173,6 +172,7 @@ root │ │ └─ sidebar.right.tab.guide.entry │ ├─ sidebar.right.pane.tab.title │ └─ sidebar.right.tab.menu.item +├─ shell.leading └─ shell.overlay ``` diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 1402639dac..2a4dbb46c0 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -296,7 +296,6 @@ export function apply(ctx: Context, config: Config = Config({})): void { locale: NS, children: { 'conversation.session.header.lineage': { kind: 'single', scope: 'session' }, - 'conversation.session.header.leading': { kind: 'single', scope: 'session' }, 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, 'conversation.session.header.utilities': { kind: 'list', scope: 'session' }, 'conversation.session.header.corner': { kind: 'single', scope: 'session' }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index cccd6a0fe6..5c8c08df48 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -140,18 +140,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { scope: 'session' owner: ConversationHeaderActionOwnerProps } - /** - * Leading seat before the Session breadcrumbs, for window-chrome-adjacent - * controls (macOS desktop sidebar reopen and New Session while the sidebar - * is hidden). The seat is laid out only while its occupant renders - * something, and it stays mounted through the blank-session state so a - * hidden sidebar always keeps a reopen control on screen. - */ - 'conversation.session.header.leading': { - kind: 'single' - scope: 'session' - owner: ConversationHeaderLeadingOwnerProps - } /** * The header's far-right corner, past the utilities' edge and into the * header's own padding, for one control. The corner is laid out only while @@ -241,12 +229,6 @@ export interface ConversationHeaderCornerOwnerProps { children?: never } -/** The leading seat's occupant derives its state from standard Session props. */ -export interface ConversationHeaderLeadingOwnerProps { - /** Marker field: the occupant receives no owner-specific values. */ - children?: never -} - /** Plain breadcrumb data handed to the optional lineage renderer. */ export interface ConversationHeaderLineageOwnerProps { /** Session represented by this breadcrumb title. */ @@ -411,7 +393,6 @@ export type ConversationSessionHeaderSlotProps = PropsRuntime<'conversation.session.header'> & PropsRenderSlots< 'conversation.session.header.lineage' - | 'conversation.session.header.leading' | 'conversation.session.header.actions' | 'conversation.session.header.utilities' | 'conversation.session.header.corner' 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 a57c5462d3..9757f84e6a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -56,15 +56,13 @@ margin-left: auto; } -/* macOS hiddenInset titlebar: the title row doubles as the window drag - region; interactive children opt out. */ -:global([data-platform='darwin']) .titleRow { - -webkit-app-region: drag; -} - -:global([data-platform='darwin']) .titleRow button, -:global([data-platform='darwin']) .titleRow a, -:global([data-platform='darwin']) .headerLeading, +/* 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. */ :global([data-platform='darwin']) .headerActions, :global([data-platform='darwin']) .headerUtilities, :global([data-platform='darwin']) .headerCorner { @@ -76,6 +74,11 @@ align-items: center; gap: 0; min-height: 30px; + /* Clears the frame's window-chrome band (traffic lights + shell.leading + controls, ui-layout AppFrame) when it is published; the header already + pads 20px of it. Zero everywhere else (the fallback makes the max + negative). */ + padding-inline-start: max(0px, calc(var(--dsh-frame-leading-clearance, 0px) - 20px)); } .titleCluster { @@ -86,21 +89,6 @@ min-width: 0; } -/* Leading seat before the breadcrumbs; laid out only while its occupant - renders something, and the occupant owns its own trailing gap (a CSS-hidden - occupant keeps the seat non-empty, so a seat-owned margin would push the - breadcrumbs while nothing shows). */ -.headerLeading { - display: flex; - flex: none; - align-items: center; - gap: 8px; -} - -.headerLeading:empty { - display: none; -} - .crumbs { display: flex; align-items: center; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 8f025cf548..d151914031 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -70,9 +70,6 @@ export function ConversationSessionHeader({ return (
-
- {renderSlot('conversation.session.header.leading', {})} -
{!hideChrome && ( <>
diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 71f238c4d9..b8443cf11f 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -450,7 +450,6 @@ describe('ConversationRoot resident composer', () => { expect(host?.contains(seat)).toBe(true) expect(seat?.contains(textarea)).toBe(true) expect(b.slotCalls).toContain('conversation.session.header.lineage') - expect(b.slotCalls).toContain('conversation.session.header.leading') expect(b.slotCalls).toContain('conversation.session.header.actions') expect(b.slotCalls).toContain('conversation.session.header.utilities') expect(b.slotCalls).toContain('conversation.session.header.corner') diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 1ec1c269dd..300804b5ba 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/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: c539e62406d3a060aa6b0ad9fca89eb6a1b5f77a +README.zh.md: c0f8e135d4d0fe303d2990687edbfaeb76b9da3e diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index f150416b7f..c539e62406 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -29,6 +29,10 @@ 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. +### Window-chrome seat + +On macOS desktop (`html[data-platform='darwin']`, set only by the desktop preload) a collapsed sidebar hides its column entirely instead of keeping the rail, and the frame mounts the single root-scoped `shell.leading` seat at its top-left — beside the hiddenInset traffic lights, over every main panel; ui-sidebar occupies it with the reopen and New Session controls. While the seat is mounted the frame publishes `--dsh-frame-leading-clearance`, the inline band the window chrome occupies measured from the frame's left edge; a main panel whose content reaches the top-left corner pads by it so nothing lands under the lights or the controls. The frame also always publishes `--dsh-frame-top-clearance` (48px), the constant step below the window's top strip; entry pages in the main panel (plugin manager and similar, not the conversation) pad their top by it. + ### 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 `` whose content follows the computed body background. Disposing the presenter removes its metadata node with its other global writes. @@ -43,7 +47,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum `selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts. -One registration declares four child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. +One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 05e49b5547..c0f8e135d4 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -29,6 +29,10 @@ kind: "package-reference" 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。 +### 窗口 chrome 座 + +在 macOS 桌面(`html[data-platform='darwin']`,仅由桌面 preload 设置)下,收起的侧边栏整列隐藏而非保留控制栏,框架在左上角挂载 root 作用域的单一 `shell.leading` 座——位于 hiddenInset 红绿灯旁,覆盖所有主面板;ui-sidebar 以重新打开与 New Session 控件占据该座。座挂载期间框架发布 `--dsh-frame-leading-clearance`:窗口 chrome 占据的行内带宽,自框架左边缘起量;内容抵达左上角的主面板以它做内边距,避免落在红绿灯或控件之下。框架还始终发布 `--dsh-frame-top-clearance`(48px):窗口顶带之下的固定下沉量;主面板中的入口型页面(插件管理器等类似页面,非对话)以它做顶部内边距。 + ### 主题呈现 呈现器消费解析后的主题快照,并投影到 document:`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,把主题的别名 token 与 `--dsh-content-font-size` 设为 body 上的内联变量,并持有一个 ``,其内容随计算后的 body 背景色更新。对呈现器执行 dispose(资源释放)时,它会连同其他全局写入一起移除自己的元数据节点。 @@ -43,7 +47,7 @@ kind: "package-reference" `selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费方在提交导航或搬移草稿前检查 signal。 -一次注册声明四个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 +一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index 9321c4ed9c..f965220b58 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -5,8 +5,14 @@ height: 100%; overflow: hidden; background: var(--dsw-alias-bg-base); - /* Collapse/expand animates the tracks on the deepsuite sider curve - (--ds-ease-in-out / --ds-transition-duration-slow, ui-theme base.css). */ +} + +/* Only a discrete collapse/expand toggle eases the tracks (the TSX holds + data-animating across the toggle on the deepsuite sider curve). Steady-state + tracks move only with the viewport, and those updates must land instantly: + an eased track chases the live window edge and the centre column visibly + rubber-bands while the user resizes the window. */ +.frame[data-animating] { transition: grid-template-columns var(--ds-transition-duration-slow) var(--ds-ease-in-out); } @@ -17,7 +23,7 @@ } @media (prefers-reduced-motion: reduce) { - .frame { + .frame[data-animating] { transition: none; } } @@ -43,24 +49,122 @@ with the wallpaper and reads as a dark seam. */ :global([data-platform='darwin']) .frame { background: transparent; + /* The constant step below the window's top strip (traffic lights, sidebar + toggle, shell.leading controls), the same in both sidebar states. Entry + pages in the main panel (plugin manager, settings-like pages — not the + conversation, which owns its header) pad their top by it so their leading + content clears the strip. */ + --dsh-frame-top-clearance: 48px; } :global([data-platform='darwin']) .sidebarCol { - background: color-mix(in srgb, var(--dsw-specific-sidebar-fill) 60%, transparent); + /* A blue wash over the top of the translucent tint, gone by a third of the + column; a faint blue-grey-violet rises through the bottom third; and a + faint blue cast is mixed into the tint itself. Every layer stays + transparent so vibrancy shows. */ + background: + linear-gradient( + to bottom, + rgb(122 155 240 / 0.1), + rgb(122 155 240 / 0) 35%, + rgb(143 137 184 / 0) 68%, + rgb(143 137 184 / 0.09) + ), + color-mix(in srgb, color-mix(in srgb, var(--dsw-specific-sidebar-fill) 97%, rgb(122 155 240)) 40%, transparent); border-right: none; } +/* Dark drops the blue cast (the light-mode wash reads purple over dark fills) + and thins the fill so more wallpaper shows; the washes stay. */ +:global([data-platform='darwin']) :global([data-ds-dark-theme]) .sidebarCol { + background: + linear-gradient( + to bottom, + rgb(122 155 240 / 0.08), + rgb(122 155 240 / 0) 35%, + rgb(143 137 184 / 0) 68%, + rgb(143 137 184 / 0.07) + ), + color-mix(in srgb, var(--dsw-specific-sidebar-fill) 50%, transparent); +} + :global([data-platform='darwin']) .centerCol { background: var(--dsw-alias-bg-base); border-left: 0.5px solid var(--dsw-alias-border-l3); } +/* The right track paints the base ground on the transparent frame: while the + track and the occupant's translateX slide ride the same curve, the grid + eases in layout and the transform on the compositor, so frames where they + diverge would otherwise show vibrancy glass between the centre's right edge + and the incoming panel. The occupant's opaque panel covers this at rest. */ +:global([data-platform='darwin']) .rightbarCol { + background: var(--dsw-alias-bg-base); +} + /* The darwin closed sidebar has no rail: the centre reaches the window edge, where a separator would draw a seam against nothing. */ :global([data-platform='darwin']) [data-sidebar-collapsed] .centerCol { border-left: none; } +/* Published while the shell.leading seat is mounted (same condition as the + TSX mount: only the darwin collapse hides the column entirely): the inline + band the window chrome occupies, measured from the frame's left edge — + traffic lights to x 68, controls at x 88 (28 + 8 + 28), then 8px of air. + Main panels whose content reaches the top-left corner pad by this. */ +:global([data-platform='darwin']) .frame[data-sidebar-collapsed] { + --dsh-frame-leading-clearance: 160px; +} + +/* Fullscreen hides the traffic lights (preload mirrors the window state onto + html[data-fullscreen]): the seat takes their band and the clearance keeps + only the controls (12 + 28 + 8 + 28 + 8). The 12px start matches the + expanded sidebar strip's inline padding (ui-sidebar .topStrip), so + collapsing never shifts the toggle. */ +:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-collapsed] { + --dsh-frame-leading-clearance: 84px; +} + +:global([data-platform='darwin'][data-fullscreen]) .leadingSeat { + left: 12px; +} + +/* The full-width window drag band (macOS desktop, always mounted): the + window's top strip stays draggable over every panel in both sidebar states. + Pointer events pass through (Electron app-regions are geometry, not hit + testing), and it renders as the frame's first child — before all column + content and overlays — so app-regions composing in document order let every + later no-drag (base.css's interactive-element rule, overlay boxes) subtract + from this band. That ordering is the drag mechanism's single invariant: + drag regions live only on early chrome bands like this one, never on + content containers, which would override earlier-mounted overlays. 52px + matches the sidebar top strip's height. */ +.leadingBand { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 52px; + pointer-events: none; + -webkit-app-region: drag; +} + +/* The seat itself: the two 28px controls centered on the conversation title + row (10px header inset + 30px row → centre y 25) and level with the traffic + lights (centre y ≈ 24). Sits above column content, below frame overlays + (20) and the fullscreen right panel (40). The box subtracts itself from any + window drag band beneath (Electron app-regions are geometry, not stacking). */ +.leadingSeat { + position: absolute; + top: 11px; + left: 88px; + z-index: 15; + display: flex; + align-items: center; + -webkit-app-region: no-drag; +} + /* Drag handles are frame children (columns clip overflow): an 8px hit strip centered on the column border via inline left, above column content. No handle draws a visible pill: the details column was the only one that did, @@ -76,8 +180,12 @@ cursor: col-resize; z-index: 11; touch-action: none; - /* Rides the same curve as the tracks so the pill stays on the moving - border during collapse/expand; paused while dragging (frame rule). */ +} + +/* Rides the same curve as the tracks so the pill stays on the moving border + during collapse/expand; instant at steady state (viewport-driven moves) + and while dragging. */ +.frame[data-animating] .handle { transition: left var(--ds-transition-duration-slow) var(--ds-ease-in-out); } @@ -94,7 +202,7 @@ } @media (prefers-reduced-motion: reduce) { - .handle { + .frame[data-animating] .handle { transition: none; } } diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 894fd15c7e..6939e63490 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -19,7 +19,7 @@ import type { ReactNode } from 'react' import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' -import { computeColumns, RIGHTBAR_DEFAULT_RATIO, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT } from './columns.ts' +import { CENTER_MIN, clampWidth, computeColumns, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT } from './columns.ts' import { DocumentTitle } from './DocumentTitle.tsx' import type { createLayoutStore } from './stores.ts' import css from './AppFrame.module.css' @@ -27,7 +27,7 @@ import css from './AppFrame.module.css' /** Full composed props: runtime share + child-slot render share + store share. */ export type AppFrameProps = & PropsRuntime<'root'> - & PropsRenderSlots<'sidebar' | 'main' | 'rightbar' | 'shell.overlay'> + & PropsRenderSlots<'sidebar' | 'main' | 'rightbar' | 'shell.overlay' | 'shell.leading'> & PropsStore> & PropsLocale<'common'> @@ -166,7 +166,8 @@ export function AppFrame({ // 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 + const darwin = document.documentElement.dataset.platform === 'darwin' + const collapsedWidth = darwin ? 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) @@ -184,6 +185,42 @@ export function AppFrame({ // Track-level transitions pause for the whole gesture: eased tracks would // detach the column edge from the pointer (AppFrame.module.css). const [dragging, setDragging] = useState(false) + // Track easing is scoped to a discrete open/close toggle: data-animating + // goes up when the collapse state or the rightbar track flips and comes down + // at transition end (timeout as the reduced-motion/covered-frame fallback). + // Steady-state viewport updates stay instant (AppFrame.module.css), and so + // does a toggle arriving together with a viewport change — that is the + // responsive auto-collapse firing mid window-resize, where easing would + // chase the live window edge. The counter restarts the settle window when a + // re-toggle interrupts a running transition. + const [animating, setAnimating] = useState(0) + const trackToggle = `${sidebarCollapsed}:${layoutInfo.rightbarTrack}` + const previousToggle = useRef(trackToggle) + const previousViewport = useRef(viewport) + useLayoutEffect(() => { + const viewportChanged = previousViewport.current !== viewport + previousViewport.current = viewport + if (previousToggle.current === trackToggle) return + previousToggle.current = trackToggle + if (viewportChanged) return + setAnimating(token => token + 1) + }, [trackToggle, viewport]) + useEffect(() => { + if (animating === 0) return + const frame = frameRef.current + /* v8 ignore next -- the ref is always attached by effect time: the frame div renders unconditionally. */ + if (frame === null) return + const settle = () => { setAnimating(0) } + const onTransitionEnd = (event: TransitionEvent) => { + if (event.target === frame && event.propertyName === 'grid-template-columns') settle() + } + frame.addEventListener('transitionend', onTransitionEnd) + const timer = setTimeout(settle, 600) + return () => { + frame.removeEventListener('transitionend', onTransitionEnd) + clearTimeout(timer) + } + }, [animating]) const onDragEnd = useCallback(() => { setDragging(false) }, []) const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar; setDragging(true) }, []) const onSidebarDrag = useCallback((dx: number) => { @@ -194,6 +231,14 @@ export function AppFrame({ actions.setRightbar(rightbarBase.current - dx) }, [actions]) const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild') + // The rendered template lets the grid solve the squeeze natively: the centre + // declares its protected minimum and the right column bids up to the clamped + // preference, so a window resize lands in the same layout pass as the frame + // edge. The JS solve lags the viewport by a ResizeObserver + rAF frame; when + // it priced the squeeze itself, the centre column absorbed each width change + // whole and was corrected two frames later — visible jitter. cols keeps only + // the discrete decisions (track present, collapse state) and the drag base. + const rightbarMax = cols.rightbar === 0 ? 0 : clampWidth(rightbarPreference, RIGHTBAR_MIN, viewport * RIGHTBAR_MAX_RATIO) const sidebar = useMemo(() => renderSlot('sidebar', { collapsed: sidebarCollapsed, width: cols.sidebar, @@ -202,6 +247,12 @@ export function AppFrame({ ), [usePanelInfo, renderSlot]) const overlays = useMemo(() => renderSlot('shell.overlay', {}), [renderSlot]) + // Window-chrome seat over the main panels' top-left corner: only a fully + // hidden sidebar column (macOS desktop collapse) leaves window chrome + // without a home. AppFrame.module.css publishes the matching + // --dsh-frame-leading-clearance under the same collapsed condition. + const leading = useMemo(() => renderSlot('shell.leading', {}), [renderSlot]) + const leadingMounted = sidebarCollapsed && collapsedWidth === 0 return (
0 || undefined} > + {/* First child: app-regions compose in document order, so everything + mounted later (chrome controls, overlays) subtracts its no-drag + from this band. */} + {darwin &&
} {overlays}
+ {leadingMounted && ( +
+ {leading} +
+ )} {/* The collapsed rail is fixed-width: no resize handle while closed. */} {!sidebarCollapsed && } {layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && ( diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index ffbbd68bb9..0ed3494fdc 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -1,7 +1,7 @@ /** * Layout plugin, browser half: one register() call contributes AppFrame into * the runtime's built-in 'root' slot and, in the same breath, declares the - * four child slots (declaration = exclusive render authority), seats the + * five child slots (declaration = exclusive render authority), seats the * layout store (panel geometry), and wires the panel-action service face. * ctx.layout selects the main panel and controls column geometry; Session * selection belongs to the Session Controller. A second effect seats the theme @@ -45,7 +45,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { // The 'root' entry itself is the runtime's built-in slot (declared - // there); these four are the frame's children, declared by the same + // there); these five are the frame's children, declared by the same // register() call that contributes AppFrame. Session owners never pass // sessionId: the framework injects it as a standard prop. /** @@ -89,6 +89,20 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * `id` is added beside the shipped entries instead of replacing them. */ 'shell.overlay': { kind: 'list'; scope: 'root' } + /** + * Window-chrome seat at the frame's top-left, over every main panel. + * Mounted only while the sidebar column is fully hidden (macOS desktop + * collapse; other platforms keep the rail), so the occupant can assume the + * frame edge is the window edge and the macOS traffic lights sit before it. + * OCCUPIED by ui-sidebar's reopen/New Session controls. + * + * While the seat is mounted the frame publishes + * `--dsh-frame-leading-clearance` (the inline inset the seat's band + * occupies, measured from the frame's left edge); a main panel whose + * content reaches the top-left corner pads by it so nothing lands under + * the lights or the controls. + */ + 'shell.leading': { kind: 'single'; scope: 'root' } } } @@ -124,7 +138,7 @@ export const inject = ['slots', 'theme', 'locale'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame - * into 'root' with the four child-slot declarations, the layout store seat, + * into 'root' with the five child-slot declarations, the layout store seat, * and the shared root instance supplying commands and the panel-info source. * @param ctx - client root context. */ @@ -153,6 +167,7 @@ export function apply(ctx: ClientContext): void { 'main': { kind: 'keyed', scope: 'root' }, 'rightbar': { kind: 'single', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' }, + 'shell.leading': { kind: 'single', scope: 'root' }, }, store, }, AppFrame) diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index 082dba8dcd..9f7289c669 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -113,10 +113,15 @@ function mountFrame(windowWidth = frameWidth) { } } +/* The template delegates the squeeze to the grid (jsdom does no layout, so + specs read the specified tracks): [sidebar px, rightbar growth limit]. + The centre's protected minimum must accompany an open right track. */ function tracks(frame: HTMLElement): number[] { - const match = /^([\d.]+)px minmax\(0, 1fr\) ([\d.]+)px$/.exec(frame.style.gridTemplateColumns) + const match = /^([\d.]+)px minmax\((0|400)px, 1fr\) minmax\(0px, ([\d.]+)px\)$/.exec(frame.style.gridTemplateColumns) if (match === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`) - return [Number(match[1]), Number(match[2])] + const rightbar = Number(match[3]) + if ((match[2] === '400') !== (rightbar > 0)) throw new Error(`centre minimum out of step: ${frame.style.gridTemplateColumns}`) + return [Number(match[1]), rightbar] } function handleFor(frame: HTMLElement, side: 'sidebar' | 'rightbar'): HTMLElement { @@ -169,6 +174,7 @@ afterEach(() => { try { cleanup() } finally { + delete document.documentElement.dataset.platform for (const restore of restoreProperties.splice(0).reverse()) restore() document.title = originalTitle vi.restoreAllMocks() @@ -221,12 +227,31 @@ describe('AppFrame', () => { }) it('keeps the closed sidebar mounted at its 56px rail without a handle', () => { - const { frame, instance, sidebarOwner, getByTestId } = mountFrame() + const { frame, instance, sidebarOwner, getByTestId, queryByTestId } = mountFrame() act(() => { instance.actions.toggleSidebar() }) expect(tracks(frame)).toEqual([56, 0]) expect(sidebarOwner()).toEqual({ collapsed: true, width: 56 }) expect(getByTestId('sidebar-content')).toBeTruthy() expect(frame.querySelector('[data-side="sidebar"]')).toBeNull() + // The rail keeps the window chrome housed: no shell.leading seat. + expect(queryByTestId('shell.leading-content')).toBeNull() + expect(frame.querySelector('[data-shell-leading-band]')).toBeNull() + }) + + it('mounts the shell.leading seat only while the darwin collapse hides the column', () => { + document.documentElement.dataset.platform = 'darwin' + const { frame, instance, sidebarOwner, queryByTestId } = mountFrame() + // The window drag band composes app-regions in DOM order: it must render + // before all column content so every later no-drag subtracts from it. + expect(frame.firstElementChild?.hasAttribute('data-shell-leading-band')).toBe(true) + expect(queryByTestId('shell.leading-content')).toBeNull() + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)).toEqual([0, 0]) + expect(sidebarOwner()).toEqual({ collapsed: true, width: 0 }) + expect(frame.querySelector('[data-shell-leading]')).not.toBeNull() + expect(queryByTestId('shell.leading-content')).toBeTruthy() + act(() => { instance.actions.toggleSidebar() }) + expect(queryByTestId('shell.leading-content')).toBeNull() }) it('switches only the keyed main outlet when the active panel changes', () => { @@ -264,10 +289,12 @@ describe('AppFrame normal width concessions', () => { const { frame, instance, rightOwner } = mountFrame() act(() => { instance.actions.setSidebar(420); instance.actions.openRightbar(true, false) }) resize(1200) - expect(tracks(frame)).toEqual([420, 380]) + // The template carries the ratio-clamped preference; the panel (rightOwner + // width) reports the resolved squeeze. + expect(tracks(frame)).toEqual([420, 840]) expect(rightOwner()).toEqual({ width: 380, viewportWidth: 1200, canShow: true }) resize(1120) - expect(tracks(frame)).toEqual([420, 300]) + expect(tracks(frame)).toEqual([420, 784]) resize(1119) expect(tracks(frame)).toEqual([420, 0]) expect(rightOwner()).toEqual({ width: 0, viewportWidth: 1119, canShow: false }) @@ -287,7 +314,7 @@ describe('AppFrame normal width concessions', () => { expect(tracks(frame)).toEqual([280, 0]) expect(rightOwner()).toEqual({ width: 344, viewportWidth: 800, canShow: true }) act(() => { instance.actions.openRightbar(true, false) }) - expect(tracks(frame)).toEqual([56, 344]) + expect(tracks(frame)).toEqual([56, 360]) expect(instance.getSnapshot().layoutInfo).toMatchObject({ narrowExpanded: false, rightbar: 360 }) expect(rightOwner().canShow).toBe(true) }) @@ -374,6 +401,52 @@ describe('AppFrame right panel presentation', () => { expect(frame.dataset.rightbarFullscreen).toBeUndefined() }) + it('eases tracks only across a discrete toggle, never for viewport updates', () => { + const { frame, instance } = mountFrame() + expect(frame.dataset.animating).toBeUndefined() + // Window-driven track updates follow the frame edge instantly. + resize(1600) + expect(frame.dataset.animating).toBeUndefined() + act(() => { instance.actions.toggleSidebar() }) + expect(frame.dataset.animating).toBe('true') + // Foreign transition ends (e.g. the handle's left) do not settle it... + act(() => { + frame.dispatchEvent(Object.assign(new Event('transitionend'), { propertyName: 'left' })) + }) + expect(frame.dataset.animating).toBe('true') + // ...the track transition's own end does. + act(() => { + frame.dispatchEvent(Object.assign(new Event('transitionend'), { propertyName: 'grid-template-columns' })) + }) + expect(frame.dataset.animating).toBeUndefined() + }) + + it('lands the responsive auto-collapse instantly, keeping user toggles eased', () => { + const { frame, instance } = mountFrame() + // Shrinking across the breakpoint flips the collapse in the same update as + // the viewport change: no easing, the tracks land with the window edge. + resize(900) + expect(frame.dataset.sidebarCollapsed).toBe('true') + expect(frame.dataset.animating).toBeUndefined() + // A user toggle at the now-stable viewport still eases. + act(() => { instance.actions.toggleSidebar() }) + expect(frame.dataset.animating).toBe('true') + }) + + it('animates the rightbar track flip and settles by timeout without a transition end', () => { + vi.useFakeTimers() + try { + const { frame, instance } = mountFrame() + act(() => { instance.actions.openRightbar(true, false) }) + expect(frame.dataset.animating).toBe('true') + // Covered or reduced-motion frames fire no transitionend; the timeout settles. + act(() => { vi.advanceTimersByTime(600) }) + expect(frame.dataset.animating).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + it('keeps fullscreen suppression independent from resetting the instant marker', () => { const { frame, instance } = mountFrame() act(() => { instance.actions.openRightbar(true, true) }) @@ -464,7 +537,7 @@ describe('AppFrame pointer resizing', () => { resize(1100) const handle = handleFor(frame, 'rightbar') expect(rightOwner().width).toBe(420) - expect(tracks(frame)[1]).toBe(420) + expect(tracks(frame)[1]).toBe(770) expect(handle.style.left).toBe('680px') drag(handle, 680, 690) expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410) diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index 74c58022c9..4c3c37adad 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -79,7 +79,7 @@ describe('ui-layout client apply', () => { expect(inject).toEqual(['slots', 'theme', 'locale']) }) - it('provides ctx.layout and declares the four root-scoped frame slots', async () => { + it('provides ctx.layout and declares the five root-scoped frame slots', async () => { const { ctx, slots } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -89,6 +89,7 @@ describe('ui-layout client apply', () => { expect(slots.spec('main')).toEqual({ kind: 'keyed', scope: 'root' }) expect(slots.spec('rightbar')).toEqual({ kind: 'single', scope: 'root' }) expect(slots.spec('shell.overlay')).toEqual({ kind: 'list', scope: 'root' }) + expect(slots.spec('shell.leading')).toEqual({ kind: 'single', scope: 'root' }) }) it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => { @@ -159,6 +160,7 @@ describe('ui-layout client apply', () => { expect(slots.spec('main')).toBeUndefined() expect(slots.spec('rightbar')).toBeUndefined() expect(slots.spec('shell.overlay')).toBeUndefined() + expect(slots.spec('shell.leading')).toBeUndefined() expect(host.root.getSnapshot().hooks.panelInfo).toBeUndefined() // The built-in root declaration survives entry teardown (renderer-owned). expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' }) diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 2971a42af9..160a641728 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -22,6 +22,13 @@ align-items: flex-start; justify-content: space-between; gap: 16px; + box-sizing: border-box; +} + +/* macOS hiddenInset titlebar: the frame's top clearance keeps the head below + the window's top strip in both sidebar states so it never jumps. */ +:global([data-platform='darwin']) .pageHead { + padding-top: var(--dsh-frame-top-clearance, 0px); } .pageTitle { @@ -864,6 +871,12 @@ flex-direction: column; } +/* Same top clearance as .pageHead: the crumb is the detail views' leading + content and otherwise lands under the traffic lights. */ +:global([data-platform='darwin']) .detail { + padding-top: var(--dsh-frame-top-clearance, 0px); +} + .crumb { display: inline-flex; align-items: center; diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index a1893a1d39..09821ccf09 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -2,7 +2,7 @@ // bubble escape ancestor overflow clipping without a portal. import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' +import type { FocusEventHandler, MouseEventHandler, MutableRefObject, PointerEventHandler, ReactElement, Ref } from 'react' import css from './Tooltip.module.css' /** Bubble placement relative to the anchor. */ @@ -15,6 +15,7 @@ interface AnchorProps { onMouseLeave?: MouseEventHandler | undefined onFocus?: FocusEventHandler | undefined onBlur?: FocusEventHandler | undefined + onPointerDown?: PointerEventHandler | undefined } type TooltipLabel = string | (() => string) @@ -92,6 +93,11 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). const triggers = useRef({ hover: false, focus: false }) + // Only keyboard focus is a trigger. A click also focuses the anchor, and the + // anchor keeps that focus when a later state change moves or hides it — no + // blur ever fires, which would pin a stale bubble (e.g. the sidebar toggle + // after collapsing the sidebar). + const pointerFocus = useRef(false) // Disabling mid-hover (e.g. clicking a rail control expands the sidebar) // must drop an already-visible bubble: no mouseleave fires. @@ -142,8 +148,18 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, ref: mergedRef, onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() }, onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) }, - onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() }, - onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, + onPointerDown: (e) => { children.props.onPointerDown?.(e); pointerFocus.current = true }, + onFocus: (e) => { + children.props.onFocus?.(e) + if (pointerFocus.current) { + pointerFocus.current = false + return + } + triggers.current.focus = true + cancelShow() + show() + }, + onBlur: (e) => { children.props.onBlur?.(e); pointerFocus.current = false; triggers.current.focus = false; hide() }, })} {pos !== null && ( { const onMouseLeave = vi.fn() const onFocus = vi.fn() const onBlur = vi.fn() + const onPointerDown = vi.fn() render( - + , ) const anchor = screen.getByText('anchor') fireEvent.mouseEnter(anchor) fireEvent.mouseLeave(anchor) + fireEvent.pointerDown(anchor) fireEvent.focus(anchor) fireEvent.blur(anchor) expect(onMouseEnter).toHaveBeenCalledOnce() expect(onMouseLeave).toHaveBeenCalledOnce() expect(onFocus).toHaveBeenCalledOnce() expect(onBlur).toHaveBeenCalledOnce() + expect(onPointerDown).toHaveBeenCalledOnce() + }) + + it('pointer-initiated focus never pins the bubble', () => { + render( + + + , + ) + const anchor = screen.getByText('anchor') + // A click focuses the anchor; hiding the control later fires no blur, so + // click focus must not become a trigger. Hover still shows and clears. + fireEvent.mouseEnter(anchor) + fireEvent.pointerDown(anchor) + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip')).toBeTruthy() + fireEvent.mouseLeave(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + // The click consumed the pointer flag: a later keyboard focus shows again. + fireEvent.blur(anchor) + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip')).toBeTruthy() }) it('suppresses the bubble while disabled without remounting the anchor', () => { 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 6608e0f9ef..31260c5060 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -233,3 +233,9 @@ clip: rect(0 0 0 0); 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; +} 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 269a9ca6cc..d14284f987 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 @@ -51,12 +51,6 @@ inset: 0; z-index: 40; border: none; - /* Electron drag regions are window geometry, not stacking: the conversation - title row's and the sidebar top strip's drag bands underneath keep taking - the pointer through this overlay, making the first strip's tabs unclickable. - The panel appears after both in DOM order, so this subtracts its whole box; - the strip rule below restores dragging on the strip's blank run. */ - -webkit-app-region: no-drag; } /* macOS fullscreen: the panel reaches the window's top-left corner, where the @@ -74,14 +68,10 @@ --dsh-dockkit-strip-inline-start: 10px; } -/* Fullscreen keeps a window drag affordance on the tab strips' blank runs; - tabs and strip controls opt back out. */ -:global([data-platform='darwin']) .panel[data-sidebar-right-panel='fullscreen'] :global([data-dockkit-strip]) { - -webkit-app-region: drag; -} - -:global([data-platform='darwin']) .panel[data-sidebar-right-panel='fullscreen'] :global([data-dockkit-strip]) > * { - -webkit-app-region: no-drag; +/* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop + preload); the first pane's strip keeps only the ordinary inset. */ +:global([data-platform='darwin'][data-fullscreen]) .panel[data-sidebar-right-panel='fullscreen'] .panelBody { + --dsh-dockkit-strip-inline-start: 10px; } @media (prefers-reduced-motion: reduce) { diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 45249bfbad..ae4decdab9 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: 783ef070442922ec4c0b56143b1131a4f381aa11 -README.zh.md: 4bd5e822b41a1b9a305a4605cfa6e0529962d232 +README.md: 9659873dbb93a4bcf3bd01dabb03eac3fb38424b +README.zh.md: 8863f8c3b0b3003c2d50d5e761f3fdbc16ad484e diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 783ef07044..9659873dbb 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -41,7 +41,7 @@ During a live collapse, the expanded content fades out at its current width, the ### 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). +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 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 4bd5e822b4..8863f8c3b0 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -41,7 +41,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 ### 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)。 +在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯、承载收起按钮,并作为窗口拖拽区;收起时整列隐藏而非保留轨道。本包向框架的 `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/package.json b/packages/client/ui-sidebar/package.json index b62b246018..812bbb8c6d 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -30,7 +30,6 @@ "inject": [ "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-ui-renderer", - "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-workspace", @@ -52,7 +51,6 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", diff --git a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.module.css b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.module.css index ffbb01efc7..a8bced60bd 100644 --- a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.module.css +++ b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.module.css @@ -1,25 +1,12 @@ -/* Rendered only on macOS desktop (the component returns null elsewhere) and - shown only while the sidebar column is fully hidden: expanded, the sidebar - holds its own toggle/New Session and the traffic lights sit over the - column. */ +/* The frame's leadingSeat (ui-layout AppFrame) owns placement — position, + traffic-light clearance, no-drag; this box only lays out the two controls. + Same 8px rhythm as the conversation header's trailing controls + (ConversationRoot .headerUtilities/.headerCorner), so both ends of the + title row read as one control set. */ .controls { - display: none; -} - -/* Left padding clears the hiddenInset traffic lights: they span x 16..~68 in - window coordinates and the header already pads 20, so 68 lands the first - control at x 88 — a full control-width of air after the lights. Trailing - margin restores the leading seat's 8px gap to the breadcrumbs (the seat - itself carries none, so the empty expanded state adds no width). */ -:global([data-sidebar-collapsed]) .controls { display: flex; align-items: center; - /* Same 8px rhythm as the header's trailing controls (ConversationRoot - .headerUtilities/.headerCorner), so both ends of the title row read as one - control set. */ gap: 8px; - padding-left: 68px; - margin-right: 8px; } .iconButton { @@ -41,10 +28,3 @@ .iconButton:hover { background: var(--dsw-alias-interactive-bg-hover); } - -/* Glyphs match the right-side header controls (ui-sidebar-right ExpandButton): - a 15px icon in the 28px circle. */ -.iconButton svg { - width: 15px; - height: 15px; -} diff --git a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx index 77a98caa63..97c0f6603d 100644 --- a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx +++ b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx @@ -1,31 +1,29 @@ -/** macOS-desktop conversation-header controls for the fully hidden sidebar. */ +/** Window-chrome controls for the fully hidden sidebar (frame shell.leading seat). */ import { - IconNewChatOutline16, IconPanelLeftOutline16, isDarwinDesktop, Tooltip, + IconNewChatOutline16, IconPanelLeftOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: pulls the conversation header slot declarations. -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +// Type-only: pulls the frame's shell.leading slot declaration. +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { SidebarRootInjected } from './contract/slots.ts' import css from './HeaderLeadingControls.module.css' -/** Full props of the conversation-header leading occupant. */ +/** Full props of the shell.leading occupant. */ export type HeaderLeadingControlsProps = - PropsRuntime<'conversation.session.header.leading'> + PropsRuntime<'shell.leading'> & InjectFace & PropsLocale<'sidebar'> /** - * Sidebar-open and New Session controls in the conversation header's leading - * seat. On macOS desktop a collapsed sidebar hides entirely (no rail), taking - * both controls off screen; this occupant puts them back beside the traffic - * lights. Mounted whenever the platform matches; visibility rides the - * AppFrame-published `data-sidebar-collapsed` attribute in CSS, so no - * collapse-state pipe is added here. + * Sidebar-open and New Session controls in the frame's window-chrome seat. + * On macOS desktop a collapsed sidebar hides entirely (no rail), taking both + * controls off screen; this occupant puts them back beside the traffic + * lights. The frame mounts the seat only in that state and owns its + * placement, so the occupant renders unconditionally. * @param props - Injected sidebar actions plus the sidebar locale seat. - * @returns the two header controls, or null off macOS desktop. + * @returns the two window-chrome controls. */ export function HeaderLeadingControls({ toggleSidebar, startSession, t }: HeaderLeadingControlsProps) { - if (!isDarwinDesktop()) return null return (
diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 68d85c63ec..cd9b3bc6af 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -93,7 +93,8 @@ /* 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) and doubles as the window drag region; the toggle opts out. */ + (16, 18). Window dragging over this run comes from ui-layout's .leadingBand + (same 52px geometry); the toggle subtracts via the base.css rule. */ .topStrip { flex: none; display: flex; @@ -102,12 +103,16 @@ height: 52px; box-sizing: border-box; margin: -6px calc(-1 * var(--dsh-sidebar-inline-padding)) 0; - padding: 0 12px; - -webkit-app-region: drag; + /* The 2px foot centres the 28px toggle at y 25, level with the collapsed + seat (ui-layout .leadingSeat, top 11) and the conversation title row, so + collapse/expand never shifts it vertically. */ + padding: 0 12px 2px; } -.topStrip .iconButton { - -webkit-app-region: no-drag; +/* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop + preload): the toggle takes their spot at the strip's left edge. */ +:global([data-fullscreen]) .topStrip { + justify-content: flex-start; } /* The strip's full 52px foot leaves the wordmark too low; the content column @@ -128,7 +133,7 @@ gap: 8px; height: 60px; padding: 8px 0 8px 4px; - margin-bottom: 8px; + margin-bottom: 4px; box-sizing: border-box; overflow: hidden; } @@ -140,6 +145,15 @@ justify-content: flex-start; } +/* macOS hiddenInset titlebar: the logo row extends the frame band's drag + below 52px. Safe as a drag source: it precedes the sidebar's overlay slots + in document order, so their no-drag still wins. The brand's pointer + New-Session shortcut yields to the drag (keyboard activation stays); the + dedicated New Session button below keeps the pointer path. */ +: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 @@ -306,15 +320,23 @@ background: var(--dsw-alias-button-floating-hover); } -/* macOS: the solid elevated fill reads as a foreign slab on the vibrancy - sidebar; letting a quarter of the material through keeps the card visible - while it sits in the glass. */ +/* macOS: the themed elevated fill reads as a foreign slab on the vibrancy + sidebar; a white wash sits in the glass like native macOS sidebar + controls. Dark keeps the same white ink at lower alpha. */ :global([data-platform='darwin']) .newSession { - background: color-mix(in srgb, var(--dsw-alias-button-elevated-fill) 75%, transparent); + background: rgb(255 255 255 / 0.55); } :global([data-platform='darwin']) .newSession:hover { - background: color-mix(in srgb, var(--dsw-alias-button-floating-hover) 75%, transparent); + background: rgb(255 255 255 / 0.3); +} + +:global([data-platform='darwin']) :global([data-ds-dark-theme]) .newSession { + background: rgb(255 255 255 / 0.15); +} + +:global([data-platform='darwin']) :global([data-ds-dark-theme]) .newSession:hover { + background: rgb(255 255 255 / 0.2); } .collapsed .newSession { diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index a768048c44..8fd878a32b 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -9,8 +9,6 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' // Type-only: pulls the Session root standard-props merge. import type {} from '@deepseek-ai/dsh-client-ui-session/client' -// Type-only: pulls the conversation header slot declarations. -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SidebarPanelMetadata, SidebarRootInjected } from './contract/slots.ts' import { HeaderLeadingControls } from './HeaderLeadingControls.tsx' import { SidebarRoot } from './SidebarRoot.tsx' @@ -85,11 +83,11 @@ export function apply(ctx: ClientContext): void { inject: injectProps, }, SidebarRoot)) // macOS desktop hides the collapsed sidebar entirely, so the open/New - // Session controls move into the conversation header's leading seat; the - // occupant reuses the shell's injected actions and shows itself purely - // through CSS against the AppFrame's data-sidebar-collapsed attribute. - ctx.slots.inject('conversation.session.header.leading', () => ctx.slots.register({ - name: 'conversation.session.header.leading', + // Session controls move into the frame's window-chrome seat beside the + // traffic lights; the occupant reuses the shell's injected actions, and + // the AppFrame mounts the seat only while the column is fully hidden. + ctx.slots.inject('shell.leading', () => ctx.slots.register({ + name: 'shell.leading', locale: NS, inject: injectProps, }, HeaderLeadingControls)) diff --git a/packages/client/ui-sidebar/tests/apply.client.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx index 16e1f37112..1ded10bc47 100644 --- a/packages/client/ui-sidebar/tests/apply.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.client.spec.tsx @@ -42,7 +42,7 @@ async function bench(declare = true) { { name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' }, 'main': { kind: 'keyed', scope: 'root' }, - 'conversation.session.header.leading': { kind: 'single', scope: 'session' }, + 'shell.leading': { kind: 'single', scope: 'root' }, } }, SidebarFrame, ) @@ -71,8 +71,8 @@ describe('ui-sidebar apply', () => { expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' }) // Copy rides the standard locale seat, not the inject face. expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar') - // The header leading occupant reuses the shell's inject face and locale. - const leading = b.slots.entries('conversation.session.header.leading') + // The window-chrome occupant reuses the shell's inject face and locale. + const leading = b.slots.entries('shell.leading') expect(leading).toHaveLength(1) expect(leading[0]!.component).toBe(HeaderLeadingControls) expect(leading[0]!.locale).toBe('sidebar') @@ -145,7 +145,7 @@ describe('ui-sidebar apply', () => { await fiber.await() await fiber.dispose() expect(b.slots.entries('sidebar')).toHaveLength(0) - expect(b.slots.entries('conversation.session.header.leading')).toHaveLength(0) + expect(b.slots.entries('shell.leading')).toHaveLength(0) expect(b.slots.spec('sidebar.brand.mark')).toBeUndefined() expect(b.slots.spec('sidebar.brand.name')).toBeUndefined() expect(b.slots.spec('sidebar.workspaces')).toBeUndefined() diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 4d8b3438ab..2027e5544a 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -199,17 +199,13 @@ it('keeps the macOS sidebar toggle in its top strip', () => { expect(shell.toggleSidebar).toHaveBeenCalledOnce() }) -it.each([undefined, 'win32', 'linux', 'darwin'])('shows header sidebar controls only on macOS desktop (%s)', (platform) => { - if (platform !== undefined) document.documentElement.dataset.platform = platform +it('wires the shell.leading controls to the shared sidebar actions', () => { const toggleSidebar = vi.fn() const startSession = vi.fn() - // This occupant only consumes its two actions and locale, not Session hooks. + // This occupant only consumes its two actions and locale, not Session hooks; + // mounting is the frame's decision (ui-layout shell.leading seat). const props = { toggleSidebar, startSession, t } as HeaderLeadingControlsProps - const view = render() - if (platform !== 'darwin') { - expect(view.container.innerHTML).toBe('') - return - } + render() fireEvent.click(screen.getByRole('button', { name: en['toggle.open'] })) fireEvent.click(screen.getByRole('button', { name: en['session.new.label'] })) expect(toggleSidebar).toHaveBeenCalledOnce() diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index 8380d30342..5d20bd9741 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../ui-primitives" }, - { - "path": "../ui-conversation" - }, { "path": "../ui-renderer" }, diff --git a/packages/client/ui-theme/tests/app-region-styles.client.spec.ts b/packages/client/ui-theme/tests/app-region-styles.client.spec.ts new file mode 100644 index 0000000000..0c8bd01b84 --- /dev/null +++ b/packages/client/ui-theme/tests/app-region-styles.client.spec.ts @@ -0,0 +1,53 @@ +/** + * Window drag-region ownership, asserted against the CSS text on disk: + * `-webkit-app-region: drag` appears only in the two chrome bands that mount + * before all content — ui-layout's frame band and ui-sidebar's logo row. + * Electron composes app-regions from window geometry in DOM order, ignoring + * stacking: a drag rule on a content container overrides the no-drag of any + * overlay mounted earlier, so its controls drag the window instead of + * receiving clicks. Everything interactive opts out through ui-web base.css. + */ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { packageStylesheets, parseRules } from './stylesheet-scan.ts' + +/** The only stylesheets allowed to declare a window drag region, relative to packages/. */ +const DRAG_OWNERS = [ + 'client/ui-layout/src/client/AppFrame.module.css', + 'client/ui-sidebar/src/client/SidebarRoot.module.css', +] + +/** + * Selectors of rules declaring a window drag region. + * @param css - stylesheet text. + * @returns the declaring selectors, in source order. + */ +function dragSelectors(css: string): string[] { + return parseRules(css) + .filter(rule => rule.declarations + .some(([property, value]) => property === '-webkit-app-region' && value === 'drag')) + .map(rule => rule.selectors.join(', ')) +} + +describe('window drag-region ownership', () => { + it('rejects a drag declaration and passes no-drag', () => { + expect(dragSelectors('.a { -webkit-app-region: drag; }')).toEqual(['.a']) + expect(dragSelectors('.a { -webkit-app-region: no-drag; }')).toEqual([]) + }) + + it('keeps -webkit-app-region: drag inside the two chrome bands', () => { + const offenders = packageStylesheets() + .filter(file => !DRAG_OWNERS.some(owner => file.endsWith(`/${owner}`))) + .flatMap(file => dragSelectors(readFileSync(file, 'utf8')).map(selectors => `${file} ${selectors}`)) + expect(offenders).toEqual([]) + }) + + it('still finds a drag band in every allowlisted owner', () => { + // An owner that stops declaring drag should leave the allowlist with it. + for (const owner of DRAG_OWNERS) { + const file = packageStylesheets().find(candidate => candidate.endsWith(`/${owner}`)) + expect(file, owner).toBeDefined() + expect(dragSelectors(readFileSync(file!, 'utf8')), owner).not.toEqual([]) + } + }) +}) diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css index 10616bba3b..d3f6d6561a 100644 --- a/packages/client/web/src/base.css +++ b/packages/client/web/src/base.css @@ -39,6 +39,33 @@ html[data-platform='darwin'] body { background: transparent; } +/* macOS desktop: the app frame publishes window drag bands, and Electron + composes app-regions from geometry, not hit testing — a surface covering a + band would otherwise drag the window instead of receiving clicks. Every + overlay portals to document.body beside #root, so one rule subtracts them + all; drag surfaces all live inside #root. */ +html[data-platform='darwin'] body > :not(#root) { + -webkit-app-region: no-drag; +} + +/* Inside #root, interactive controls and raised surfaces (dialogs, menus) + subtract themselves from any drag band they overlap, wherever they render. + Electron composes app-regions from geometry in document order, so this works + only because the bands are declared before all content (AppFrame's first + child; the sidebar rows that precede the column's overlay slots) — a drag + rule on a content container would override every overlay mounted earlier in + the DOM. One selector on the native elements, ARIA roles, and tabindexed + custom widgets replaces per-component no-drag opt-outs. */ +html[data-platform='darwin'] :is( + button, a, input, select, textarea, summary, [contenteditable='true'], [tabindex], + [role='dialog'], [role='alertdialog'], [role='menu'], [role='listbox'], [role='tooltip'], + [role='button'], [role='link'], [role='tab'], [role='menuitem'], [role='menuitemcheckbox'], + [role='menuitemradio'], [role='option'], [role='checkbox'], [role='radio'], [role='switch'], + [role='slider'], [role='combobox'], [role='textbox'] +) { + -webkit-app-region: no-drag; +} + code, pre, [data-diff], diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 1ec4166573..045f2a331b 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -393,7 +393,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', }, { key: 'conversation.composer.bar', @@ -432,7 +432,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', }, { key: 'conversation.composer.dock', @@ -488,7 +488,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:181', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169', }, { key: 'conversation.hero.agentPreset', @@ -525,7 +525,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', }, { key: 'conversation.hero.brand.mark', @@ -554,7 +554,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', }, { key: 'conversation.hero.workspace', @@ -587,7 +587,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -660,7 +660,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177', }, { key: 'conversation.input.dock', @@ -723,7 +723,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165', }, { key: 'conversation.input.left', @@ -777,7 +777,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', }, { key: 'conversation.input.model', @@ -816,7 +816,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:199', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', }, { key: 'conversation.input.overlay', @@ -874,7 +874,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167', }, { key: 'conversation.input.permission', @@ -913,7 +913,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:197', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', }, { key: 'conversation.input.plan', @@ -952,7 +952,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', }, { key: 'conversation.input.right', @@ -1006,7 +1006,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', }, { key: 'conversation.message.images', @@ -1224,46 +1224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', - }, - { - key: 'conversation.session.header.leading', - kind: 'single', - scope: 'session', - summary: 'Leading seat before the Session breadcrumbs, for window-chrome-adjacent controls (macOS desktop sidebar reopen and New Session while the sidebar is hidden).', - doc: 'Leading seat before the Session breadcrumbs, for window-chrome-adjacent\ncontrols (macOS desktop sidebar reopen and New Session while the sidebar\nis hidden). The seat is laid out only while its occupant renders\nsomething, and it stays mounted through the blank-session state so a\nhidden sidebar always keeps a reopen control on screen.', - registerOptions: [], - ownerProps: [ - '/** The leading seat\'s occupant derives its state from standard Session props. */\nexport interface ConversationHeaderLeadingOwnerProps {\n /** Marker field: the occupant receives no owner-specific values. */\n children?: never\n}', - ], - ownerPropsReferences: [], - standardProps: [ - 'useResource: UseResource', - 'useWorkspaces: SnapshotSelectorHook', - 'usePanelInfo: UsePanelInfo', - 'useSessions: UseSessions', - 'useSessionStatus: UseSessionStatus', - 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', - 'useChat: UseChat', - 'useConversation: UseConversation', - 'useInput: SnapshotSelectorHook', - 'inputActions: InputActions', - 'useSession: SessionSnapshotSelector', - 'sessionId: SessionId', - 'useProjection: UseProjection', - 'useTrajectory: UseTrajectory', - ], - keyDomain: '', - hookContext: '', - slotInject: '', - declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted', - occupants: [ - 'client-ui-sidebar HeaderLeadingControls', - ], - replaceRisk: 'shadows-shipped-ui', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.leading\', () => ctx.slots.register(\n { name: \'conversation.session.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:150', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149', }, { key: 'conversation.session.header.lineage', @@ -1467,7 +1428,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155', }, { key: 'main', @@ -2206,6 +2167,35 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.trigger\', () => ctx.slots.register(\n { name: \'settings.trigger\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-settings/src/client/contract/slots.ts:24', }, + { + key: 'shell.leading', + kind: 'single', + scope: 'root', + summary: 'Window-chrome seat at the frame\'s top-left, over every main panel.', + doc: 'Window-chrome seat at the frame\'s top-left, over every main panel.\nMounted only while the sidebar column is fully hidden (macOS desktop\ncollapse; other platforms keep the rail), so the occupant can assume the\nframe edge is the window edge and the macOS traffic lights sit before it.\nOCCUPIED by ui-sidebar\'s reopen/New Session controls.\n\nWhile the seat is mounted the frame publishes\n`--dsh-frame-leading-clearance` (the inline inset the seat\'s band\noccupies, measured from the frame\'s left edge); a main panel whose\ncontent reaches the top-left corner pads by it so nothing lands under\nthe lights or the controls.', + registerOptions: [], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', + occupants: [ + 'client-ui-sidebar HeaderLeadingControls', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.leading\', () => ctx.slots.register(\n { name: \'shell.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-layout/src/client/index.ts:105', + }, { key: 'shell.overlay', kind: 'list', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23120ed0df..457dda954c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4236,9 +4236,6 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../ui-layout @@ -8106,7 +8103,7 @@ importers: version: link:../../../vendor/schemastery '@earendil-works/pi-ai': specifier: ^0.85.1 - version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3) + version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -20561,14 +20558,14 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.123.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 '@earendil-works/pi-telemetry': 0.85.1 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2(supports-color@9.4.0) + http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 openai: 6.40.0(ws@8.21.0)(zod@4.4.3) partial-json: 0.1.7 @@ -23196,7 +23193,7 @@ snapshots: cross-spawn: 7.0.6 debug: 4.4.3(supports-color@9.4.0) fs-extra: 10.1.0 - http-proxy-agent: 7.0.2(supports-color@9.4.0) + http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 js-yaml: 4.3.1 sanitize-filename: 1.6.4 @@ -24511,7 +24508,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2(supports-color@9.4.0): + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@9.4.0) From d66b9c81b171172a90c2bc4d6188d5927f909b4b Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 11:36:21 +0800 Subject: [PATCH 02/59] fix(desktop): drop superseded preload.ts after merge Upstream removed the standalone plugin manager and replaced this bridge with preload-app.ts; the fullscreen sync it carried already lives there. --- apps/desktop/src/preload.ts | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 apps/desktop/src/preload.ts diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts deleted file mode 100644 index d6d98601d6..0000000000 --- a/apps/desktop/src/preload.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** Context-isolated renderer bridge for desktop package and update operations. */ - -import { contextBridge, ipcRenderer } from 'electron' -import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts' -import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts' - -const api: DshDesktopApi = { - protocolVersion: 1, - locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as Promise extends Promise ? T : never>, - plugins: { - list: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsList) as Promise extends Promise ? T : never>, - add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise, - remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise, - toggle: (name, enabled) => ipcRenderer.invoke(DESKTOP_IPC.pluginsToggle, name, enabled) as Promise, - update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise, - }, - updates: { - check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise, - install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise, - subscribe(listener) { - const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdateState): void => { listener(state) } - ipcRenderer.on(DESKTOP_IPC.updatesState, handle) - return () => { ipcRenderer.off(DESKTOP_IPC.updatesState, handle) } - }, - }, -} - -markDocumentPlatform() -syncWindowFullscreen() -contextBridge.exposeInMainWorld('dshDesktop', api) From 994e998b7d9bd6c3192b6d252b780a5749538a09 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 11:59:45 +0800 Subject: [PATCH 03/59] feat(client): deepen the conversation drag band to the header block While the Conversation is selected and its header shows the view tab strip, the macOS window drag band grows from 52px to the 76px header block (title row plus tab strip), so blank header space drags. The band matches the strip's data-conversation-tabs marker via :has, so a compact header (single view, per #4451's developer-tools setting) falls back to the 52px strip. --- .../client/skeleton/ConversationSession.tsx | 4 +++- .../tests/skeleton.client.spec.tsx | 2 ++ 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 | 11 +++++++++++ .../client/ui-layout/src/client/AppFrame.tsx | 19 +++++++++++++++++++ .../ui-layout/tests/app-frame.client.spec.tsx | 2 ++ 8 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index d151914031..24697f660a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -136,7 +136,9 @@ export function ConversationSessionHeader({
{!hideChrome && tabs.length > 1 && ( -
+ // data-conversation-tabs: ui-layout's window drag band matches this + // marker (:has) to deepen only while the tab strip adds header height. +
{tabs.map(viewTab => ( - )} + ) + return darwinDesktop + ? {identity} + : ( + + ) + })()} {!darwinDesktop && toggle}
diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index fb9cda6aa5..18e7fbc5e1 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -226,6 +226,11 @@ it('keeps the macOS sidebar toggle in its top strip', () => { const shell = mountShell() fireEvent.click(screen.getByRole('button', { name: en['toggle.collapse'] })) expect(shell.toggleSidebar).toHaveBeenCalledOnce() + // The brand stays part of the logo row's window-drag surface: no button + // role (the global no-drag rule would subtract it); only the dedicated + // New Session capsule starts a session. + expect(screen.getAllByRole('button', { name: 'New session' })).toHaveLength(1) + expect(screen.getByTestId('custom-brand-mark')).toBeTruthy() }) it('wires the shell.leading controls to the shared sidebar actions', () => { From e44345c04475661bed15c45bfa7786ab163cb20d Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 13:32:53 +0800 Subject: [PATCH 05/59] test(desktop): stub syncWindowFullscreen in preload-platform mocks --- apps/desktop/tests/preload-app.spec.ts | 2 +- .../tests/client-flow.client.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 0af35e98c5..b095aae2c0 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -7,7 +7,7 @@ const electron = vi.hoisted(() => ({ ipcRenderer: { invoke: vi.fn(), on: vi.fn(), off: vi.fn(), send: vi.fn() }, })) vi.mock('electron', () => electron) -vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() })) +vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() })) vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 1aed3a8d2a..11f6083a2a 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -20,7 +20,7 @@ vi.mock(desktopIpc.electron, () => ({ ipcRenderer: { invoke: desktopIpc.invoke }, contextBridge: { exposeInMainWorld: (name: string, value: unknown) => { vi.stubGlobal(name, value) } }, })) -vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() })) +vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() })) vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) vi.mock('../../../../apps/desktop/src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) From 8072511aad11c3c61012a4068b8d28e2759fb690 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 13:32:55 +0800 Subject: [PATCH 06/59] fix(client): subtract dockkit strip runs and pane bodies from the macOS drag band --- .../ui-dockkit/src/components/dockkit.module.css | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index 55279ecbe1..82541eea1e 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -221,6 +221,20 @@ margin-left: 4px; } +/* macOS desktop: an embedder may pin this surface under the window's top drag + band (ui-layout .leadingBand — full-window geometry that Electron composes + in DOM order, not by stacking). The chip run, the strip-end chrome, and the + pane body subtract their whole boxes so tabs, controls, and content stay + clickable even where they overlap the band; the strip's blank fill and top + inset keep the band's drag, so the row moves the window like a native tab + bar. Hidden surfaces contribute nothing: Blink skips non-visible boxes when + it collects draggable regions. */ +:global(html[data-platform='darwin']) .stripTabs, +:global(html[data-platform='darwin']) .stripChrome, +:global(html[data-platform='darwin']) .paneBody { + -webkit-app-region: no-drag; +} + /* The slot between two chips: a 10px box with a hairline down its centre. Targeted by a drag, the same box draws the caret instead, so the chips around it never move. */ From 02bb08ecd99b51d1d558b1f1d8448578447b7135 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 13:33:00 +0800 Subject: [PATCH 07/59] fix(client): compensate menu fills for the blur the transparent window disables --- packages/client/web/src/base.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css index d3f6d6561a..805a74e9fb 100644 --- a/packages/client/web/src/base.css +++ b/packages/client/web/src/base.css @@ -39,6 +39,19 @@ html[data-platform='darwin'] body { background: transparent; } +/* The transparent window disables backdrop-filter: Chromium composites it + against the root surface, which vibrancy leaves transparent, so the blur in + --dsw-menu-backdrop-filter renders as nothing and half-opaque menu fills + let underlying text bleed through. Compensate with near-opaque fills on the + same design-platform hues (light 248 249 250 / dark 48 49 54). */ +html[data-platform='darwin'] body { + --dsw-specific-menu: rgba(248, 249, 250, 0.94); +} + +html[data-platform='darwin'] body[data-ds-dark-theme] { + --dsw-specific-menu: rgba(48, 49, 54, 0.94); +} + /* macOS desktop: the app frame publishes window drag bands, and Electron composes app-regions from geometry, not hit testing — a surface covering a band would otherwise drag the window instead of receiving clicks. Every From 9ae6f0defa70bdbf5552365dee69a708b59fcf52 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 14:28:07 +0800 Subject: [PATCH 08/59] fix(client): mount the shell.leading seat on macOS only and cover the fullscreen relay --- apps/desktop/tests/main-startup.spec.ts | 35 +++++++++++++++++++ .../tests/preload-platform.client.spec.ts | 33 +++++++++++++++++ .../client/ui-layout/src/client/AppFrame.tsx | 7 ++-- .../ui-layout/tests/app-frame.client.spec.tsx | 5 ++- 4 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/tests/preload-platform.client.spec.ts diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 3df37cb4ae..b83754ea27 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -74,6 +74,8 @@ const harness = await vi.hoisted(async () => { super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this); if (options.modal) policyBlocked.resolve() } isDestroyed() { return this.destroyed } + fullscreen = false + isFullScreen() { return this.fullscreen } isMinimized() { return false } isFocused() { return true } async loadURL(url: string) { @@ -518,6 +520,39 @@ describe('desktop main startup', () => { expect(harness.hosts).toHaveLength(0) }) + it('relays the macOS fullscreen state on transitions and after each load', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + const sent = () => window.webContents.send.mock.calls.filter(([channel]) => channel === DESKTOP_IPC.windowFullscreen) + expect(sent()).toHaveLength(0) + window.fullscreen = true + window.emit('enter-full-screen') + expect(sent().at(-1)).toEqual([DESKTOP_IPC.windowFullscreen, true]) + // A reload re-registers the preload listener; the finished load resends + // the current state so fullscreen CSS survives the reload. + window.webContents.emit('did-finish-load') + expect(sent().at(-1)).toEqual([DESKTOP_IPC.windowFullscreen, true]) + window.fullscreen = false + window.emit('leave-full-screen') + expect(sent().at(-1)).toEqual([DESKTOP_IPC.windowFullscreen, false]) + const relayed = sent().length + window.destroyed = true + window.emit('enter-full-screen') + expect(sent()).toHaveLength(relayed) + }) + + it.each(['win32', 'linux'] as const)('registers no fullscreen relay on %s', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.emit('enter-full-screen') + window.webContents.emit('did-finish-load') + expect(window.webContents.send.mock.calls.filter(([channel]) => channel === DESKTOP_IPC.windowFullscreen)).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') diff --git a/apps/desktop/tests/preload-platform.client.spec.ts b/apps/desktop/tests/preload-platform.client.spec.ts new file mode 100644 index 0000000000..fbf6902db0 --- /dev/null +++ b/apps/desktop/tests/preload-platform.client.spec.ts @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { DESKTOP_IPC } from '../src/ipc.ts' +import { syncWindowFullscreen } from '../src/preload-platform.ts' + +const on = vi.hoisted(() => vi.fn()) +vi.mock('electron', () => ({ ipcRenderer: { on } })) + +afterEach(() => { + delete document.documentElement.dataset.fullscreen + vi.restoreAllMocks() + on.mockClear() +}) + +it.each(['win32', 'linux'] as const)('subscribes no fullscreen channel on %s', (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + syncWindowFullscreen() + expect(on).not.toHaveBeenCalled() +}) + +it('mirrors the sent fullscreen state onto html[data-fullscreen] on darwin', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + syncWindowFullscreen() + expect(on).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.windowFullscreen, expect.any(Function)) + const handler = on.mock.calls[0]?.[1] as (event: unknown, fullscreen: boolean) => void + handler({}, true) + expect(document.documentElement.dataset.fullscreen).toBe('true') + handler({}, false) + expect(document.documentElement.dataset.fullscreen).toBeUndefined() + // A repeated windowed report stays a no-op rather than toggling the flag. + handler({}, false) + expect(document.documentElement.dataset.fullscreen).toBeUndefined() +}) diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 7b39e777a9..e9ed81aa47 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -266,11 +266,12 @@ export function AppFrame({ ), [usePanelInfo, renderSlot]) const overlays = useMemo(() => renderSlot('shell.overlay', {}), [renderSlot]) // Window-chrome seat over the main panels' top-left corner: only a fully - // hidden sidebar column (macOS desktop collapse) leaves window chrome - // without a home. AppFrame.module.css publishes the matching + // hidden sidebar column on macOS desktop leaves window chrome without a + // home — the Windows zero-width collapse keeps its controls in the caption + // row (ui-sidebar). AppFrame.module.css publishes the matching // --dsh-frame-leading-clearance under the same collapsed condition. const leading = useMemo(() => renderSlot('shell.leading', {}), [renderSlot]) - const leadingMounted = sidebarCollapsed && collapsedWidth === 0 + const leadingMounted = darwin && sidebarCollapsed return (
{ 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() + const { frame, instance, sidebarOwner, getByTestId, queryByTestId } = mountFrame() act(() => { instance.actions.toggleSidebar() }) expect(tracks(frame)[0]).toBe(0) expect(sidebarOwner()).toMatchObject({ collapsed: true, width: 0 }) expect(getByTestId('sidebar-content')).toBeTruthy() + // The caption row keeps the reopen controls; the darwin-only + // shell.leading seat must not mount a duplicate set. + expect(queryByTestId('shell.leading-content')).toBeNull() } finally { document.documentElement.removeAttribute('data-windows-titlebar') } From e509dd93418374114dbe4004ff7d1b16d1752b72 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 14:28:12 +0800 Subject: [PATCH 09/59] fix(client): pulse an app-region nudge at the right panel's open/close edges Electron recollects window drag rects only when a style or layout pass dirties an app-region value, and Blink skips hidden boxes when collecting them, so the first open of the slid-off panel left the strip swallowed by the drag band until an unrelated layout ran. --- .../src/client/shell/SidebarRight.module.css | 10 ++++++ .../src/client/shell/SidebarRight.tsx | 31 +++++++++++++++++++ .../tests/seat.client.spec.tsx | 28 +++++++++++++++++ 3 files changed, 69 insertions(+) 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 4dd937e3e0..b9551e62cb 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,6 +46,16 @@ 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). While set, the whole panel is out of the drag band for + that frame, which is also the desired end state everywhere but the strip's + blank fill. */ +: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; 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 1a517f4684..f326717124 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -374,6 +374,37 @@ 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]) + // Fullscreen leaves the previous column report in force until its own slide // completes. Normal presentation and zero-duration transitions report before paint. useLayoutEffect(() => { 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 8a5db55190..579da311ec 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -210,6 +210,34 @@ describe('RightbarSeat presentation', () => { expect(h.frame.closeRightbar).toHaveBeenCalled() }) + 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() From e67aca993ecc556d5d884d6dfe4b8d5dd52dd654 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 15:14:59 +0800 Subject: [PATCH 10/59] docs(notes): align window-chrome notes and sidebar README with shipped drag ownership The seat note claimed a plugin-manager inline clearance formula that never shipped (entry pages pad by the unconditional --dsh-frame-top-clearance) and omitted the 84px fullscreen clearance tier. The vibrancy note still called the sidebar top strip a drag region after that declaration moved to the frame's .leadingBand, said drag lives in "exactly two places" while the Windows caption ::before is a third, and lacked the dsh-desktop:window-fullscreen relay that drives html[data-fullscreen]. The ui-sidebar README carried the same stale top-strip drag claim. Chinese sides follow; pairings re-recorded. --- ...17-frame-shell-leading-window-chrome-seat.i18n.yaml | 4 ++-- ...026-09-17-frame-shell-leading-window-chrome-seat.md | 4 ++-- ...-09-17-frame-shell-leading-window-chrome-seat.zh.md | 4 ++-- ...2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml | 4 ++-- .../2026-09-13-macos-hidden-titlebar-vibrancy.md | 10 ++++++---- .../2026-09-13-macos-hidden-titlebar-vibrancy.zh.md | 10 ++++++---- packages/client/ui-sidebar/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- 9 files changed, 24 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.i18n.yaml index f8433a2aed..4efdadb220 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.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/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md -2026-09-17-frame-shell-leading-window-chrome-seat.md: d5724cbc65326f8d43a3154854cf2e028f758fd2 -2026-09-17-frame-shell-leading-window-chrome-seat.zh.md: 3b58bc3cfd0974fcb83b2d183db47c0f8f72d152 +2026-09-17-frame-shell-leading-window-chrome-seat.md: 9a35d02f04106b0d2be42e7f53af0ce873d3ea4b +2026-09-17-frame-shell-leading-window-chrome-seat.zh.md: f9a2e2c7653c4988330408746f372755a979ebc9 diff --git a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md index d5724cbc65..9a35d02f04 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md +++ b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md @@ -12,7 +12,7 @@ The [macOS hidden-titlebar work](../feature/2026-09-13-macos-hidden-titlebar-vib The window chrome belongs to the frame, not to one panel. ui-layout declares a fifth root-scoped child slot, `shell.leading` (single), and AppFrame renders its seat as a frame-level box over the columns — mounted only while `sidebarCollapsed && collapsedWidth === 0`, the darwin desktop full-hide state, the one state that leaves window chrome without a home. The seat sits at the frame's top-left (left 88px clears the hiddenInset traffic lights, top 11px centers the 28px controls on the conversation title row, z-index 15 above column content and below frame overlays) and carries `-webkit-app-region: no-drag`, subtracting itself from any drag band beneath it. -Under the same collapsed condition the frame publishes `--dsh-frame-leading-clearance: 160px` — the inline band the traffic lights plus the seat's two controls occupy, measured from the frame's left edge. Main panels whose content reaches the top-left corner pad by it and subtract what they already inset: the conversation title row pads `max(0px, clearance - 20px)` (its header pads 20px), and the plugin manager's page head pads `max(0px, clearance - clamp(24px, 4vw, 48px) - max(0px, (100% - 960px) / 2))`, so its centred column only indents when a narrow window would actually collide. +Under the same collapsed condition the frame publishes `--dsh-frame-leading-clearance: 160px` — the inline band the traffic lights plus the seat's two controls occupy, measured from the frame's left edge. Window fullscreen hides the traffic lights (the desktop preload mirrors the state onto `html[data-fullscreen]`): the seat moves to `left: 12px` into their vacated band and the clearance drops to `84px`. The conversation title row is the one consumer: it pads `max(0px, clearance - 20px)` (its header already pads 20px). Entry pages that start below the window strip in both sidebar states (the plugin manager's page head) instead pad their top by `--dsh-frame-top-clearance` (48px, published on the darwin frame unconditionally), which clears the strip vertically rather than indenting inline. ui-sidebar registers `HeaderLeadingControls` into `shell.leading` instead of the conversation seat, reusing the shell's inject face and locale; the component renders unconditionally because the frame owns the mount condition, and its platform check and CSS collapse-gating are deleted. The conversation slot `conversation.session.header.leading` is removed outright — declaration, owner-props interface, children entry, header render seat, and CSS — per the pre-stable API rule (every consumer updated, no compatibility shim), which also drops ui-sidebar's dependency on ui-conversation. @@ -28,7 +28,7 @@ ui-sidebar registers `HeaderLeadingControls` into `shell.leading` instead of the ## Consequences -- Every current and future main panel keeps the reopen and New Session controls while the sidebar is hidden, at zero per-panel cost; panels whose content reaches the top-left corner opt into `--dsh-frame-leading-clearance`. +- Every current and future main panel keeps the reopen and New Session controls while the sidebar is hidden, at zero per-panel cost; the conversation opts into `--dsh-frame-leading-clearance`, and entry pages take the unconditional `--dsh-frame-top-clearance` instead. - `conversation.session.header.leading` no longer exists in the client catalog; `shell.leading` replaces it as a public root-scoped seat any package may occupy. - ui-sidebar no longer depends on ui-conversation (inject list, devDependency, and tsconfig reference removed). - The seat is darwin-reachable only in the shipped composition (only the darwin collapse hides the column), but the mount condition is platform-agnostic: a platform that later hides its column gets the seat for free. diff --git a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md index 3b58bc3cfd..f9a2e2c765 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md @@ -12,7 +12,7 @@ Status: implemented 窗口 chrome 属于框架,不属于某个面板。ui-layout 声明第五个 root 作用域子 slot `shell.leading`(single),AppFrame 把它的座渲染为覆盖各列的框架级盒子——仅在 `sidebarCollapsed && collapsedWidth === 0`(darwin 桌面全隐藏,唯一让窗口 chrome 无处安放的状态)时挂载。座位于框架左上角(left 88px 避开 hiddenInset 红绿灯,top 11px 让 28px 控件对齐会话标题行中线,z-index 15 高于列内容、低于框架浮层),并带 `-webkit-app-region: no-drag`,把自己从下方任何拖拽带中减去。 -在同一收起条件下框架发布 `--dsh-frame-leading-clearance: 160px`——红绿灯加座内两个控件占据的行内带宽,自框架左边缘起量。内容抵达左上角的主面板以它做内边距并减去自身已有的缩进:会话标题行 pad `max(0px, clearance - 20px)`(其头部已 pad 20px);插件管理器页头 pad `max(0px, clearance - clamp(24px, 4vw, 48px) - max(0px, (100% - 960px) / 2))`,居中列只在窗口窄到真正碰撞时才缩进。 +在同一收起条件下框架发布 `--dsh-frame-leading-clearance: 160px`——红绿灯加座内两个控件占据的行内带宽,自框架左边缘起量。窗口全屏隐藏红绿灯(桌面 preload 把状态镜像到 `html[data-fullscreen]`):座移入其空出的带宽(`left: 12px`),避让量降到 `84px`。会话标题行是唯一消费者:它 pad `max(0px, clearance - 20px)`(其头部已 pad 20px)。两种侧栏状态下都始于窗口顶带之下的入口页(插件管理器页头)改用 `--dsh-frame-top-clearance`(48px,darwin 框架上无条件发布)做顶部内边距,纵向避开顶带而非行内缩进。 ui-sidebar 把 `HeaderLeadingControls` 改注册到 `shell.leading` 而非会话座,复用 shell 的 inject face 与 locale;因挂载条件归框架所有,组件无条件渲染,其平台判断与 CSS 收起门控被删除。会话 slot `conversation.session.header.leading` 按 pre-stable API 规则整体移除——声明、owner props 接口、children 条目、头部渲染座与 CSS(更新所有消费者,不留兼容垫层),同时解除 ui-sidebar 对 ui-conversation 的依赖。 @@ -28,7 +28,7 @@ ui-sidebar 把 `HeaderLeadingControls` 改注册到 `shell.leading` 而非会话 ## Consequences -- 侧边栏隐藏时,现有与未来的每个主面板都免费获得重新打开与 New Session 控件;内容抵达左上角的面板自行接入 `--dsh-frame-leading-clearance`。 +- 侧边栏隐藏时,现有与未来的每个主面板都免费获得重新打开与 New Session 控件;会话接入 `--dsh-frame-leading-clearance`,入口页改用无条件发布的 `--dsh-frame-top-clearance`。 - `conversation.session.header.leading` 从客户端目录中消失;`shell.leading` 取而代之,成为任何包都可占据的公开 root 作用域座。 - ui-sidebar 不再依赖 ui-conversation(inject 列表、devDependency 与 tsconfig 引用均已移除)。 - 在当前组合中该座仅 darwin 可达(只有 darwin 收起会隐藏整列),但挂载条件与平台无关:未来任何隐藏整列的平台都自动获得该座。 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 d6a059faa9..d15870537e 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: 545c1a49f115bb02dd1283086c12da9a1e96039b -2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: ef884da1285d013873e1fad6fb0d82ca2f9a563f +2026-09-13-macos-hidden-titlebar-vibrancy.md: 807c79d06c468e01b8b9df3f18a32e5fbaba1e90 +2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: a521aff7624b1f9dbab18e7d0f02f61af0bdc24a 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 545c1a49f1..807c79d06c 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,9 +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, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). 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 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). -**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so `-webkit-app-region: drag` is declared in exactly two places, both early chrome bands: the AppFrame's `.leadingBand` (a 52px full-width, `pointer-events: none` strip rendered as the frame's first child) and the sidebar's `.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. +**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. **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. @@ -41,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 in the two chrome bands (AppFrame `.leadingBand`, SidebarRoot `.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 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. - 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 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 chrome bands 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 style gate pins drag-region ownership to the two allowlisted stylesheets across every package stylesheet. 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 ef884da128..a521aff762 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,9 +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 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`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 的顶部条,避开红绿灯并承载收起按钮;框架的 `.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) 所有。 -**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此 `-webkit-app-region: drag` 只在两处声明,且都是靠前挂载的 chrome 带:AppFrame 的 `.leadingBand`(52px 全宽、`pointer-events: none`、作为框架第一个子元素渲染的条带)和侧边栏的 `.logoRow`(把拖拽带向 52px 以下延伸)。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。拖拽带先于全部内容挂载,任何靠后的 no-drag 盒子必然生效;内容容器永不声明 drag——那会覆盖更早挂载的浮层。 +**窗口全屏标记。** 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——那会覆盖更早挂载的浮层。 **全屏避开红绿灯。** ui-dockkit 把 tab 条起始内边距发布为 `--dsh-dockkit-strip-inline-start`(回退为设计自身的 10px)。右侧边栏全屏形态在 darwin 上对面板主体设 88px,并对每个非首格 split 单元的子树重置为 10px,使得任意分屏深度下恰好只有触及窗口左上角的 pane 避开红绿灯。 @@ -41,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` 只出现在两条 chrome 带(AppFrame `.leadingBand`、SidebarRoot `.logoRow`);其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 样式门禁对全部包内样式表强制该允许清单。 +- 拖拽区归属是窗口级全局不变量:`-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 样式门禁对全部包内样式表强制该允许清单。 - 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `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 下座的挂载。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 样式门在全部包内样式表上把拖拽区归属钉在两条 chrome 带。 +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 样式门在全部包内样式表上把拖拽区归属钉在两个允许清单样式表内。 diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index fecb9d0985..c837437b74 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: 8122b073764170bf875b05af95d08193e54de2d6 -README.zh.md: 42bf5abff2eafe2682d4cae6feacfc3aa6d3f30d +README.md: 1c5306b8b3efa5d7908cfd33206acefef20fc3a8 +README.zh.md: d72da040252f391bcc1468c9e29adf6a5ef85ef0 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 8122b07376..1c5306b8b3 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, carries the collapse toggle, and acts as the window drag region; the logo row below extends that 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 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). ### Scrollbars diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 42bf5abff2..d72da04025 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 红绿灯、承载收起按钮,并作为窗口拖拽区;其下的 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 红绿灯并承载收起按钮;框架自身的拖拽带(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)。 ### 滚动条 From 92101e1a5b4cb1717cfab75b27bb7cffe3d90b0f Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 18 Sep 2026 15:59:27 +0800 Subject: [PATCH 11/59] fix(ui-conversation): render the current crumb as text so the title joins the drag band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The current session's breadcrumb was a disabled button: the global darwin no-drag rule subtracted it from the window drag band while its disabled state and button user-select kept it from doing anything else, leaving the title a dead zone that neither dragged the window nor selected. Plain text keeps the native titlebar behavior — the title drags the window; ancestor crumbs stay buttons. --- .../skeleton/ConversationRoot.module.css | 3 +- .../client/skeleton/ConversationSession.tsx | 33 +++++++++++-------- .../tests/assembly-surfaces.client.spec.tsx | 6 ++-- .../tests/skeleton.client.spec.tsx | 9 +++-- 4 files changed, 30 insertions(+), 21 deletions(-) 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 5620eff45a..da7b7dd083 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -93,6 +93,7 @@ } .crumb { + display: inline-block; max-width: 220px; overflow: hidden; padding: 4px 8px; @@ -112,7 +113,7 @@ line-height: 18px; } -.crumb:hover:not(:disabled) { +button.crumb:hover { background: var(--dsw-alias-interactive-bg-hover); } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 594e8cdb3f..4aac3f0a0e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -77,20 +77,25 @@ export function ConversationSessionHeader({