From f12c5e658a79655090d9e0d3b08fc880b4a86012 Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Mon, 19 Oct 2026 18:00:58 +0900 Subject: [PATCH 01/11] fix(desktop): keep settings choice menus inside the zoomed viewport Issue: #2269 Agent-Task: codex/local/01a1506e-87d7-7c10-b4d9-9ba86f02b264 --- .agents/skills/dsh-ui/SKILL.md | 2 +- .../dsh-ui/references/native-contract.md | 28 +- changes/2269-scaled-choice-menus.md | 11 + docs/architecture/botharness-architecture.md | 5 + .../client/src/client/ds/pointer-menu.tsx | 43 ++- packages/client/src/client/ds/select.tsx | 135 +++++-- .../client/test/bot-mode-setting-rows.test.ts | 41 ++- packages/client/test/design-system.test.ts | 4 +- .../test/messaging-defaults-settings.test.ts | 2 +- scripts/check-select-zoom.mjs | 334 ++++++++++++++++++ 10 files changed, 543 insertions(+), 62 deletions(-) create mode 100644 changes/2269-scaled-choice-menus.md create mode 100644 scripts/check-select-zoom.mjs diff --git a/.agents/skills/dsh-ui/SKILL.md b/.agents/skills/dsh-ui/SKILL.md index 66fff0b16..3efcf6fe2 100644 --- a/.agents/skills/dsh-ui/SKILL.md +++ b/.agents/skills/dsh-ui/SKILL.md @@ -26,7 +26,7 @@ The in-harness UI is part of the shell, not a standalone app (ADR-0028): style w - **Baseline externals only** in the client bundle; adding one updates the bundle contract test in the same commit. - **Modes unwind**: shadows and listeners reconcile from a single store and dispose cleanly. -- **Pointer menus have one positioning owner**: use `ds/PointerMenu` for every application right-click menu. Pass unmodified `clientX/clientY`; never add feature-local offsets or construct a coordinate proxy anchor. See the coordinate contract in `references/native-contract.md`. Native anchored dropdowns continue to use `Menu`, except the qualified compact Settings picker under whole-window zoom; its owning-Modal container contract is recorded in the native-contract reference. +- **Pointer menus have one positioning owner**: use `ds/PointerMenu` for every application right-click menu. Pass unmodified `clientX/clientY`; never add feature-local offsets or construct a coordinate proxy anchor. See the coordinate contract in `references/native-contract.md`. Native anchored dropdowns continue to use `Menu`, except the measured compact Settings picker and shared `ds/Select` under whole-window zoom; its owning-Modal container contract is recorded in the native-contract reference. ## Reference diff --git a/.agents/skills/dsh-ui/references/native-contract.md b/.agents/skills/dsh-ui/references/native-contract.md index 631dfc5bc..6f89e4885 100644 --- a/.agents/skills/dsh-ui/references/native-contract.md +++ b/.agents/skills/dsh-ui/references/native-contract.md @@ -62,7 +62,7 @@ Use the Puppeteer install from `node_modules/.pnpm/puppeteer@*/node_modules/pupp ## Pointer-anchored context menus -Application-defined context menus use `packages/client/src/client/ds/pointer-menu.tsx`. A mouse caller supplies raw viewport `clientX/clientY`; a keyboard caller supplies the trigger's `getBoundingClientRect()` left/bottom. Do not use page/offset coordinates, feature-local nudges, or a transformed panel as a positioning container. Ordinary button-anchored dropdowns keep the native `Menu`. +Application-defined context menus use `packages/client/src/client/ds/pointer-menu.tsx`. A mouse caller supplies raw viewport `clientX/clientY`; a keyboard caller supplies the trigger's `getBoundingClientRect()` left/bottom. Do not use page/offset coordinates, feature-local nudges, or a transformed panel as a positioning container. Ordinary button-anchored dropdowns keep the native `Menu`, except the measured shared `ds/Select` contract below. `PointerMenu` portals to `document.body`, measures its rendered coordinate scale and the actual native menu rectangle, and positions the menu at the requested viewport point. It alone owns window-edge clamping, native traffic-light clearance, available menu size and resize/scroll repositioning. A menu moves away from the pointer only to remain inside the safe viewport; native scrolling and submenu behavior remain intact. This keeps panel transforms, clipping ancestors and CSS zoom out of feature code. @@ -85,3 +85,29 @@ Only the shared Panel title button (`data-panel-hide`, icon + label) is a Panel Verified against Dockview 8.4.1 and the shipped DSH 0.2.1-rc.2 frontend: Dockview adds a draggable, focusable `role=tab` wrapper outside our React header. A drag starting on that wrapper never traverses the inner header's capture handler. Reject non-title header drag starts at the outer Dock capture boundary, before Dockview's native listener. Regression tests must dispatch on the real `.dv-tab` and `.dv-tabs-and-actions-container` as well as the inner header, and separately prove title drags remain allowed. DSH marks `[tabindex]` and `[role=tab]` as native `no-drag` regions. On macOS, explicitly restore `drag` on these Dock header wrappers while keeping title, context, tabs and actions `no-drag`. A passing DOM drag test does not prove native window movement: check both blank-header window displacement and title-only Panel drops in a non-fullscreen Desktop. + +### Shared Select under whole-window zoom + +The shared `ds/Select` also uses `PointerMenu` after the native body-portaled menu was measured +outside the viewport at 144% and 430%, and detached from its trigger below 100%. Its trigger +supplies the measured left/bottom or right/bottom anchor, and the menu owner applies start/end +alignment, native frame clearance and viewport clamping. The owning dialog is the portal +container so native Modal keyboard ownership still applies. Zoom/style, resize and scroll +changes update the anchor; closing restores focus only if the next action did not move it. +Native Menu still owns option rendering, composition guards and keyboard traversal. Other +button-anchored native Menu callers are unchanged and require their own qualification. + +The coordinate owner uses CSSOM `currentCSSZoom` when available, with its existing rendered +probe fallback. A 1px probe is quantized at small zoom factors and cannot reliably convert a +long viewport distance in one placement. The qualified Desktop exposes `currentCSSZoom`; +[CSSOM View](https://www.w3.org/TR/cssom-view/#dom-element-currentcsszoom) defines it as the +effective CSS zoom. Available width also caps the native minimum so a choice menu can fit a +small viewport. This changes popup geometry, not the owning Panel's minimum constraints. + +Run `node scripts/check-select-zoom.mjs --output ` for the real production +Select/SettingsDialog/PointerMenu and unmodified reachable native Menu/Modal code and CSS. +The isolated source fixture covers all 17 levels, start/end alignment, page/dialog placement, +two viewport sizes, keyboard selection, menu-only Escape, focus return, outside clicks and +zoom changes while open. `--baseline ` runs the old Select and must fail on the reported +coordinate bug. It does not qualify a running DSH Host, native window controls or guest painting; +macOS acceptance remains a separate #2269 check. diff --git a/changes/2269-scaled-choice-menus.md b/changes/2269-scaled-choice-menus.md new file mode 100644 index 000000000..f3d0e6931 --- /dev/null +++ b/changes/2269-scaled-choice-menus.md @@ -0,0 +1,11 @@ +--- +section: Fixed +--- + +## en + +- Kept shared settings choice menus next to their controls and inside the window when zooming, with keyboard selection and focus return ([#2269](https://github.com/BotHarness/DeepSeekBot/issues/2269)). + +## zh + +- 修复整窗缩放后共享设置选项菜单偏离控件或超出窗口的问题,并保留键盘选择和关闭后的焦点返回([#2269](https://github.com/BotHarness/DeepSeekBot/issues/2269))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index e1e342422..7bcf21524 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -1173,3 +1173,8 @@ packaged-install and Human QA evidence remain separate from source coverage; num broader reliability and other desktop platforms are tracked in #2269/#2270. Bot-mode Dockview content overlays and splitter movement convert viewport coordinates to local CSS units through the repository-pinned Dockview compatibility patch. The patch is bundled in the Client; it changes no DSH executable and preserves persistent panel instances and the existing layout store. The macOS workspace corner separately retains the native 88×40 viewport-pixel traffic-light footprint and converts measured group intersections into local header padding; zoom, layout and fullscreen updates do not change that physical footprint. + +Bot-owned choice controls use the shared `ds/Select` → `PointerMenu` geometry owner under +window zoom. It keeps the native Menu renderer and keyboard owner inside the owning Modal, +converts viewport anchor coordinates through effective CSS zoom, clamps to the safe viewport +and returns focus after dismissal. No second shortcut router or Panel state is introduced. diff --git a/packages/client/src/client/ds/pointer-menu.tsx b/packages/client/src/client/ds/pointer-menu.tsx index 6c56e7266..eeaba5b12 100644 --- a/packages/client/src/client/ds/pointer-menu.tsx +++ b/packages/client/src/client/ds/pointer-menu.tsx @@ -30,17 +30,29 @@ export function pointerMenuPosition( type PointerMenuProps = Omit< ComponentProps, 'open' | 'portal' | 'getAnchorRect' | 'side' | 'align' | 'className' -> & { point: MenuPoint; container?: Element }; +> & { point: MenuPoint; container?: Element; align?: 'start' | 'end' }; -export function PointerMenu({ point, container = document.body, ...props }: PointerMenuProps) { +export function PointerMenu({ + point, + container = document.body, + align = 'start', + ...props +}: PointerMenuProps) { const mount = useMountedResource( (node) => { const menu = node.querySelector('[role="menu"]'); if (!menu) return; const originalMaxWidth = Number.parseFloat(getComputedStyle(menu).maxWidth); + const originalMinWidth = Number.parseFloat(getComputedStyle(menu).minWidth); const place = () => { const scale = node.getBoundingClientRect(); if (!scale.width || !scale.height) return; + const cssZoom = + 'currentCSSZoom' in node && typeof node.currentCSSZoom === 'number' + ? node.currentCSSZoom + : undefined; + const scaleX = cssZoom ?? scale.width; + const scaleY = cssZoom ?? scale.height; const root = document.documentElement; const clearance = root.hasAttribute('data-fullscreen') ? 0 @@ -48,18 +60,25 @@ export function PointerMenu({ point, container = document.body, ...props }: Poin getComputedStyle(root).getPropertyValue('--dsh-frame-top-clearance'), ) || 0; const margin = Number.parseFloat(space.x4); - menu.style.maxWidth = `${Math.min(originalMaxWidth || Infinity, (innerWidth - 2 * margin) / scale.width)}px`; - menu.style.maxHeight = `${Math.max(0, innerHeight - Math.max(margin, clearance) - margin) / scale.height}px`; + const availableWidth = Math.max(0, innerWidth - 2 * margin) / scaleX; + menu.style.minWidth = `${Math.min(originalMinWidth || 0, availableWidth)}px`; + menu.style.maxWidth = `${Math.min(originalMaxWidth || Infinity, availableWidth)}px`; + menu.style.maxHeight = `${Math.max(0, innerHeight - Math.max(margin, clearance) - margin) / scaleY}px`; const box = menu.getBoundingClientRect(); - const desired = pointerMenuPosition(point, box.width, box.height, { - width: innerWidth, - height: innerHeight, - top: clearance, - }); + const desired = pointerMenuPosition( + { x: align === 'end' ? point.x - box.width : point.x, y: point.y }, + box.width, + box.height, + { + width: innerWidth, + height: innerHeight, + top: clearance, + }, + ); const left = Number.parseFloat(node.style.left) || 0; const top = Number.parseFloat(node.style.top) || 0; - node.style.left = `${left + (desired.x - box.left) / scale.width}px`; - node.style.top = `${top + (desired.y - box.top) / scale.height}px`; + node.style.left = `${left + (desired.x - box.left) / scaleX}px`; + node.style.top = `${top + (desired.y - box.top) / scaleY}px`; }; place(); const observer = @@ -74,7 +93,7 @@ export function PointerMenu({ point, container = document.body, ...props }: Poin window.removeEventListener('scroll', place, true); }; }, - [point.x, point.y], + [point.x, point.y, align], ); return createPortal( diff --git a/packages/client/src/client/ds/select.tsx b/packages/client/src/client/ds/select.tsx index 1b3c198cc..f2672ecc9 100644 --- a/packages/client/src/client/ds/select.tsx +++ b/packages/client/src/client/ds/select.tsx @@ -1,10 +1,11 @@ -import { Menu } from '../appearance-primitives.js'; -import { useState, type ReactElement, type ReactNode } from 'react'; +import { PointerMenu, type MenuPoint } from './pointer-menu.js'; +import { useMountedResource } from '../mounted-resource.js'; +import { useRef, useState, type ReactElement, type ReactNode } from 'react'; import * as stylex from '@stylexjs/stylex'; import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'; import { pillStyles } from './pill-button.js'; -import { color } from './tokens.stylex.js'; +import { color, space } from './tokens.stylex.js'; const styles = stylex.create({ label: { @@ -47,41 +48,103 @@ export function Select({ reselect = false, }: SelectProps): ReactElement { const [open, setOpen] = useState(false); + const [point, setPoint] = useState(); + const trigger = useRef(null); + const active = open && !disabled; + const mount = useMountedResource( + (node) => { + trigger.current = node; + if (!active) + return () => { + trigger.current = null; + }; + const place = () => { + const rect = node.getBoundingClientRect(); + const scale = node.offsetHeight > 0 ? rect.height / node.offsetHeight : 1; + const next = { + x: align === 'end' ? rect.right : rect.left, + y: rect.bottom + Number.parseFloat(space.x2) * scale, + }; + setPoint((previous) => (previous?.x === next.x && previous.y === next.y ? previous : next)); + }; + place(); + const resize = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(place); + resize?.observe(node); + resize?.observe(document.documentElement); + const observer = new MutationObserver(place); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['style', 'data-fullscreen'], + }); + window.addEventListener('resize', place); + window.addEventListener('scroll', place, true); + return () => { + trigger.current = null; + resize?.disconnect(); + observer.disconnect(); + window.removeEventListener('resize', place); + window.removeEventListener('scroll', place, true); + }; + }, + [active, align], + ); + const close = () => { + const button = trigger.current; + const menu = document.activeElement?.closest('[data-pointer-menu]'); + setOpen(false); + setPoint(undefined); + queueMicrotask(() => { + const focused = document.activeElement; + if ( + button?.isConnected && + !button.disabled && + (focused === document.body || (focused !== null && menu?.contains(focused))) + ) + button.focus({ preventScroll: true }); + }); + }; const current = options.find((option) => option.value === value); return ( - ({ id: option.value, label: option.label }))} - selectedId={value} - onSelect={(id) => { - setOpen(false); - const next = options.find((option) => option.value === id); - if (next !== undefined && (reselect || next.value !== value)) onChange(next.value); - }} - onClose={() => { - setOpen(false); - }} - anchor={ - + {active && point ? ( +