From 60ed3b2b746faeccded1a079afc48f5d0271cc92 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 13:52:10 +0800 Subject: [PATCH 1/8] docs(notes): propose force-directed edge recovery for floating dock panels --- ...21-floating-window-edge-recovery.i18n.yaml | 40 ++++++ ...026-10-21-floating-window-edge-recovery.md | 120 ++++++++++++++++++ ...-10-21-floating-window-edge-recovery.zh.md | 120 ++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 .agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.md create mode 100644 .agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.zh.md diff --git a/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml b/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml new file mode 100644 index 0000000000..d03ffbf82e --- /dev/null +++ b/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for 2026-10-21-floating-window-edge-recovery.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.md +/agent-note-force-directed-edge-recovery-for-floating-dock-panels: + en: 7ba35ffa8515b87d + zh: 684a6bb16beb7742 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/problem: + en: a2b601d95a23d7bf + zh: f40f06ee37fae43f +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal: + en: 737f73d512805128 + zh: 1b7fc14d1effb2b8 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal/legal-region-and-displayed-position: + en: 7ccc0a74c0fcae41 + zh: 516f19ece43a6c1e +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal/drag-with-resistance: + en: 2706e0ded33ca233 + zh: e4de8d0566101d61 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal/release-and-spring: + en: 9fa63c723a756e0b + zh: 27f5b95a24619437 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal/reduced-motion: + en: 9858426f655a4530 + zh: faac7f7c9cfe91e6 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal/change-scope: + en: 930eea9ec6ba15c6 + zh: d4d2f40fd77a4c99 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/testing-plan: + en: 515b77c1a6073983 + zh: b4ff9be6d0204379 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/alternatives-considered: + en: c50b0c17a478178f + zh: 7bd46fedd967bd66 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/acceptance-criteria: + en: 51e57e5195c6b37d + zh: a5a5732c35415d81 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/risks: + en: b567ebe883f9d295 + zh: 7dc4d3cb72384b27 diff --git a/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.md b/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.md new file mode 100644 index 0000000000..f7f5dbb947 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.md @@ -0,0 +1,120 @@ +# Agent Note: Force-directed edge recovery for floating dock panels + +Status: proposed + +English | [中文](2026-10-21-floating-window-edge-recovery.zh.md) + +## Problem + +The dock kit's floating panels are the client's floating windows. `FloatLayer` and the retained `DockLayout` renderer draw them in viewport coordinates through one shared gesture hook, `useFloatGestures`, and the right Sidebar embeds them, so the same panels run in the Web client and inside the Desktop window. Their drag follows the pointer one to one with only a top floor (`--dsh-dockkit-float-top`, which keeps the header below the Windows caption). A panel can therefore be dragged past the left, right or bottom edge until its header is out of reach; a viewport that shrinks leaves a panel outside it; and the creation cascade can place a new panel outside a small viewport. The required behavior is that a panel pushed past an edge visibly resists, and on release is pulled back inside with spring physics instead of being stopped by a wall or left outside. + +We reverse engineered Claude Desktop 2.42304.2 as a reference, and it has no such behavior. On macOS its Quick Entry surface is a native Swift `NSPanel`; its binary contains animation primitives, but no edge-recovery rule could be established from it. The Electron fallback used on Windows/Linux is static: after a 750 ms input debounce it makes one instantaneous `setPosition` that clamps only the right and bottom edges (trigger margin 0, target margin 30 DIP, so the first correction jumps by overflow + 30) and never clamps left or top. No `spring` symbol exists in its main bundle; its only window-motion utilities are a 240 ms `easeInOutCubic` bounds tween for main-window size changes, Electron's macOS-only `setBounds(bounds, true)`, and a 300 ms `cubic-bezier(0.16, 1, 0.3, 1)` CSS vocabulary. The behavior has to be designed, not ported. + +The change must keep these obligations: a panel header is always reachable; one gesture still records exactly one history entry; reduced motion is honored; the persisted Sidebar layout format and every embedder stay unchanged; and the motion is deterministic enough to test without a real browser clock. + +## Proposal + +The behavior lives entirely in `packages/client/ui-dockkit`, inside `useFloatGestures` and the pure engine helpers it calls. Because both float renderers already take their rectangles from that hook, Web and Desktop gain the behavior together, and no embedder, shell or persisted format changes. + +### Legal region and displayed position + +The legal region of a panel is the viewport (`window.innerWidth` by `window.innerHeight`) with the existing top floor: the Windows caption height plus 20 px when `data-windows-titlebar` is set (20 px in native fullscreen), otherwise 0. A rectangle is legal when `x` lies in `[0, viewportWidth - width]` and `y` in `[top, viewportHeight - height]`. When a panel is wider or taller than the region, the minimum edge is pinned, so the header and its grip stay on screen and the excess hangs off the right or bottom. + +The displayed rectangle is the per-axis projection of the stored rectangle onto the legal region, computed at render. The stored rectangle is not rewritten: a shrinking viewport pushes the panel in immediately without recording an operation, and when the viewport grows again the panel returns toward its stored position. Persisted layouts that hold off-screen rectangles from before this change display inside the viewport without migration. + +### Drag with resistance + +A move gesture computes the desired rectangle from the gesture's start rectangle and the pointer travel, as today, then splits it per axis into the legal projection and the overflow `o` beyond it. The preview rectangle draws at `legal + sign(o) * K * |o| / (K + |o|)` with `K = 120` px. The mapping is continuous, has slope 1 at the edge, and approaches `K`, so the panel moves past the edge at first and resists more the further the pointer goes: + +| pointer overflow (px) | panel displacement past the edge (px) | share of the overflow | +|---|---|---| +| 10 | 9.2 | 92.3% | +| 40 | 30.0 | 75.0% | +| 120 | 60.0 | 50.0% | +| 400 | 92.3 | 23.1% | + +A resize gesture clamps the bottom-right corner to the legal region and keeps `FLOAT_MIN_SIZE`; resizing has no resistance, because a corner that lags the pointer reads as a broken handle. + +### Release and spring + +On release the gesture records one `moveFloat` with the legal rectangle, so history and persistence always store a legal position. If the preview was outside the legal region, the hook keeps drawing a preview and animates it from the released position to the recorded one with a spring, then clears it. A release inside the legal region records the move and animates nothing. + +The spring is underdamped, because its bounce goes inward and is always legal: response 0.32 s and damping ratio 0.72 (`omega0 = 2 * pi / 0.32`). The initial velocity is the pointer velocity over the last 100 ms of the drag, capped at 1500 px/s, so a fling past the edge comes back faster than a slow push. Per axis the update is: + +``` +a = -omega0^2 * (x - target) - 2 * zeta * omega0 * v +v = v + a * h +x = x + v * h +``` + +The integrator uses a fixed substep `h = 1/480 s`. One `requestAnimationFrame` loop in the hook splits each frame's elapsed time into equal substeps of at most `h`, clamps a frame to 64 ms, and snaps to the target when a frame gap exceeds 250 ms. It stops and draws the exact target when each axis is within 1 px with speed below 60 px/s, or after 900 ms. Simulated from rest, a 200 px overflow settles in 367 ms with a 6.9 px (3.5%) inward overshoot, within the analytic bound `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%`; the resistance caps the released overflow at 120 px, so real releases settle faster. The integrator is pure and takes `dt`, so the engine keeps its rule of reading no clock. + +A press on a panel during its animation cancels the animation and starts the new gesture from the rectangle being drawn. At most one animation runs per panel, and unmounting the layer cancels its frame request. + +### Reduced motion + +When `matchMedia('(prefers-reduced-motion: reduce)')` matches, a move gesture clamps to the legal region with no resistance, and release records the legal rectangle with no animation. The rule only removes motion; it never removes the legality correction. + +### Change scope + +The implementation changes one package, `@deepseek-ai/dsh-client-ui-dockkit`, and leaves its exports and `DockIntents` unchanged. + +| File | Change | +|---|---| +| `packages/client/ui-dockkit/src/engine/geometry.ts` | add the legal projection and the resistance mapping | +| `packages/client/ui-dockkit/src/engine/` | new `spring.ts`: the pure spring integrator and its settle test | +| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`: displayed projection, resistance, release spring, velocity sampling, reduced motion, frame loop and cancellation | +| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | draw the hook's displayed rectangle instead of the stored one | +| `packages/client/ui-dockkit/tests/` | geometry, spring, `FloatLayer` and `TabLayout` specs | +| `packages/client/ui-dockkit/README.md` pair | the edge rule under the interaction rules | + +`ui-sidebar-right` and its persisted layout schema, `apps/web`, `apps/desktop`, the shared primitives and the theme are untouched. No dependency is added. + +## Testing plan + +Pure tests cover the projection (inside, each edge, corner, oversized panel, the Windows top floor), the resistance mapping (continuous, monotone, slope 1 at the edge, bounded by `K`), and the spring with fixed time steps (deterministic trajectories, exact final target, inward overshoot within one percentage point of the analytic bound, settling inside the 900 ms deadline, cadence robustness at 30, 60 and 120 Hz, and the 250 ms stall snap). + +Component tests run `FloatLayer` and `TabLayout` under jsdom with a fake `requestAnimationFrame` and clock: a drag past each edge draws the resisted preview; release records exactly one `moveFloat` with the legal rectangle; the preview animates to it and clears; a press mid-animation cancels it and starts from the drawn rectangle; reduced motion clamps and never schedules a frame; a viewport resize moves the displayed panel without calling any intent; unmounting cancels the frame. The per-file coverage gate applies to every changed source file. + +A browser check in the Web client drags a Sidebar floating panel past each edge and corner, flings it, resizes the window under it, and repeats with reduced motion; the PR records it as a GIF. Desktop loads the same client bundle, so one manual pass there confirms the Windows caption floor. + +No keyless recorded-session snapshot is required: nothing model-visible changes and no `SessionEventMap` member is added. Documentation gates: `doc-sync`. + +## Alternatives considered + +**Hard clamp at the edge.** Stopping the panel at the viewport edge is the cheapest correct rule, but it feels like hitting a wall and gives no sense of the boundary before contact. It remains as the reduced-motion path. + +**Clamp during the drag and rebound on release.** Clamping the drag at the edge and converting the outward pointer speed into an inward bounce keeps every frame inside the viewport. It hides how far the user pushed, so the bounce has no visible cause; the resistance mapping shows the push and still bounds the overflow at 120 px. + +**Eased tween on release.** A fixed 240-300 ms ease-out from the released position is predictable, but it carries no velocity, so a fling and a slow push return identically, and a press during it either jumps or restarts. The spring keeps velocity continuous and is interruptible. + +**Store the off-screen position and correct it later.** Recording the released rectangle and fixing it on the next render would persist off-screen positions and make undo restore them. Recording the legal rectangle keeps one history entry per gesture with a legal value. + +**Rewrite stored rectangles when the viewport shrinks.** Moving panels through operations on resize would add history entries the user never made and change persisted layouts on every window resize. Projecting at render achieves the same visible result without either. + +**Native always-on-top OS window.** A real Electron floating window could float above other applications, but only on Desktop; the Web client cannot do it at all, and native frameless dragging gives no per-frame control, so the resistance would be impossible and the spring could only start after release. It is a separate product decision with its own window, IPC and platform work, and this note does not preclude it. + +**Animation library dependency.** A spring library would replace a pure integrator of a few dozen lines and its tests with a new dependency in a statically linked package. The integrator also has to be clock-free for the engine's deterministic tests. + +**A shared primitive in `ui-primitives`.** Placing the hook in the primitives package would widen the change to a second package with no second consumer today. It can move there when another floating surface needs it. + +## Acceptance criteria + +- Dragging a floating panel past any viewport edge draws it at the resisted displacement, never more than 120 px beyond the edge; reduced motion clamps it at the edge instead. +- Releasing it outside the legal region records exactly one `moveFloat` whose rectangle is legal, and the panel springs to that rectangle within 900 ms, ending at the exact recorded position. +- Undo after such a gesture restores the pre-gesture position in one step, and the persisted layout never contains a rectangle that was illegal when it was recorded. +- Releasing inside the legal region behaves as today: one operation and no animation. +- Pressing a panel during its animation stops the animation and the new gesture starts from the rectangle on screen. +- Shrinking the viewport keeps every panel header on screen without recording an operation; growing it returns the panel toward its stored position. +- Under the Windows caption the header stays at least 20 px below the caption, as today. +- No package other than `@deepseek-ai/dsh-client-ui-dockkit` changes, and its exports and `DockIntents` are unchanged. + +## Risks + +**One render per animation frame.** The spring updates the preview through React state, which costs the same per frame as a drag does today, but a panel hosting heavy content re-renders for up to 900 ms after release. If measurement shows dropped frames, the loop can write a transform on the panel element directly without changing the rule. + +**Elastic return after the viewport grows.** A panel pushed in by a shrinking window returns toward its stored position when the window grows. That is the intended consequence of not rewriting stored rectangles, but a user who expects the panel to stay where it was pushed may find it surprising. + +**Embedded content under motion.** Panels can host a webview or an iframe that receives pointer events. The spring runs only after release and a press cancels it, but content that reacts to its own movement could observe up to 900 ms of motion. + +**Touch remains untuned.** The resistance and the velocity sample come from pointer events and work for touch input, but the constants are tuned for a mouse or trackpad; the README already lists touch as untuned. diff --git a/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.zh.md b/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.zh.md new file mode 100644 index 0000000000..4b73e4098a --- /dev/null +++ b/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.zh.md @@ -0,0 +1,120 @@ +# Agent Note: 悬浮停靠面板的力导向边界收回 + +Status: proposed + +[English](2026-10-21-floating-window-edge-recovery.md) | 中文 + +## 问题 + +dock kit 的悬浮面板就是客户端里的悬浮窗。`FloatLayer` 与保留式 `DockLayout` 渲染器通过同一个手势 hook `useFloatGestures` 以视口坐标绘制它们,右侧 Sidebar 嵌入了它们,因此同一套面板既运行在 Web 客户端中,也运行在 Desktop 窗口内。它们的拖拽一比一跟随指针,只有一个顶部下限(`--dsh-dockkit-float-top`,让标题栏保持在 Windows 标题栏之下)。因此面板可以被拖过左、右或下边界,直到标题栏够不着;视口缩小会把面板留在外面;新建面板的级联位置也可能落在小视口之外。期望行为是:面板被推过边界时可见地受到阻力,松手后以弹簧物理被拉回视口内,而不是像撞墙一样被挡住或被留在外面。 + +我们逆向分析了参考实现 Claude Desktop 2.42304.2,它没有这种行为。在 macOS 上它的 Quick Entry 是原生 Swift `NSPanel`;其二进制包含动画原语,但无法从中确认任何边界收回规则。Windows/Linux 上使用的 Electron 回退是静态的:输入防抖 750 ms 后做一次瞬时 `setPosition`,只裁剪右边界和下边界(触发边距为 0、目标边距为 30 DIP,因此首次修正在溢出量之外再跳 30),从不裁剪左边界和上边界。其主 bundle 中不存在 `spring` 符号;仅有的窗口运动工具是用于主窗口尺寸变化的 240 ms `easeInOutCubic` 边界补间、Electron 仅 macOS 可用的 `setBounds(bounds, true)`,以及 300 ms `cubic-bezier(0.16, 1, 0.3, 1)` 的 CSS 运动语汇。该行为必须被设计,而不是移植。 + +变更必须维持以下义务:面板标题栏始终可达;一个手势仍然只记录一条历史;遵循减弱动态效果;Sidebar 的持久化布局格式与所有嵌入方保持不变;运动足够确定,无需真实浏览器时钟即可测试。 + +## 提案 + +该行为完全位于 `packages/client/ui-dockkit` 内,在 `useFloatGestures` 及其调用的纯 engine 辅助函数中实现。由于两个悬浮渲染器都已从该 hook 取得矩形,Web 与 Desktop 同时获得该行为,不改动任何嵌入方、壳或持久化格式。 + +### 合法区域与显示位置 + +面板的合法区域是视口(`window.innerWidth` × `window.innerHeight`)加上既有的顶部下限:设置了 `data-windows-titlebar` 时为 Windows 标题栏高度加 20 px(原生全屏时为 20 px),否则为 0。当 `x` 位于 `[0, viewportWidth - width]` 且 `y` 位于 `[top, viewportHeight - height]` 时矩形合法。面板比区域更宽或更高时钉住最小边,使标题栏及其抓手留在屏幕上,多余部分悬在右侧或下方。 + +显示矩形是存储矩形在合法区域上的逐轴投影,在渲染时计算。存储矩形不会被改写:视口缩小时面板立即被推入,且不记录操作;视口再次变大时面板回到存储位置。本变更之前持久化下来、包含屏幕外矩形的布局无需迁移即在视口内显示。 + +### 带阻力的拖拽 + +移动手势照旧由手势起始矩形和指针位移计算期望矩形,然后逐轴拆分为合法投影与超出它的溢出量 `o`。预览矩形绘制在 `legal + sign(o) * K * |o| / (K + |o|)`,其中 `K = 120` px。该映射连续、在边界处斜率为 1、渐近于 `K`,因此面板起初会越过边界,指针走得越远阻力越大: + +| 指针溢出(px) | 面板越过边界的位移(px) | 占溢出的比例 | +|---|---|---| +| 10 | 9.2 | 92.3% | +| 40 | 30.0 | 75.0% | +| 120 | 60.0 | 50.0% | +| 400 | 92.3 | 23.1% | + +缩放手势把右下角钳制在合法区域内并保持 `FLOAT_MIN_SIZE`;缩放不加阻力,因为落后于指针的角看起来像坏掉的手柄。 + +### 松手与弹簧 + +松手时手势以合法矩形记录一次 `moveFloat`,因此历史与持久化始终存储合法位置。如果预览在合法区域之外,hook 继续绘制预览,用弹簧把它从松手位置动画到记录的位置,然后清除预览。在合法区域内松手则记录移动且不播放动画。 + +弹簧是欠阻尼的,因为它的回弹朝向内侧,始终合法:响应时间 0.32 s、阻尼比 0.72(`omega0 = 2 * pi / 0.32`)。初速度取拖拽最后 100 ms 的指针速度,上限 1500 px/s,因此甩过边界的面板比慢慢推过去的回来得更快。每轴更新为: + +``` +a = -omega0^2 * (x - target) - 2 * zeta * omega0 * v +v = v + a * h +x = x + v * h +``` + +积分器使用固定子步 `h = 1/480 s`。hook 中唯一的 `requestAnimationFrame` 循环把每帧经过的时间拆成等长且不超过 `h` 的子步,单帧钳制到 64 ms,帧间隔超过 250 ms 时直接跳到目标。当每轴距离在 1 px 内且速度低于 60 px/s,或经过 900 ms 后,它停止并绘制精确目标。从静止开始仿真,200 px 溢出在 367 ms 内稳定,向内过冲 6.9 px(3.5%),在解析上限 `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%` 之内;阻力把松手时的溢出限制在 120 px 内,因此真实松手稳定得更快。积分器是纯函数并接收 `dt`,因此 engine 保持不读取时钟的规则。 + +动画期间按下面板会取消动画,并从正在绘制的矩形开始新手势。每个面板最多运行一个动画,卸载该层会取消其帧请求。 + +### 减弱动态效果 + +`matchMedia('(prefers-reduced-motion: reduce)')` 匹配时,移动手势钳制在合法区域内且无阻力,松手时记录合法矩形且不播放动画。该规则只移除运动,从不移除合法性修正。 + +### 改动范围 + +实现只修改一个包 `@deepseek-ai/dsh-client-ui-dockkit`,其导出与 `DockIntents` 保持不变。 + +| 文件 | 变更 | +|---|---| +| `packages/client/ui-dockkit/src/engine/geometry.ts` | 新增合法投影与阻力映射 | +| `packages/client/ui-dockkit/src/engine/` | 新增 `spring.ts`:纯弹簧积分器及其稳定判定 | +| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`:显示投影、阻力、松手弹簧、速度采样、减弱动态效果、帧循环与取消 | +| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | 绘制 hook 给出的显示矩形而不是存储矩形 | +| `packages/client/ui-dockkit/tests/` | 几何、弹簧、`FloatLayer` 与 `TabLayout` 测试 | +| `packages/client/ui-dockkit/README.md` 对 | 在交互规则下补充边界规则 | + +`ui-sidebar-right` 及其持久化布局 schema、`apps/web`、`apps/desktop`、共享基础组件与主题均不改动。不新增依赖。 + +## 测试计划 + +纯测试覆盖投影(内部、每条边、角、超尺寸面板、Windows 顶部下限)、阻力映射(连续、单调、边界处斜率为 1、以 `K` 为上界),以及固定时间步下的弹簧(轨迹确定、最终目标精确、向内过冲与解析上限相差不超过一个百分点、在 900 ms 截止时间内稳定、30/60/120 Hz 节奏鲁棒,以及 250 ms 停顿直接跳转)。 + +组件测试在 jsdom 中用假的 `requestAnimationFrame` 与时钟运行 `FloatLayer` 和 `TabLayout`:拖过每条边绘制带阻力的预览;松手恰好记录一次带合法矩形的 `moveFloat`;预览动画到该矩形后清除;动画中按下会取消动画并从绘制中的矩形开始;减弱动态效果下钳制且从不调度帧;视口缩放移动显示面板而不调用任何 intent;卸载取消帧请求。逐文件覆盖率门禁适用于每个改动的源文件。 + +在 Web 客户端中进行浏览器检查:把 Sidebar 悬浮面板拖过每条边与每个角、甩出去、在其下方缩放窗口,并在减弱动态效果下重复;PR 以 GIF 记录。Desktop 加载同一份客户端 bundle,因此在那里手动走一遍即可确认 Windows 标题栏下限。 + +不需要 keyless 录制会话快照:没有模型可见的变化,也没有新增 `SessionEventMap` 成员。文档门禁:`doc-sync`。 + +## 考虑过的替代方案 + +**在边界处硬钳制。** 在视口边界停住面板是成本最低的正确规则,但手感像撞墙,接触之前也感觉不到边界。它保留为减弱动态效果的路径。 + +**拖拽中钳制、松手时回弹。** 在边界处钳制拖拽,并把向外的指针速度转换为向内的回弹,能让每一帧都留在视口内。但它隐藏了用户推了多远,回弹因此没有可见的起因;阻力映射显示了推动,同时把溢出限制在 120 px 内。 + +**松手后的缓动补间。** 从松手位置开始的固定 240–300 ms 缓出补间可预测,但不携带速度,因此甩出与慢推的返回完全相同,补间中按下要么跳变要么重启。弹簧保持速度连续且可打断。 + +**存储屏幕外位置并在之后修正。** 记录松手矩形、在下一次渲染时修正,会持久化屏幕外位置,并让撤销恢复它们。记录合法矩形则保持每个手势一条历史且值合法。 + +**视口缩小时改写存储矩形。** 在缩放时通过操作移动面板会增加用户从未做过的历史条目,并在每次窗口缩放时改变持久化布局。渲染时投影无需二者即可得到相同的可见结果。 + +**原生始终置顶 OS 窗口。** 真正的 Electron 悬浮窗可以浮在其他应用之上,但只在 Desktop 上可行;Web 客户端完全无法做到,而且原生无边框拖拽不提供逐帧控制,因此阻力无法实现,弹簧只能在松手后启动。它是一个独立的产品决策,有自己的窗口、IPC 与平台工作,本 Note 不排斥它。 + +**动画库依赖。** 弹簧库会用一个静态链接包里的新依赖替换几十行的纯积分器及其测试。积分器还必须不依赖时钟,才能用于 engine 的确定性测试。 + +**放进 `ui-primitives` 的共享基础组件。** 把 hook 放进基础组件包会把改动扩大到第二个包,而目前没有第二个使用方。等另一个悬浮界面需要它时再迁移。 + +## 验收标准 + +- 把悬浮面板拖过任一视口边界时,它绘制在带阻力的位移处,越过边界不超过 120 px;减弱动态效果下改为钳制在边界处。 +- 在合法区域外松手恰好记录一次矩形合法的 `moveFloat`,面板在 900 ms 内弹到该矩形,最终停在精确的记录位置。 +- 这样的手势之后撤销一步即可恢复手势前的位置,持久化布局中从不包含记录时就不合法的矩形。 +- 在合法区域内松手的行为与现在相同:一次操作,无动画。 +- 动画期间按下面板会停止动画,新手势从屏幕上的矩形开始。 +- 视口缩小时每个面板标题栏都留在屏幕上且不记录操作;视口变大时面板回到存储位置。 +- 在 Windows 标题栏下,标题栏与现在一样至少保持在其下方 20 px。 +- 除 `@deepseek-ai/dsh-client-ui-dockkit` 外没有其他包改动,其导出与 `DockIntents` 不变。 + +## 风险 + +**每个动画帧一次渲染。** 弹簧通过 React state 更新预览,每帧成本与现在拖拽时相同,但承载重内容的面板在松手后最多会重渲染 900 ms。如果测量显示掉帧,循环可以直接在面板元素上写 transform,而不改变规则。 + +**视口变大后的弹性返回。** 被缩小窗口推入的面板会在窗口变大时回到存储位置。这是不改写存储矩形的预期结果,但期望面板留在被推到的位置的用户可能会感到意外。 + +**运动中的嵌入内容。** 面板可以承载接收指针事件的 webview 或 iframe。弹簧只在松手后运行,按下即取消,但会对自身移动作出反应的内容可能观察到最多 900 ms 的运动。 + +**触控仍未调优。** 阻力与速度采样来自 pointer 事件,适用于触控输入,但常量是针对鼠标或触控板调优的;README 已将触控列为未调优。 From 7bfe4ad07f72fb199e705cfc72c9b01824039fd3 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 15:17:07 +0800 Subject: [PATCH 2/8] feat(ui-dockkit): spring floating panels back inside the viewport --- ...21-floating-window-edge-recovery.i18n.yaml | 65 +++-- ...026-10-21-floating-window-edge-recovery.md | 100 +++++++ ...-10-21-floating-window-edge-recovery.zh.md | 100 +++++++ ...026-10-21-floating-window-edge-recovery.md | 120 -------- ...-10-21-floating-window-edge-recovery.zh.md | 120 -------- packages/client/ui-dockkit/README.i18n.yaml | 4 +- packages/client/ui-dockkit/README.md | 2 + packages/client/ui-dockkit/README.zh.md | 2 + .../ui-dockkit/src/components/FloatLayer.tsx | 179 ++++++++++-- .../ui-dockkit/src/components/TabLayout.tsx | 2 +- .../client/ui-dockkit/src/engine/geometry.ts | 88 ++++++ .../client/ui-dockkit/src/engine/spring.ts | 60 ++++ .../tests/float-bounds.client.spec.ts | 107 +++++++ .../tests/float-recovery.client.spec.tsx | 263 ++++++++++++++++++ .../ui-dockkit/tests/spring.client.spec.ts | 63 +++++ 15 files changed, 979 insertions(+), 296 deletions(-) rename .agents/notes/{proposed => implemented}/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml (51%) create mode 100644 .agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md create mode 100644 .agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.md delete mode 100644 .agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.zh.md create mode 100644 packages/client/ui-dockkit/src/engine/spring.ts create mode 100644 packages/client/ui-dockkit/tests/float-bounds.client.spec.ts create mode 100644 packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx create mode 100644 packages/client/ui-dockkit/tests/spring.client.spec.ts diff --git a/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml similarity index 51% rename from .agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml rename to .agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml index d03ffbf82e..432be6dadf 100644 --- a/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml @@ -1,40 +1,37 @@ # Bilingual-pair consistency record for 2026-10-21-floating-window-edge-recovery.md (docs/i18n/README.md): per heading # section, a hash of its English and Chinese blocks outside code blocks and generated regions. # After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.md +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md /agent-note-force-directed-edge-recovery-for-floating-dock-panels: - en: 7ba35ffa8515b87d - zh: 684a6bb16beb7742 + en: 3742208bc57c921a + zh: 1032661b2f07c813 /agent-note-force-directed-edge-recovery-for-floating-dock-panels/problem: - en: a2b601d95a23d7bf - zh: f40f06ee37fae43f -/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal: - en: 737f73d512805128 - zh: 1b7fc14d1effb2b8 -/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal/legal-region-and-displayed-position: - en: 7ccc0a74c0fcae41 - zh: 516f19ece43a6c1e -/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal/drag-with-resistance: - en: 2706e0ded33ca233 - zh: e4de8d0566101d61 -/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal/release-and-spring: - en: 9fa63c723a756e0b - zh: 27f5b95a24619437 -/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal/reduced-motion: - en: 9858426f655a4530 - zh: faac7f7c9cfe91e6 -/agent-note-force-directed-edge-recovery-for-floating-dock-panels/proposal/change-scope: - en: 930eea9ec6ba15c6 - zh: d4d2f40fd77a4c99 -/agent-note-force-directed-edge-recovery-for-floating-dock-panels/testing-plan: - en: 515b77c1a6073983 - zh: b4ff9be6d0204379 + en: ed30f1f207a4b288 + zh: 319bbacba5ce71a6 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision: + en: c5ded7c065d856c6 + zh: a1cf89d15728a2d5 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision/legal-region-and-displayed-position: + en: 5a2dd1efef511c37 + zh: 81628ab5cf8ecf11 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision/drag-with-resistance: + en: 44149d1bb485b486 + zh: 12b3c05fe87f2ad9 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision/release-and-spring: + en: b7657b6be917b6a3 + zh: 1399ffdda70d5d48 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision/reduced-motion: + en: 7e29bf1c45b5386d + zh: c913a2ce6d1722d1 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision/change-scope: + en: 836645900d530e43 + zh: bba46c2df47c8c81 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/testing: + en: 4d823bf96a215c77 + zh: 45bb98c308bba55c /agent-note-force-directed-edge-recovery-for-floating-dock-panels/alternatives-considered: - en: c50b0c17a478178f - zh: 7bd46fedd967bd66 -/agent-note-force-directed-edge-recovery-for-floating-dock-panels/acceptance-criteria: - en: 51e57e5195c6b37d - zh: a5a5732c35415d81 -/agent-note-force-directed-edge-recovery-for-floating-dock-panels/risks: - en: b567ebe883f9d295 - zh: 7dc4d3cb72384b27 + en: 3d901f0f5e270e29 + zh: c1fb89b567ea3c70 +/agent-note-force-directed-edge-recovery-for-floating-dock-panels/consequences: + en: 02ef970d57f83067 + zh: 3c120262e98a0fe6 diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md new file mode 100644 index 0000000000..312fc1fa92 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md @@ -0,0 +1,100 @@ +# Agent Note: Force-directed edge recovery for floating dock panels + +Status: implemented + +English | [中文](2026-10-21-floating-window-edge-recovery.zh.md) + +## Problem + +The dock kit's floating panels are the client's floating windows. `FloatLayer` and the retained `DockLayout` renderer draw them in viewport coordinates through one shared gesture hook, `useFloatGestures`, and the right Sidebar embeds them, so the same panels run in the Web client and inside the Desktop window. Their drag followed the pointer one to one with only a top floor (`--dsh-dockkit-float-top`, which keeps the header below the Windows caption). A panel could therefore be dragged past the left, right or bottom edge until its header was out of reach; a viewport that shrank left a panel outside it; and the creation cascade could place a new panel outside a small viewport. The required behavior is that a panel pushed past an edge visibly resists, and on release is pulled back inside with spring physics instead of being stopped by a wall or left outside. + +We reverse engineered Claude Desktop 2.42304.2 as a reference, and it has no such behavior. On macOS its Quick Entry surface is a native Swift `NSPanel`; its binary contains animation primitives, but no edge-recovery rule could be established from it. The Electron fallback used on Windows/Linux is static: after a 750 ms input debounce it makes one instantaneous `setPosition` that clamps only the right and bottom edges (trigger margin 0, target margin 30 DIP, so the first correction jumps by overflow + 30) and never clamps left or top. No `spring` symbol exists in its main bundle; its only window-motion utilities are a 240 ms `easeInOutCubic` bounds tween for main-window size changes, Electron's macOS-only `setBounds(bounds, true)`, and a 300 ms `cubic-bezier(0.16, 1, 0.3, 1)` CSS vocabulary. The behavior had to be designed, not ported. + +The change keeps these obligations: a panel header is always reachable; one gesture still records at most one history entry; reduced motion is honored; the persisted Sidebar layout format and every embedder stay unchanged; and the motion is deterministic enough to test without a real browser clock. + +## Decision + +The behavior lives entirely in `packages/client/ui-dockkit`, inside `useFloatGestures` and the pure engine helpers it calls. Both float renderers take their rectangles from that hook through `rectOf`, so Web and Desktop share the behavior, and no embedder, shell or persisted format changed. + +### Legal region and displayed position + +The legal region of a panel is the viewport (`window.innerWidth` by `window.innerHeight`) with the existing top floor: the Windows caption height plus 20 px when `data-windows-titlebar` is set (20 px in native fullscreen), otherwise 0. `floatBounds` reads only these values and the root's attributes, so starting a gesture forces no layout. `legalFloatRect` projects a rectangle into the region per axis; when a panel is wider or taller than the region it pins the minimum edge, so the header and its grip stay on screen and the excess hangs off the right or bottom. + +A panel draws at its stored rectangle projected into the viewport. The stored rectangle is never rewritten for the viewport: a shrinking window pushes the panel in without recording an operation, and a growing window returns it toward its stored position. The hook re-reads the bounds on every window `resize` event. Persisted layouts that hold off-screen rectangles display inside the viewport without migration. + +### Drag with resistance + +A move computes the desired rectangle from the gesture's start rectangle and the pointer travel, then `resistedRect` splits it per axis into the legal projection and the overflow `o` beyond it and draws it at `legal + sign(o) * K * |o| / (K + |o|)` with `K = FLOAT_RESISTANCE = 120` px. The mapping is continuous, has slope 1 at the edge, and approaches `K`: + +| pointer overflow (px) | panel displacement past the edge (px) | share of the overflow | +|---|---|---| +| 10 | 9.2 | 92.3% | +| 40 | 30.0 | 75.0% | +| 120 | 60.0 | 50.0% | +| 400 | 92.3 | 23.1% | + +A resize clamps the bottom-right corner to the region through `boundedResize` and keeps `FLOAT_MIN_SIZE`; resizing has no resistance, because a corner that lags the pointer reads as a broken handle. + +### Release and spring + +A release records the legal rectangle: `moveFloat` or `resizeFloat` when it differs from the stored one, otherwise only the raise a click records. History and persistence therefore always hold a legal position, and undo restores the pre-gesture position in one step. When the panel was drawn outside the legal region, the hook keeps a preview and springs it from where it was drawn to the recorded rectangle, then clears the preview. A release inside the region animates nothing. + +The spring in `engine/spring.ts` is underdamped, because its bounce goes inward and is always legal: response 0.32 s and damping ratio 0.72. An overflowing axis starts with the pointer velocity over the last 100 ms of the drag, capped at `SPRING_MAX_VELOCITY` (1500 px/s), so a fling past the edge travels further before it turns back; an axis that did not overflow keeps still. Per axis the update is: + +``` +a = -omega0^2 * (x - target) - 2 * zeta * omega0 * v +v = v + a * h +x = x + v * h +``` + +`stepSpring` integrates in equal substeps of at most `h = 1/480 s`, so the motion does not depend on the frame rate. One `requestAnimationFrame` loop in the hook clamps a frame to 64 ms, ends at the target when a frame gap exceeds 250 ms or 900 ms have passed, and otherwise stops when each axis is within 1 px of the target and slower than 60 px/s. From rest, a 200 px overflow settles in 367 ms with an inward overshoot within one percentage point of the analytic bound `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%`; the resistance caps the released overflow at 120 px. The integrator is pure and takes `dt`, so the engine keeps its rule of reading no clock. + +A press on the grip or the corner during a return cancels it. A move picks the panel up where it is drawn: `unresistedRect` inverts the resistance, so zero pointer travel draws the same rectangle, and a release in place resumes the return without recording a second operation. One hook runs at most one return; a window resize ends a running return at its recorded rectangle, and unmounting cancels the pending frame. + +### Reduced motion + +When `matchMedia('(prefers-reduced-motion: reduce)')` matches, a move clamps to the legal region with no resistance, and release records the legal rectangle with no animation. Environments without `matchMedia` report no preference. The rule only removes motion; it never removes the legality correction. + +### Change scope + +The change touches one package, `@deepseek-ai/dsh-client-ui-dockkit`, and leaves its exports and `DockIntents` unchanged. + +| File | Change | +|---|---| +| `packages/client/ui-dockkit/src/engine/geometry.ts` | `FloatBounds`, `FLOAT_RESISTANCE`, `legalFloatRect`, `resistedRect`, `unresistedRect`, `boundedResize` | +| `packages/client/ui-dockkit/src/engine/spring.ts` | the pure spring integrator, its rest test and its timing limits | +| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`: viewport projection, resistance, release spring, velocity sampling, reduced motion, frame loop and cancellation; `rectOf` for both renderers | +| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | draws the hook's displayed rectangle | +| `packages/client/ui-dockkit/README.md` pair | the edge rule beside the float renderer description | + +`ui-sidebar-right` and its persisted layout schema, `apps/web`, `apps/desktop`, the shared primitives and the theme are untouched. No dependency was added. + +## Testing + +`tests/float-bounds.client.spec.ts` covers the projection (inside, each edge, corner, the top floor, an oversized panel), the resistance (the table values, slope 1 at the edge, monotone growth bounded by `K`, independent axes), its inverse and its cap, and the bounded resize. `tests/spring.client.spec.ts` covers settling inside the deadline, the overshoot bound, frame-rate independence at 30, 60 and 120 Hz, the effect of release velocity, and determinism. + +`tests/float-recovery.client.spec.tsx` renders `FloatLayer` over a live controller with hand-driven frames and clock: the resisted preview past an edge, exactly one legal `moveFloat` per release and the spring to it with an inward overshoot, undo in one step, fling against slow push, no animation for a release inside, a push that ends at the stored position, a press mid-return picking the panel up without a jump, the stall snap, the deadline, unmount cancellation, both reduced-motion answers, the viewport projection on shrink and growth without any intent, a resize ending a return, and an off-screen stored rectangle drawn inside. The existing `FloatLayer` and `TabLayout` specs still pass unchanged, including the rule that starting a gesture forces no layout read; every changed source file is fully covered. + +## Alternatives considered + +**Hard clamp at the edge.** Stopping the panel at the viewport edge is the cheapest correct rule, but it feels like hitting a wall and gives no sense of the boundary before contact. It remains as the reduced-motion path. + +**Clamp during the drag and rebound on release.** Clamping the drag at the edge and converting the outward pointer speed into an inward bounce keeps every frame inside the viewport. It hides how far the user pushed, so the bounce has no visible cause; the resistance shows the push and still bounds the overflow at 120 px. + +**Eased tween on release.** A fixed 240-300 ms ease-out from the released position is predictable, but it carries no velocity, so a fling and a slow push return identically, and a press during it either jumps or restarts. The spring keeps velocity continuous and is interruptible. + +**Store the off-screen position and correct it later.** Recording the released rectangle and fixing it on the next render would persist off-screen positions and make undo restore them. Recording the legal rectangle keeps one history entry per gesture with a legal value. + +**Rewrite stored rectangles when the viewport shrinks.** Moving panels through operations on resize would add history entries the user never made and change persisted layouts on every window resize. Projecting at render achieves the visible result without either, at the cost of the elastic return described below. + +**Native always-on-top OS window.** A real Electron floating window could float above other applications, but only on Desktop; the Web client cannot do it at all, and native frameless dragging gives no per-frame control, so the resistance would be impossible and the spring could only start after release. It is a separate product decision with its own window, IPC and platform work, and this decision does not preclude it. + +**Animation library dependency.** A spring library would replace a pure integrator of a few dozen lines and its tests with a new dependency in a statically linked package. The integrator also has to be clock-free for the engine's deterministic tests. + +**A shared primitive in `ui-primitives`.** Placing the hook in the primitives package would widen the change to a second package with no second consumer. It can move there when another floating surface needs it. + +## Consequences + +Floating panels can no longer be lost off screen, in either client, and a push past an edge reads as a pull back rather than a wall. History, persistence and every embedder are unaffected, and the motion is covered by deterministic tests instead of manual checks. + +The spring updates the preview through React state, which costs the same per frame as a drag, but a panel hosting heavy content re-renders for up to 900 ms after release; writing a transform on the panel element directly is the fallback if that measures as a problem. A panel pushed in by a shrinking window returns toward its stored position when the window grows, which a user who expects it to stay where it was pushed may find surprising. Content inside a panel, such as a webview, can observe up to 900 ms of motion after release. The constants are tuned for a mouse or trackpad; touch input works through the same pointer events but remains untuned. diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md new file mode 100644 index 0000000000..a61104cdf9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md @@ -0,0 +1,100 @@ +# Agent Note: 悬浮停靠面板的力导向边界收回 + +Status: implemented + +[English](2026-10-21-floating-window-edge-recovery.md) | 中文 + +## 问题 + +dock kit 的悬浮面板就是客户端里的悬浮窗。`FloatLayer` 与保留式 `DockLayout` 渲染器通过同一个手势 hook `useFloatGestures` 以视口坐标绘制它们,右侧 Sidebar 嵌入了它们,因此同一套面板既运行在 Web 客户端中,也运行在 Desktop 窗口内。它们的拖拽过去一比一跟随指针,只有一个顶部下限(`--dsh-dockkit-float-top`,让标题栏保持在 Windows 标题栏之下)。因此面板可以被拖过左、右或下边界,直到标题栏够不着;视口缩小会把面板留在外面;新建面板的级联位置也可能落在小视口之外。期望行为是:面板被推过边界时可见地受到阻力,松手后以弹簧物理被拉回视口内,而不是像撞墙一样被挡住或被留在外面。 + +我们逆向分析了参考实现 Claude Desktop 2.42304.2,它没有这种行为。在 macOS 上它的 Quick Entry 是原生 Swift `NSPanel`;其二进制包含动画原语,但无法从中确认任何边界收回规则。Windows/Linux 上使用的 Electron 回退是静态的:输入防抖 750 ms 后做一次瞬时 `setPosition`,只裁剪右边界和下边界(触发边距为 0、目标边距为 30 DIP,因此首次修正在溢出量之外再跳 30),从不裁剪左边界和上边界。其主 bundle 中不存在 `spring` 符号;仅有的窗口运动工具是用于主窗口尺寸变化的 240 ms `easeInOutCubic` 边界补间、Electron 仅 macOS 可用的 `setBounds(bounds, true)`,以及 300 ms `cubic-bezier(0.16, 1, 0.3, 1)` 的 CSS 运动语汇。该行为必须被设计,而不是移植。 + +变更维持以下义务:面板标题栏始终可达;一个手势仍然最多记录一条历史;遵循减弱动态效果;Sidebar 的持久化布局格式与所有嵌入方保持不变;运动足够确定,无需真实浏览器时钟即可测试。 + +## 决定 + +该行为完全位于 `packages/client/ui-dockkit` 内,在 `useFloatGestures` 及其调用的纯 engine 辅助函数中实现。两个悬浮渲染器都通过 `rectOf` 从该 hook 取得矩形,因此 Web 与 Desktop 共享该行为,任何嵌入方、壳或持久化格式都没有改变。 + +### 合法区域与显示位置 + +面板的合法区域是视口(`window.innerWidth` × `window.innerHeight`)加上既有的顶部下限:设置了 `data-windows-titlebar` 时为 Windows 标题栏高度加 20 px(原生全屏时为 20 px),否则为 0。`floatBounds` 只读取这些值和根元素属性,因此开始手势不会触发布局。`legalFloatRect` 逐轴把矩形投影进该区域;面板比区域更宽或更高时钉住最小边,使标题栏及其抓手留在屏幕上,多余部分悬在右侧或下方。 + +面板绘制在其存储矩形投影到视口后的位置。存储矩形从不因视口而被改写:窗口缩小时面板被推入且不记录操作,窗口变大时面板回到存储位置。hook 在每次窗口 `resize` 事件时重新读取区域。包含屏幕外矩形的持久化布局无需迁移即在视口内显示。 + +### 带阻力的拖拽 + +移动时由手势起始矩形和指针位移计算期望矩形,然后 `resistedRect` 逐轴把它拆分为合法投影与超出它的溢出量 `o`,并绘制在 `legal + sign(o) * K * |o| / (K + |o|)`,其中 `K = FLOAT_RESISTANCE = 120` px。该映射连续、在边界处斜率为 1、渐近于 `K`: + +| 指针溢出(px) | 面板越过边界的位移(px) | 占溢出的比例 | +|---|---|---| +| 10 | 9.2 | 92.3% | +| 40 | 30.0 | 75.0% | +| 120 | 60.0 | 50.0% | +| 400 | 92.3 | 23.1% | + +缩放通过 `boundedResize` 把右下角钳制在区域内并保持 `FLOAT_MIN_SIZE`;缩放不加阻力,因为落后于指针的角看起来像坏掉的手柄。 + +### 松手与弹簧 + +松手时记录合法矩形:与存储矩形不同时记录 `moveFloat` 或 `resizeFloat`,否则只记录点击会记录的提升。因此历史与持久化始终保存合法位置,撤销一步即可恢复手势前的位置。若面板绘制在合法区域之外,hook 保留预览,用弹簧把它从绘制位置带到记录的矩形,然后清除预览。在区域内松手不播放动画。 + +`engine/spring.ts` 中的弹簧是欠阻尼的,因为它的回弹朝向内侧,始终合法:响应时间 0.32 s、阻尼比 0.72。溢出的轴以拖拽最后 100 ms 的指针速度起步,上限为 `SPRING_MAX_VELOCITY`(1500 px/s),因此甩过边界的面板会先走得更远再折返;没有溢出的轴保持不动。每轴更新为: + +``` +a = -omega0^2 * (x - target) - 2 * zeta * omega0 * v +v = v + a * h +x = x + v * h +``` + +`stepSpring` 以等长且不超过 `h = 1/480 s` 的子步积分,因此运动与帧率无关。hook 中唯一的 `requestAnimationFrame` 循环把单帧钳制到 64 ms,帧间隔超过 250 ms 或已过 900 ms 时直接停在目标,否则在每轴距目标 1 px 内且慢于 60 px/s 时停止。从静止开始,200 px 溢出在 367 ms 内稳定,向内过冲与解析上限 `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%` 相差不超过一个百分点;阻力把松手时的溢出限制在 120 px 内。积分器是纯函数并接收 `dt`,因此 engine 保持不读取时钟的规则。 + +回弹期间按下抓手或右下角会取消回弹。移动从绘制位置接住面板:`unresistedRect` 反演阻力,因此零指针位移绘制同一矩形,原地松手会恢复回弹且不记录第二次操作。一个 hook 最多运行一个回弹;窗口缩放会让进行中的回弹停在记录的矩形,卸载会取消待处理的帧。 + +### 减弱动态效果 + +`matchMedia('(prefers-reduced-motion: reduce)')` 匹配时,移动钳制在合法区域内且无阻力,松手时记录合法矩形且不播放动画。没有 `matchMedia` 的环境视为无偏好。该规则只移除运动,从不移除合法性修正。 + +### 改动范围 + +变更只涉及一个包 `@deepseek-ai/dsh-client-ui-dockkit`,其导出与 `DockIntents` 保持不变。 + +| 文件 | 变更 | +|---|---| +| `packages/client/ui-dockkit/src/engine/geometry.ts` | `FloatBounds`、`FLOAT_RESISTANCE`、`legalFloatRect`、`resistedRect`、`unresistedRect`、`boundedResize` | +| `packages/client/ui-dockkit/src/engine/spring.ts` | 纯弹簧积分器、静止判定与时间上限 | +| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`:视口投影、阻力、松手弹簧、速度采样、减弱动态效果、帧循环与取消;供两个渲染器使用的 `rectOf` | +| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | 绘制 hook 给出的显示矩形 | +| `packages/client/ui-dockkit/README.md` 对 | 在悬浮渲染器说明旁补充边界规则 | + +`ui-sidebar-right` 及其持久化布局 schema、`apps/web`、`apps/desktop`、共享基础组件与主题均未改动。没有新增依赖。 + +## 测试 + +`tests/float-bounds.client.spec.ts` 覆盖投影(内部、每条边、角、顶部下限、超尺寸面板)、阻力(表中数值、边界处斜率为 1、以 `K` 为上界的单调增长、各轴独立)、其反演与上限,以及受限缩放。`tests/spring.client.spec.ts` 覆盖在截止时间内稳定、过冲上限、30/60/120 Hz 下与帧率无关、松手速度的作用以及确定性。 + +`tests/float-recovery.client.spec.tsx` 在活的控制器上渲染 `FloatLayer`,帧与时钟由测试手动推进:越过边界时带阻力的预览、每次松手恰好一次合法的 `moveFloat` 以及带向内过冲的弹回、一步撤销、甩出与慢推的对比、区域内松手不播放动画、推到存储位置的手势、回弹中按下接住面板且不跳变、停顿直接跳转、截止时间、卸载取消、两种减弱动态效果答复、视口缩小与变大时的投影且不调用任何 intent、缩放结束回弹,以及屏幕外存储矩形在视口内绘制。既有的 `FloatLayer` 与 `TabLayout` 测试原样通过,包括开始手势不做布局读取这一规则;每个改动的源文件都被完全覆盖。 + +## 考虑过的替代方案 + +**在边界处硬钳制。** 在视口边界停住面板是成本最低的正确规则,但手感像撞墙,接触之前也感觉不到边界。它保留为减弱动态效果的路径。 + +**拖拽中钳制、松手时回弹。** 在边界处钳制拖拽,并把向外的指针速度转换为向内的回弹,能让每一帧都留在视口内。但它隐藏了用户推了多远,回弹因此没有可见的起因;阻力显示了推动,同时把溢出限制在 120 px 内。 + +**松手后的缓动补间。** 从松手位置开始的固定 240–300 ms 缓出补间可预测,但不携带速度,因此甩出与慢推的返回完全相同,补间中按下要么跳变要么重启。弹簧保持速度连续且可打断。 + +**存储屏幕外位置并在之后修正。** 记录松手矩形、在下一次渲染时修正,会持久化屏幕外位置,并让撤销恢复它们。记录合法矩形则保持每个手势一条历史且值合法。 + +**视口缩小时改写存储矩形。** 在缩放时通过操作移动面板会增加用户从未做过的历史条目,并在每次窗口缩放时改变持久化布局。渲染时投影无需二者即可得到可见结果,代价是下文描述的弹性返回。 + +**原生始终置顶 OS 窗口。** 真正的 Electron 悬浮窗可以浮在其他应用之上,但只在 Desktop 上可行;Web 客户端完全无法做到,而且原生无边框拖拽不提供逐帧控制,因此阻力无法实现,弹簧只能在松手后启动。它是一个独立的产品决策,有自己的窗口、IPC 与平台工作,本决定不排斥它。 + +**动画库依赖。** 弹簧库会用一个静态链接包里的新依赖替换几十行的纯积分器及其测试。积分器还必须不依赖时钟,才能用于 engine 的确定性测试。 + +**放进 `ui-primitives` 的共享基础组件。** 把 hook 放进基础组件包会把改动扩大到第二个包,而没有第二个使用方。等另一个悬浮界面需要它时再迁移。 + +## 后果 + +悬浮面板在两个客户端中都不会再丢失到屏幕外,推过边界读起来是被拉回而不是撞墙。历史、持久化与所有嵌入方不受影响,运动由确定性测试而非手动检查覆盖。 + +弹簧通过 React state 更新预览,每帧成本与拖拽相同,但承载重内容的面板在松手后最多会重渲染 900 ms;如果测量显示问题,退路是直接在面板元素上写 transform。被缩小窗口推入的面板会在窗口变大时回到存储位置,期望它留在被推到位置的用户可能会感到意外。面板内的内容(例如 webview)在松手后可能观察到最多 900 ms 的运动。常量针对鼠标或触控板调优;触控输入经由同样的 pointer 事件可用,但仍未调优。 diff --git a/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.md b/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.md deleted file mode 100644 index f7f5dbb947..0000000000 --- a/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.md +++ /dev/null @@ -1,120 +0,0 @@ -# Agent Note: Force-directed edge recovery for floating dock panels - -Status: proposed - -English | [中文](2026-10-21-floating-window-edge-recovery.zh.md) - -## Problem - -The dock kit's floating panels are the client's floating windows. `FloatLayer` and the retained `DockLayout` renderer draw them in viewport coordinates through one shared gesture hook, `useFloatGestures`, and the right Sidebar embeds them, so the same panels run in the Web client and inside the Desktop window. Their drag follows the pointer one to one with only a top floor (`--dsh-dockkit-float-top`, which keeps the header below the Windows caption). A panel can therefore be dragged past the left, right or bottom edge until its header is out of reach; a viewport that shrinks leaves a panel outside it; and the creation cascade can place a new panel outside a small viewport. The required behavior is that a panel pushed past an edge visibly resists, and on release is pulled back inside with spring physics instead of being stopped by a wall or left outside. - -We reverse engineered Claude Desktop 2.42304.2 as a reference, and it has no such behavior. On macOS its Quick Entry surface is a native Swift `NSPanel`; its binary contains animation primitives, but no edge-recovery rule could be established from it. The Electron fallback used on Windows/Linux is static: after a 750 ms input debounce it makes one instantaneous `setPosition` that clamps only the right and bottom edges (trigger margin 0, target margin 30 DIP, so the first correction jumps by overflow + 30) and never clamps left or top. No `spring` symbol exists in its main bundle; its only window-motion utilities are a 240 ms `easeInOutCubic` bounds tween for main-window size changes, Electron's macOS-only `setBounds(bounds, true)`, and a 300 ms `cubic-bezier(0.16, 1, 0.3, 1)` CSS vocabulary. The behavior has to be designed, not ported. - -The change must keep these obligations: a panel header is always reachable; one gesture still records exactly one history entry; reduced motion is honored; the persisted Sidebar layout format and every embedder stay unchanged; and the motion is deterministic enough to test without a real browser clock. - -## Proposal - -The behavior lives entirely in `packages/client/ui-dockkit`, inside `useFloatGestures` and the pure engine helpers it calls. Because both float renderers already take their rectangles from that hook, Web and Desktop gain the behavior together, and no embedder, shell or persisted format changes. - -### Legal region and displayed position - -The legal region of a panel is the viewport (`window.innerWidth` by `window.innerHeight`) with the existing top floor: the Windows caption height plus 20 px when `data-windows-titlebar` is set (20 px in native fullscreen), otherwise 0. A rectangle is legal when `x` lies in `[0, viewportWidth - width]` and `y` in `[top, viewportHeight - height]`. When a panel is wider or taller than the region, the minimum edge is pinned, so the header and its grip stay on screen and the excess hangs off the right or bottom. - -The displayed rectangle is the per-axis projection of the stored rectangle onto the legal region, computed at render. The stored rectangle is not rewritten: a shrinking viewport pushes the panel in immediately without recording an operation, and when the viewport grows again the panel returns toward its stored position. Persisted layouts that hold off-screen rectangles from before this change display inside the viewport without migration. - -### Drag with resistance - -A move gesture computes the desired rectangle from the gesture's start rectangle and the pointer travel, as today, then splits it per axis into the legal projection and the overflow `o` beyond it. The preview rectangle draws at `legal + sign(o) * K * |o| / (K + |o|)` with `K = 120` px. The mapping is continuous, has slope 1 at the edge, and approaches `K`, so the panel moves past the edge at first and resists more the further the pointer goes: - -| pointer overflow (px) | panel displacement past the edge (px) | share of the overflow | -|---|---|---| -| 10 | 9.2 | 92.3% | -| 40 | 30.0 | 75.0% | -| 120 | 60.0 | 50.0% | -| 400 | 92.3 | 23.1% | - -A resize gesture clamps the bottom-right corner to the legal region and keeps `FLOAT_MIN_SIZE`; resizing has no resistance, because a corner that lags the pointer reads as a broken handle. - -### Release and spring - -On release the gesture records one `moveFloat` with the legal rectangle, so history and persistence always store a legal position. If the preview was outside the legal region, the hook keeps drawing a preview and animates it from the released position to the recorded one with a spring, then clears it. A release inside the legal region records the move and animates nothing. - -The spring is underdamped, because its bounce goes inward and is always legal: response 0.32 s and damping ratio 0.72 (`omega0 = 2 * pi / 0.32`). The initial velocity is the pointer velocity over the last 100 ms of the drag, capped at 1500 px/s, so a fling past the edge comes back faster than a slow push. Per axis the update is: - -``` -a = -omega0^2 * (x - target) - 2 * zeta * omega0 * v -v = v + a * h -x = x + v * h -``` - -The integrator uses a fixed substep `h = 1/480 s`. One `requestAnimationFrame` loop in the hook splits each frame's elapsed time into equal substeps of at most `h`, clamps a frame to 64 ms, and snaps to the target when a frame gap exceeds 250 ms. It stops and draws the exact target when each axis is within 1 px with speed below 60 px/s, or after 900 ms. Simulated from rest, a 200 px overflow settles in 367 ms with a 6.9 px (3.5%) inward overshoot, within the analytic bound `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%`; the resistance caps the released overflow at 120 px, so real releases settle faster. The integrator is pure and takes `dt`, so the engine keeps its rule of reading no clock. - -A press on a panel during its animation cancels the animation and starts the new gesture from the rectangle being drawn. At most one animation runs per panel, and unmounting the layer cancels its frame request. - -### Reduced motion - -When `matchMedia('(prefers-reduced-motion: reduce)')` matches, a move gesture clamps to the legal region with no resistance, and release records the legal rectangle with no animation. The rule only removes motion; it never removes the legality correction. - -### Change scope - -The implementation changes one package, `@deepseek-ai/dsh-client-ui-dockkit`, and leaves its exports and `DockIntents` unchanged. - -| File | Change | -|---|---| -| `packages/client/ui-dockkit/src/engine/geometry.ts` | add the legal projection and the resistance mapping | -| `packages/client/ui-dockkit/src/engine/` | new `spring.ts`: the pure spring integrator and its settle test | -| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`: displayed projection, resistance, release spring, velocity sampling, reduced motion, frame loop and cancellation | -| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | draw the hook's displayed rectangle instead of the stored one | -| `packages/client/ui-dockkit/tests/` | geometry, spring, `FloatLayer` and `TabLayout` specs | -| `packages/client/ui-dockkit/README.md` pair | the edge rule under the interaction rules | - -`ui-sidebar-right` and its persisted layout schema, `apps/web`, `apps/desktop`, the shared primitives and the theme are untouched. No dependency is added. - -## Testing plan - -Pure tests cover the projection (inside, each edge, corner, oversized panel, the Windows top floor), the resistance mapping (continuous, monotone, slope 1 at the edge, bounded by `K`), and the spring with fixed time steps (deterministic trajectories, exact final target, inward overshoot within one percentage point of the analytic bound, settling inside the 900 ms deadline, cadence robustness at 30, 60 and 120 Hz, and the 250 ms stall snap). - -Component tests run `FloatLayer` and `TabLayout` under jsdom with a fake `requestAnimationFrame` and clock: a drag past each edge draws the resisted preview; release records exactly one `moveFloat` with the legal rectangle; the preview animates to it and clears; a press mid-animation cancels it and starts from the drawn rectangle; reduced motion clamps and never schedules a frame; a viewport resize moves the displayed panel without calling any intent; unmounting cancels the frame. The per-file coverage gate applies to every changed source file. - -A browser check in the Web client drags a Sidebar floating panel past each edge and corner, flings it, resizes the window under it, and repeats with reduced motion; the PR records it as a GIF. Desktop loads the same client bundle, so one manual pass there confirms the Windows caption floor. - -No keyless recorded-session snapshot is required: nothing model-visible changes and no `SessionEventMap` member is added. Documentation gates: `doc-sync`. - -## Alternatives considered - -**Hard clamp at the edge.** Stopping the panel at the viewport edge is the cheapest correct rule, but it feels like hitting a wall and gives no sense of the boundary before contact. It remains as the reduced-motion path. - -**Clamp during the drag and rebound on release.** Clamping the drag at the edge and converting the outward pointer speed into an inward bounce keeps every frame inside the viewport. It hides how far the user pushed, so the bounce has no visible cause; the resistance mapping shows the push and still bounds the overflow at 120 px. - -**Eased tween on release.** A fixed 240-300 ms ease-out from the released position is predictable, but it carries no velocity, so a fling and a slow push return identically, and a press during it either jumps or restarts. The spring keeps velocity continuous and is interruptible. - -**Store the off-screen position and correct it later.** Recording the released rectangle and fixing it on the next render would persist off-screen positions and make undo restore them. Recording the legal rectangle keeps one history entry per gesture with a legal value. - -**Rewrite stored rectangles when the viewport shrinks.** Moving panels through operations on resize would add history entries the user never made and change persisted layouts on every window resize. Projecting at render achieves the same visible result without either. - -**Native always-on-top OS window.** A real Electron floating window could float above other applications, but only on Desktop; the Web client cannot do it at all, and native frameless dragging gives no per-frame control, so the resistance would be impossible and the spring could only start after release. It is a separate product decision with its own window, IPC and platform work, and this note does not preclude it. - -**Animation library dependency.** A spring library would replace a pure integrator of a few dozen lines and its tests with a new dependency in a statically linked package. The integrator also has to be clock-free for the engine's deterministic tests. - -**A shared primitive in `ui-primitives`.** Placing the hook in the primitives package would widen the change to a second package with no second consumer today. It can move there when another floating surface needs it. - -## Acceptance criteria - -- Dragging a floating panel past any viewport edge draws it at the resisted displacement, never more than 120 px beyond the edge; reduced motion clamps it at the edge instead. -- Releasing it outside the legal region records exactly one `moveFloat` whose rectangle is legal, and the panel springs to that rectangle within 900 ms, ending at the exact recorded position. -- Undo after such a gesture restores the pre-gesture position in one step, and the persisted layout never contains a rectangle that was illegal when it was recorded. -- Releasing inside the legal region behaves as today: one operation and no animation. -- Pressing a panel during its animation stops the animation and the new gesture starts from the rectangle on screen. -- Shrinking the viewport keeps every panel header on screen without recording an operation; growing it returns the panel toward its stored position. -- Under the Windows caption the header stays at least 20 px below the caption, as today. -- No package other than `@deepseek-ai/dsh-client-ui-dockkit` changes, and its exports and `DockIntents` are unchanged. - -## Risks - -**One render per animation frame.** The spring updates the preview through React state, which costs the same per frame as a drag does today, but a panel hosting heavy content re-renders for up to 900 ms after release. If measurement shows dropped frames, the loop can write a transform on the panel element directly without changing the rule. - -**Elastic return after the viewport grows.** A panel pushed in by a shrinking window returns toward its stored position when the window grows. That is the intended consequence of not rewriting stored rectangles, but a user who expects the panel to stay where it was pushed may find it surprising. - -**Embedded content under motion.** Panels can host a webview or an iframe that receives pointer events. The spring runs only after release and a press cancels it, but content that reacts to its own movement could observe up to 900 ms of motion. - -**Touch remains untuned.** The resistance and the velocity sample come from pointer events and work for touch input, but the constants are tuned for a mouse or trackpad; the README already lists touch as untuned. diff --git a/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.zh.md b/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.zh.md deleted file mode 100644 index 4b73e4098a..0000000000 --- a/.agents/notes/proposed/feature/2026-10-21-floating-window-edge-recovery.zh.md +++ /dev/null @@ -1,120 +0,0 @@ -# Agent Note: 悬浮停靠面板的力导向边界收回 - -Status: proposed - -[English](2026-10-21-floating-window-edge-recovery.md) | 中文 - -## 问题 - -dock kit 的悬浮面板就是客户端里的悬浮窗。`FloatLayer` 与保留式 `DockLayout` 渲染器通过同一个手势 hook `useFloatGestures` 以视口坐标绘制它们,右侧 Sidebar 嵌入了它们,因此同一套面板既运行在 Web 客户端中,也运行在 Desktop 窗口内。它们的拖拽一比一跟随指针,只有一个顶部下限(`--dsh-dockkit-float-top`,让标题栏保持在 Windows 标题栏之下)。因此面板可以被拖过左、右或下边界,直到标题栏够不着;视口缩小会把面板留在外面;新建面板的级联位置也可能落在小视口之外。期望行为是:面板被推过边界时可见地受到阻力,松手后以弹簧物理被拉回视口内,而不是像撞墙一样被挡住或被留在外面。 - -我们逆向分析了参考实现 Claude Desktop 2.42304.2,它没有这种行为。在 macOS 上它的 Quick Entry 是原生 Swift `NSPanel`;其二进制包含动画原语,但无法从中确认任何边界收回规则。Windows/Linux 上使用的 Electron 回退是静态的:输入防抖 750 ms 后做一次瞬时 `setPosition`,只裁剪右边界和下边界(触发边距为 0、目标边距为 30 DIP,因此首次修正在溢出量之外再跳 30),从不裁剪左边界和上边界。其主 bundle 中不存在 `spring` 符号;仅有的窗口运动工具是用于主窗口尺寸变化的 240 ms `easeInOutCubic` 边界补间、Electron 仅 macOS 可用的 `setBounds(bounds, true)`,以及 300 ms `cubic-bezier(0.16, 1, 0.3, 1)` 的 CSS 运动语汇。该行为必须被设计,而不是移植。 - -变更必须维持以下义务:面板标题栏始终可达;一个手势仍然只记录一条历史;遵循减弱动态效果;Sidebar 的持久化布局格式与所有嵌入方保持不变;运动足够确定,无需真实浏览器时钟即可测试。 - -## 提案 - -该行为完全位于 `packages/client/ui-dockkit` 内,在 `useFloatGestures` 及其调用的纯 engine 辅助函数中实现。由于两个悬浮渲染器都已从该 hook 取得矩形,Web 与 Desktop 同时获得该行为,不改动任何嵌入方、壳或持久化格式。 - -### 合法区域与显示位置 - -面板的合法区域是视口(`window.innerWidth` × `window.innerHeight`)加上既有的顶部下限:设置了 `data-windows-titlebar` 时为 Windows 标题栏高度加 20 px(原生全屏时为 20 px),否则为 0。当 `x` 位于 `[0, viewportWidth - width]` 且 `y` 位于 `[top, viewportHeight - height]` 时矩形合法。面板比区域更宽或更高时钉住最小边,使标题栏及其抓手留在屏幕上,多余部分悬在右侧或下方。 - -显示矩形是存储矩形在合法区域上的逐轴投影,在渲染时计算。存储矩形不会被改写:视口缩小时面板立即被推入,且不记录操作;视口再次变大时面板回到存储位置。本变更之前持久化下来、包含屏幕外矩形的布局无需迁移即在视口内显示。 - -### 带阻力的拖拽 - -移动手势照旧由手势起始矩形和指针位移计算期望矩形,然后逐轴拆分为合法投影与超出它的溢出量 `o`。预览矩形绘制在 `legal + sign(o) * K * |o| / (K + |o|)`,其中 `K = 120` px。该映射连续、在边界处斜率为 1、渐近于 `K`,因此面板起初会越过边界,指针走得越远阻力越大: - -| 指针溢出(px) | 面板越过边界的位移(px) | 占溢出的比例 | -|---|---|---| -| 10 | 9.2 | 92.3% | -| 40 | 30.0 | 75.0% | -| 120 | 60.0 | 50.0% | -| 400 | 92.3 | 23.1% | - -缩放手势把右下角钳制在合法区域内并保持 `FLOAT_MIN_SIZE`;缩放不加阻力,因为落后于指针的角看起来像坏掉的手柄。 - -### 松手与弹簧 - -松手时手势以合法矩形记录一次 `moveFloat`,因此历史与持久化始终存储合法位置。如果预览在合法区域之外,hook 继续绘制预览,用弹簧把它从松手位置动画到记录的位置,然后清除预览。在合法区域内松手则记录移动且不播放动画。 - -弹簧是欠阻尼的,因为它的回弹朝向内侧,始终合法:响应时间 0.32 s、阻尼比 0.72(`omega0 = 2 * pi / 0.32`)。初速度取拖拽最后 100 ms 的指针速度,上限 1500 px/s,因此甩过边界的面板比慢慢推过去的回来得更快。每轴更新为: - -``` -a = -omega0^2 * (x - target) - 2 * zeta * omega0 * v -v = v + a * h -x = x + v * h -``` - -积分器使用固定子步 `h = 1/480 s`。hook 中唯一的 `requestAnimationFrame` 循环把每帧经过的时间拆成等长且不超过 `h` 的子步,单帧钳制到 64 ms,帧间隔超过 250 ms 时直接跳到目标。当每轴距离在 1 px 内且速度低于 60 px/s,或经过 900 ms 后,它停止并绘制精确目标。从静止开始仿真,200 px 溢出在 367 ms 内稳定,向内过冲 6.9 px(3.5%),在解析上限 `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%` 之内;阻力把松手时的溢出限制在 120 px 内,因此真实松手稳定得更快。积分器是纯函数并接收 `dt`,因此 engine 保持不读取时钟的规则。 - -动画期间按下面板会取消动画,并从正在绘制的矩形开始新手势。每个面板最多运行一个动画,卸载该层会取消其帧请求。 - -### 减弱动态效果 - -`matchMedia('(prefers-reduced-motion: reduce)')` 匹配时,移动手势钳制在合法区域内且无阻力,松手时记录合法矩形且不播放动画。该规则只移除运动,从不移除合法性修正。 - -### 改动范围 - -实现只修改一个包 `@deepseek-ai/dsh-client-ui-dockkit`,其导出与 `DockIntents` 保持不变。 - -| 文件 | 变更 | -|---|---| -| `packages/client/ui-dockkit/src/engine/geometry.ts` | 新增合法投影与阻力映射 | -| `packages/client/ui-dockkit/src/engine/` | 新增 `spring.ts`:纯弹簧积分器及其稳定判定 | -| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`:显示投影、阻力、松手弹簧、速度采样、减弱动态效果、帧循环与取消 | -| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | 绘制 hook 给出的显示矩形而不是存储矩形 | -| `packages/client/ui-dockkit/tests/` | 几何、弹簧、`FloatLayer` 与 `TabLayout` 测试 | -| `packages/client/ui-dockkit/README.md` 对 | 在交互规则下补充边界规则 | - -`ui-sidebar-right` 及其持久化布局 schema、`apps/web`、`apps/desktop`、共享基础组件与主题均不改动。不新增依赖。 - -## 测试计划 - -纯测试覆盖投影(内部、每条边、角、超尺寸面板、Windows 顶部下限)、阻力映射(连续、单调、边界处斜率为 1、以 `K` 为上界),以及固定时间步下的弹簧(轨迹确定、最终目标精确、向内过冲与解析上限相差不超过一个百分点、在 900 ms 截止时间内稳定、30/60/120 Hz 节奏鲁棒,以及 250 ms 停顿直接跳转)。 - -组件测试在 jsdom 中用假的 `requestAnimationFrame` 与时钟运行 `FloatLayer` 和 `TabLayout`:拖过每条边绘制带阻力的预览;松手恰好记录一次带合法矩形的 `moveFloat`;预览动画到该矩形后清除;动画中按下会取消动画并从绘制中的矩形开始;减弱动态效果下钳制且从不调度帧;视口缩放移动显示面板而不调用任何 intent;卸载取消帧请求。逐文件覆盖率门禁适用于每个改动的源文件。 - -在 Web 客户端中进行浏览器检查:把 Sidebar 悬浮面板拖过每条边与每个角、甩出去、在其下方缩放窗口,并在减弱动态效果下重复;PR 以 GIF 记录。Desktop 加载同一份客户端 bundle,因此在那里手动走一遍即可确认 Windows 标题栏下限。 - -不需要 keyless 录制会话快照:没有模型可见的变化,也没有新增 `SessionEventMap` 成员。文档门禁:`doc-sync`。 - -## 考虑过的替代方案 - -**在边界处硬钳制。** 在视口边界停住面板是成本最低的正确规则,但手感像撞墙,接触之前也感觉不到边界。它保留为减弱动态效果的路径。 - -**拖拽中钳制、松手时回弹。** 在边界处钳制拖拽,并把向外的指针速度转换为向内的回弹,能让每一帧都留在视口内。但它隐藏了用户推了多远,回弹因此没有可见的起因;阻力映射显示了推动,同时把溢出限制在 120 px 内。 - -**松手后的缓动补间。** 从松手位置开始的固定 240–300 ms 缓出补间可预测,但不携带速度,因此甩出与慢推的返回完全相同,补间中按下要么跳变要么重启。弹簧保持速度连续且可打断。 - -**存储屏幕外位置并在之后修正。** 记录松手矩形、在下一次渲染时修正,会持久化屏幕外位置,并让撤销恢复它们。记录合法矩形则保持每个手势一条历史且值合法。 - -**视口缩小时改写存储矩形。** 在缩放时通过操作移动面板会增加用户从未做过的历史条目,并在每次窗口缩放时改变持久化布局。渲染时投影无需二者即可得到相同的可见结果。 - -**原生始终置顶 OS 窗口。** 真正的 Electron 悬浮窗可以浮在其他应用之上,但只在 Desktop 上可行;Web 客户端完全无法做到,而且原生无边框拖拽不提供逐帧控制,因此阻力无法实现,弹簧只能在松手后启动。它是一个独立的产品决策,有自己的窗口、IPC 与平台工作,本 Note 不排斥它。 - -**动画库依赖。** 弹簧库会用一个静态链接包里的新依赖替换几十行的纯积分器及其测试。积分器还必须不依赖时钟,才能用于 engine 的确定性测试。 - -**放进 `ui-primitives` 的共享基础组件。** 把 hook 放进基础组件包会把改动扩大到第二个包,而目前没有第二个使用方。等另一个悬浮界面需要它时再迁移。 - -## 验收标准 - -- 把悬浮面板拖过任一视口边界时,它绘制在带阻力的位移处,越过边界不超过 120 px;减弱动态效果下改为钳制在边界处。 -- 在合法区域外松手恰好记录一次矩形合法的 `moveFloat`,面板在 900 ms 内弹到该矩形,最终停在精确的记录位置。 -- 这样的手势之后撤销一步即可恢复手势前的位置,持久化布局中从不包含记录时就不合法的矩形。 -- 在合法区域内松手的行为与现在相同:一次操作,无动画。 -- 动画期间按下面板会停止动画,新手势从屏幕上的矩形开始。 -- 视口缩小时每个面板标题栏都留在屏幕上且不记录操作;视口变大时面板回到存储位置。 -- 在 Windows 标题栏下,标题栏与现在一样至少保持在其下方 20 px。 -- 除 `@deepseek-ai/dsh-client-ui-dockkit` 外没有其他包改动,其导出与 `DockIntents` 不变。 - -## 风险 - -**每个动画帧一次渲染。** 弹簧通过 React state 更新预览,每帧成本与现在拖拽时相同,但承载重内容的面板在松手后最多会重渲染 900 ms。如果测量显示掉帧,循环可以直接在面板元素上写 transform,而不改变规则。 - -**视口变大后的弹性返回。** 被缩小窗口推入的面板会在窗口变大时回到存储位置。这是不改写存储矩形的预期结果,但期望面板留在被推到的位置的用户可能会感到意外。 - -**运动中的嵌入内容。** 面板可以承载接收指针事件的 webview 或 iframe。弹簧只在松手后运行,按下即取消,但会对自身移动作出反应的内容可能观察到最多 900 ms 的运动。 - -**触控仍未调优。** 阻力与速度采样来自 pointer 事件,适用于触控输入,但常量是针对鼠标或触控板调优的;README 已将触控列为未调优。 diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 3dafc30f52..bc14c56a5b 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -18,8 +18,8 @@ en: 718f52cfce426009 zh: e8282e2e3713d242 /deepseek-ai-dsh-client-ui-dockkit/embedding-it: - en: c4ba08038bfa6f30 - zh: 95db30618b52babe + en: dbc69843ab81c56e + zh: 372b6fd3e19e0124 /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping: en: cf2d00ce2b7d5719 zh: 5510420f8872e23a diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index bc29b54c08..bc7b4bc81e 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -66,6 +66,8 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp `DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. Both float renderers keep the header at least 20px below the Windows caption (20px below the viewport top in native fullscreen), including newly opened and restored panels. Dragging and resizing start at the displayed position. +Floating panels stay inside the viewport. Each draws at its stored rectangle projected into the viewport, so a shrinking window pushes it in without recording an operation and a growing window returns it toward the stored position. A move follows the pointer one to one inside the viewport and resists past an edge, drawing at most 120px beyond it; its release records one `moveFloat` with the nearest legal rectangle, then springs the panel there from where it was drawn, carrying the release velocity and overshooting slightly inward. A press during that return picks the panel up where it is drawn. A resize keeps the corner inside the viewport. Under `prefers-reduced-motion: reduce` a move clamps at the edge and nothing animates. + ## Interaction rules worth keeping diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 15c5d332d9..3567c1f486 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -66,6 +66,8 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间 `DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 两种浮窗渲染器都让标题栏与 Windows 顶栏至少间隔 20px(原生全屏时距视口顶部 20px),新建和恢复的浮窗均适用。拖动和调整大小从实际显示位置开始。 +浮窗始终留在视口内。每个浮窗绘制在其存储矩形投影到视口后的位置,因此窗口缩小时浮窗被推入且不记录操作,窗口变大时浮窗回到存储位置。移动时浮窗在视口内一比一跟随指针,越过边界后受到阻力,最多越过边界 120px;松手时记录一次带最近合法矩形的 `moveFloat`,再让浮窗从绘制位置以弹簧回到该矩形,携带松手速度并略微向内过冲。回弹过程中按下会从绘制位置接住浮窗。调整大小时右下角保持在视口内。在 `prefers-reduced-motion: reduce` 下,移动在边界处钳制且不播放动画。 + ## 值得保留的交互规则 diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index dfe028993b..8142d70196 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -13,14 +13,19 @@ * only `state` and the outward contracts. An embedder may portal it anywhere, * and nothing here assumes the docked tree is an ancestor or even present. */ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconCloseOutlineRegular, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' import type { FloatRect, LayoutState, PaneId, TabId, TabRecord } from '../contract/types.ts' import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' -import { movedRect, resizedRect } from '../engine/geometry.ts' +import { + boundedResize, legalFloatRect, movedRect, resistedRect, resizedRect, unresistedRect, type FloatBounds, +} from '../engine/geometry.ts' +import { + SPRING_DEADLINE_MS, SPRING_MAX_FRAME_MS, SPRING_MAX_VELOCITY, SPRING_STALL_MS, springSettled, stepSpring, type SpringAxis, +} from '../engine/spring.ts' import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts' import { useGesture } from './pointer.ts' import { TabTitle } from './TabTitle.tsx' @@ -46,7 +51,7 @@ interface FloatDrag { readonly rect: FloatRect } -/** The rectangle a gesture has reached. */ +/** The rectangle the pointer travel asks for, before the viewport has its say. */ function draggedRect(drag: FloatDrag, x: number, y: number): FloatRect { const dx = x - drag.originX const dy = y - drag.originY @@ -65,47 +70,183 @@ function raised(state: LayoutState, paneId: PaneId): boolean { return state.activePaneId === paneId && state.floats.at(-1) === paneId } +/** + * The viewport region panels stay inside. It reads the window size and the + * root's caption attributes only, so starting a gesture never forces layout. + */ +function floatBounds(): FloatBounds { + const root = document.documentElement + const windows = root.hasAttribute('data-windows-titlebar') + // Desktop publishes the caption height in pixels on the root's inline style. + const caption = windows && !root.hasAttribute('data-fullscreen') + ? Number.parseFloat(root.style.getPropertyValue('--dsh-windows-titlebar-height')) : 0 + return { top: windows ? caption + 20 : 0, width: window.innerWidth, height: window.innerHeight } +} + +/** Whether the platform asks for reduced motion; environments without `matchMedia` (jsdom) report no preference. */ +function reducedMotion(): boolean { + return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches +} + +/** How much recent pointer history the release velocity is measured over. */ +const VELOCITY_WINDOW_MS = 100 + +/** One pointer position and when it was read. */ +interface PointerSample { + readonly t: number + readonly x: number + readonly y: number +} + +/** Pointer velocity over the last `VELOCITY_WINDOW_MS`, capped per axis, in pixels per second. */ +function releaseVelocity(samples: readonly PointerSample[]): { readonly x: number; readonly y: number } { + const last = samples[samples.length - 1] as PointerSample + const first = samples.find(sample => last.t - sample.t <= VELOCITY_WINDOW_MS) as PointerSample + if (last.t === first.t) return { x: 0, y: 0 } + const rate = (travel: number): number => + Math.max(-SPRING_MAX_VELOCITY, Math.min(SPRING_MAX_VELOCITY, travel / (last.t - first.t) * 1000)) + return { x: rate(last.x - first.x), y: rate(last.y - first.y) } +} + +/** A panel springing back inside the viewport after a release past an edge. */ +interface Settling { + readonly paneId: PaneId + readonly target: FloatRect + readonly started: number + x: SpringAxis + y: SpringAxis + last: number + frame: number +} + /** * Shared move/resize gestures; changing layout mode never owns the content DOM. + * Panels draw at their stored rectangle projected into the viewport, so a + * shrinking window pushes them in without recording anything. A move past an + * edge draws with resistance; its release records the legal rectangle and the + * panel springs there from where it was drawn. * @param state - committed layout and float stacking order. * @param intents - gesture settlements. - * @returns the current preview and focus/gesture callbacks. + * @returns the current preview, the rectangle to draw per panel, and focus/gesture callbacks. */ export function useFloatGestures(state: LayoutState, intents: DockIntents) { const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined) + const [bounds, setBounds] = useState(floatBounds) + const settling = useRef(undefined) const begin = useGesture(() => { setPreview(undefined) }) + /** Stop a running return where it is; reports whether one was running. */ + const cancelSettling = useRef((): boolean => { + const running = settling.current + if (running === undefined) return false + cancelAnimationFrame(running.frame) + settling.current = undefined + return true + }).current + + useEffect(() => { + const resize = (): void => { + if (cancelSettling()) setPreview(undefined) + setBounds(floatBounds()) + } + window.addEventListener('resize', resize) + return () => { + window.removeEventListener('resize', resize) + cancelSettling() + } + }, [cancelSettling]) + + /** One animation frame of a return: advance the spring, or end it at its target. */ + const frame = (): void => { + const running = settling.current as Settling + const now = performance.now() + const gap = now - running.last + running.last = now + if (gap <= SPRING_STALL_MS && now - running.started < SPRING_DEADLINE_MS) { + const dt = Math.min(gap, SPRING_MAX_FRAME_MS) / 1000 + running.x = stepSpring(running.x, running.target.x, dt) + running.y = stepSpring(running.y, running.target.y, dt) + if (!springSettled(running.x, running.target.x) || !springSettled(running.y, running.target.y)) { + setPreview({ paneId: running.paneId, rect: { ...running.target, x: running.x.position, y: running.y.position } }) + running.frame = requestAnimationFrame(frame) + return + } + } + settling.current = undefined + setPreview(undefined) + } + + /** Spring a panel from where it was drawn to its recorded rectangle; a legal axis keeps still. */ + const settle = (paneId: PaneId, from: FloatRect, target: FloatRect, velocity: { x: number; y: number }): void => { + const axis = (position: number, goal: number, speed: number): SpringAxis => + ({ position, velocity: position === goal ? 0 : speed }) + const now = performance.now() + settling.current = { + paneId, target, started: now, last: now, + x: axis(from.x, target.x, velocity.x), y: axis(from.y, target.y, velocity.y), + frame: requestAnimationFrame(frame), + } + setPreview({ paneId, rect: from }) + } + /** Focus and raise a panel from a press or click on it, unless it is raised already. */ const raise = (paneId: PaneId): void => { if (raised(state, paneId)) return intents.focusPane(paneId) } + /** + * Where a panel draws: its gesture or return preview, otherwise its stored + * rectangle projected into the viewport. + * @param paneId - the floating pane. + * @param stored - its recorded rectangle. + * @returns the rectangle to draw. + */ + const rectOf = (paneId: PaneId, stored: FloatRect): FloatRect => + preview?.paneId === paneId ? preview.rect : legalFloatRect(stored, bounds) + /** Start a move or resize from a press on the panel's grip or corner; a release that moved nothing is a click. */ const drag = (mode: 'move' | 'resize', paneId: PaneId, event: ReactPointerEvent): void => { // The press stops here: the panel's own press-to-focus would record a focus // entry before the drag's, and the release below decides which one it is. event.stopPropagation() - const root = document.documentElement - const windows = root.hasAttribute('data-windows-titlebar') - // Desktop publishes the caption height in pixels on the root's inline style. - const caption = windows && !root.hasAttribute('data-fullscreen') - ? Number.parseFloat(root.style.getPropertyValue('--dsh-windows-titlebar-height')) : 0 - const saved = floatRect(getPane(state, paneId)) - const rect = { ...saved, y: Math.max(saved.y, windows ? caption + 20 : 0) } + // A press during a return takes the panel from where it is drawn. + const drawn = settling.current?.paneId === paneId ? preview?.rect : undefined + cancelSettling() + const area = floatBounds() + const reduce = reducedMotion() + const stored = floatRect(getPane(state, paneId)) + const shown = drawn ?? legalFloatRect(stored, area) + const rect = mode === 'move' && !reduce ? unresistedRect(shown, area) : legalFloatRect(shown, area) const start: FloatDrag = { mode, originX: event.clientX, originY: event.clientY, rect } + const samples: PointerSample[] = [{ t: performance.now(), x: event.clientX, y: event.clientY }] + /** The rectangle to draw for a pointer position, and the legal one a release there records. */ + const reached = (x: number, y: number): { readonly drawn: FloatRect; readonly legal: FloatRect } => { + const desired = draggedRect(start, x, y) + if (mode === 'resize') { + const resized = boundedResize(desired, area, FLOAT_MIN_SIZE) + return { drawn: resized, legal: resized } + } + const legal = legalFloatRect(desired, area) + return { drawn: reduce ? legal : resistedRect(desired, area), legal } + } begin(event.currentTarget, event.pointerId, { - move: (moved) => { setPreview({ paneId, rect: draggedRect(start, moved.clientX, moved.clientY) }) }, + move: (moved) => { + samples.push({ t: performance.now(), x: moved.clientX, y: moved.clientY }) + setPreview({ paneId, rect: reached(moved.clientX, moved.clientY).drawn }) + }, up: (released) => { - const rect = draggedRect(start, released.clientX, released.clientY) - if (sameRect(rect, start.rect)) raise(paneId) - else if (mode === 'move') intents.moveFloat(paneId, rect.x, rect.y) - else intents.resizeFloat(paneId, rect) + samples.push({ t: performance.now(), x: released.clientX, y: released.clientY }) + const { drawn, legal } = reached(released.clientX, released.clientY) + if (sameRect(legal, stored)) raise(paneId) + else if (mode === 'move') intents.moveFloat(paneId, legal.x, legal.y) + else intents.resizeFloat(paneId, legal) + if (!sameRect(drawn, legal)) settle(paneId, drawn, legal, releaseVelocity(samples)) }, }) } - return { preview, raise, drag } + return { preview, raise, drag, rectOf } } /** @@ -151,7 +292,7 @@ export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canC /** Every floating panel, in z order. */ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode { - const { preview, raise, drag } = useFloatGestures(state, intents) + const { preview, raise, drag, rectOf } = useFloatGestures(state, intents) return ( <> @@ -161,7 +302,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, // A panel mid-gesture draws where the pointer has taken it and on top, // as the operation its release records will leave it. const lifted = preview?.paneId === paneId ? preview.rect : undefined - const live = lifted ?? floatRect(pane) + const live = rectOf(paneId, floatRect(pane)) return (
{ element.removeEventListener('focus', focus, true) } }, [visible, floating, floats, pane.id, state.activePaneId, callbacks]) const lifted = floating && floats.preview?.paneId === pane.id ? floats.preview.rect : undefined - const rect = floating ? lifted ?? floatRect(pane) : undefined + const rect = floating ? floats.rectOf(pane.id, floatRect(pane)) : undefined const depth = lifted === undefined ? state.floats.indexOf(pane.id) + 1 : state.floats.length + 1 return (