diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml index ba859885ad..4425a38e15 100644 --- a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml @@ -24,14 +24,14 @@ en: 7e29bf1c45b5386d zh: c913a2ce6d1722d1 /agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/change-scope: - en: bec7d9d2e719e2cd - zh: 95e5b3149653cbcf + en: 56a9cedd912c5e40 + zh: bc172d7259e65d3f /agent-note-spring-based-edge-recovery-for-floating-dock-panels/testing: - en: 2108f6e2342bf694 - zh: ce3d4fad4170a493 + en: 5a7866cbc5f587c3 + zh: fa4681952359d006 /agent-note-spring-based-edge-recovery-for-floating-dock-panels/alternatives-considered: en: 3d901f0f5e270e29 zh: c1fb89b567ea3c70 /agent-note-spring-based-edge-recovery-for-floating-dock-panels/consequences: - en: 02ef970d57f83067 - zh: 646acf419161acc4 + en: 6efca78f26d42716 + zh: 48a7bf6b46866a4f 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 index 98fb637dad..88a343efa9 100644 --- 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 @@ -65,8 +65,8 @@ The change touches one package, `@deepseek-ai/dsh-client-ui-dockkit`, and leaves |---|---| | `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 predicate `springSettled`, `launchVelocity` and its timing limits | -| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`: viewport projection, resistance, release spring, drawn-velocity sampling, reduced motion, frame loop and cancellation; `rectOf` and `floatStyle` for both renderers | -| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | draws the hook's displayed rectangle through `floatStyle` | +| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`: viewport projection, resistance, release spring, drawn-velocity sampling, reduced motion, frame loop and cancellation; `rectOf`, `floatStyle` and the memoized `TabBody` for both renderers | +| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | draws the hook's displayed rectangle through `floatStyle` and bodies through `TabBody` | | `packages/client/ui-dockkit/src/components/dockkit.module.css` | the resting clamp of position and size on `.float` | | `packages/client/ui-dockkit/README.md` pair | the edge rule beside the float renderer description | | `apps/web/tests/sidebar-right.e2e.ts` | a browser case for the resistance and the return | @@ -77,7 +77,7 @@ The change touches one package, `@deepseek-ai/dsh-client-ui-dockkit`, and leaves `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, determinism, and the launch cap keeping every return within `K`. `tests/app-region-styles.client.spec.ts` pins the resting clamp in the stylesheet. -`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, a fast fling staying within `K`, a drag above the top floor, 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, a return yielding to an undo, a close and a press on another panel, unmount cancellation, both reduced-motion answers, the viewport projection on shrink and growth without any intent, a click on a projected panel recording nothing, a window resize during a gesture bounding its release, a resize ending a return, and an off-screen stored rectangle drawn inside. The existing `FloatLayer` and `TabLayout` specs still pass with their position assertions read from the resting variables, including the rule that starting a gesture forces no layout read; every changed source file is fully covered. `apps/web/tests/sidebar-right.e2e.ts` drags a Sidebar float past the right and bottom edges in Chromium, checks that the drawn panel stays within 120 px beyond them, waits for the release to settle flush against both edges, then publishes a resting rectangle far outside and larger than the viewport and checks that the stylesheet alone draws it exactly over the viewport. +`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, a fast fling staying within `K`, a drag above the top floor, 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, a body left unrendered through a drag and its return, a return yielding to an undo, a close and a press on another panel, unmount cancellation, both reduced-motion answers, the viewport projection on shrink and growth without any intent, a click on a projected panel recording nothing, a window resize during a gesture bounding its release, a resize ending a return, and an off-screen stored rectangle drawn inside. `tests/tab-layout.client.spec.tsx` adds that a float drag leaves every retained body unrendered. The existing `FloatLayer` and `TabLayout` specs still pass with their position assertions read from the resting variables, including the rule that starting a gesture forces no layout read; every changed source file is fully covered. `apps/web/tests/sidebar-right.e2e.ts` drags a Sidebar float past the right and bottom edges in Chromium, checks that the drawn panel stays within 120 px beyond them, waits for the release to settle flush against both edges, then publishes a resting rectangle far outside and larger than the viewport and checks that the stylesheet alone draws it exactly over the viewport. ## Alternatives considered @@ -101,4 +101,4 @@ The change touches one package, `@deepseek-ai/dsh-client-ui-dockkit`, and leaves 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. +A drag and the spring update the preview through React state, so the layout re-renders on every frame, for up to 900 ms after release as well. Both renderers draw tab bodies through `TabBody`, which is memoized on the renderer and the tab record, so such a frame re-renders only the panel frames and headers, never the embedder's content; writing a transform on the panel element directly is the fallback if the remaining cost 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 index 627d38bd23..030e71bc64 100644 --- 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 @@ -65,8 +65,8 @@ x = x + v * h |---|---| | `packages/client/ui-dockkit/src/engine/geometry.ts` | `FloatBounds`、`FLOAT_RESISTANCE`、`legalFloatRect`、`resistedRect`、`unresistedRect`、`boundedResize` | | `packages/client/ui-dockkit/src/engine/spring.ts` | 纯弹簧积分器、静止判定函数 `springSettled`、`launchVelocity` 与时间上限 | -| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`:视口投影、阻力、松手弹簧、绘制速度采样、减弱动态效果、帧循环与取消;供两个渲染器使用的 `rectOf` 与 `floatStyle` | -| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | 通过 `floatStyle` 绘制 hook 给出的显示矩形 | +| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`:视口投影、阻力、松手弹簧、绘制速度采样、减弱动态效果、帧循环与取消;供两个渲染器使用的 `rectOf`、`floatStyle` 与 memo 化的 `TabBody` | +| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | 通过 `floatStyle` 绘制 hook 给出的显示矩形,通过 `TabBody` 绘制内容 | | `packages/client/ui-dockkit/src/components/dockkit.module.css` | `.float` 上位置与尺寸的静止钳制 | | `packages/client/ui-dockkit/README.md` 对 | 在悬浮渲染器说明旁补充边界规则 | | `apps/web/tests/sidebar-right.e2e.ts` | 覆盖阻力与回弹的浏览器用例 | @@ -77,7 +77,7 @@ x = x + v * h `tests/float-bounds.client.spec.ts` 覆盖投影(内部、每条边、角、顶部下限、超尺寸面板)、阻力(表中数值、边界处斜率为 1、以 `K` 为上界的单调增长、各轴独立)、其反演与上限,以及受限缩放。`tests/spring.client.spec.ts` 覆盖在截止时间内稳定、过冲上限、30/60/120 Hz 下与帧率无关、松手速度的作用、确定性,以及启动速度上限让每条回弹都不超出 `K`。`tests/app-region-styles.client.spec.ts` 固定样式表中的静止钳制。 -`tests/float-recovery.client.spec.tsx` 在活的控制器上渲染 `FloatLayer`,帧与时钟由测试手动推进:越过边界时带阻力的预览、每次松手恰好一次合法的 `moveFloat` 以及带向内过冲的弹回、一步撤销、甩出与慢推的对比、快速甩出不超出 `K`、拖过顶部下限、区域内松手不播放动画、推到存储位置的手势、回弹中按下接住面板且不跳变、停顿直接跳转、截止时间、回弹让位于撤销、关闭与按下另一个面板、卸载取消、两种减弱动态效果答复、视口缩小与变大时的投影且不调用任何 intent、单击投影面板不记录任何操作、手势中窗口缩放约束其松手结果、缩放结束回弹,以及屏幕外存储矩形在视口内绘制。既有的 `FloatLayer` 与 `TabLayout` 测试在位置断言改为读取静止变量后通过,包括开始手势不做布局读取这一规则;每个改动的源文件都被完全覆盖。`apps/web/tests/sidebar-right.e2e.ts` 在 Chromium 中把 Sidebar 悬浮面板拖过右边界和下边界,检查绘制的面板越过两边均不超过 120 px,等待松手后面板贴合两条边静止,再发布一个远在视口外且大于视口的静止矩形,检查仅靠样式表就把它恰好绘制成覆盖整个视口。 +`tests/float-recovery.client.spec.tsx` 在活的控制器上渲染 `FloatLayer`,帧与时钟由测试手动推进:越过边界时带阻力的预览、每次松手恰好一次合法的 `moveFloat` 以及带向内过冲的弹回、一步撤销、甩出与慢推的对比、快速甩出不超出 `K`、拖过顶部下限、区域内松手不播放动画、推到存储位置的手势、回弹中按下接住面板且不跳变、停顿直接跳转、截止时间、拖拽与回弹期间不重渲染标签页内容、回弹让位于撤销、关闭与按下另一个面板、卸载取消、两种减弱动态效果答复、视口缩小与变大时的投影且不调用任何 intent、单击投影面板不记录任何操作、手势中窗口缩放约束其松手结果、缩放结束回弹,以及屏幕外存储矩形在视口内绘制。`tests/tab-layout.client.spec.tsx` 新增:悬浮面板拖拽期间所有保留的标签页内容都不重渲染。既有的 `FloatLayer` 与 `TabLayout` 测试在位置断言改为读取静止变量后通过,包括开始手势不做布局读取这一规则;每个改动的源文件都被完全覆盖。`apps/web/tests/sidebar-right.e2e.ts` 在 Chromium 中把 Sidebar 悬浮面板拖过右边界和下边界,检查绘制的面板越过两边均不超过 120 px,等待松手后面板贴合两条边静止,再发布一个远在视口外且大于视口的静止矩形,检查仅靠样式表就把它恰好绘制成覆盖整个视口。 ## 考虑过的替代方案 @@ -101,4 +101,4 @@ x = x + v * h 悬浮面板在两个客户端中都不会再丢失到屏幕外,推过边界读起来是被拉回而不是撞墙。历史、持久化与所有嵌入方不受影响,运动由确定性测试而非手动检查覆盖。 -弹簧通过 React state 更新预览,每帧成本与拖拽相同,但承载重内容的面板在松手后最多会重渲染 900 ms;如果测量显示问题,退路是直接在面板元素上写 transform。被缩小窗口推入的面板会在窗口变大时朝存储位置回移,期望它留在被推到位置的用户可能会感到意外。面板内的内容(例如 webview)在松手后可能观察到最多 900 ms 的运动。常量针对鼠标或触控板调优;触控输入经由同样的 pointer 事件可用,但仍未调优。 +拖拽与弹簧都通过 React state 更新预览,因此布局每帧都会重渲染,松手后最多还会持续 900 ms。两个渲染器都通过 `TabBody` 绘制标签页内容,它以渲染函数和标签记录为键做了 memo,因此这样的一帧只重渲染面板框与标题栏,从不重渲染嵌入方的内容;如果剩余成本经测量构成问题,退路是直接在面板元素上写 transform。被缩小窗口推入的面板会在窗口变大时朝存储位置回移,期望它留在被推到位置的用户可能会感到意外。面板内的内容(例如 webview)在松手后可能观察到最多 900 ms 的运动。常量针对鼠标或触控板调优;触控输入经由同样的 pointer 事件可用,但仍未调优。 diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index 9a7ba55f51..70a6075af2 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -13,7 +13,7 @@ * 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 { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconCloseOutlineRegular, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' @@ -341,6 +341,18 @@ export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canC ) } +/** + * A tab's body from the embedder's renderer. It is memoized on the renderer and + * the tab record, so a float gesture or return, which re-renders its layout on + * every frame, leaves every body untouched. + */ +export const TabBody = memo(function TabBody({ tab, renderTab }: { + readonly tab: TabRecord + readonly renderTab: TabRenderer +}): ReactNode { + return renderTab(tab) +}) + /** Every floating panel, in z order. */ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode { const { preview, raise, drag, rectOf } = useFloatGestures(state, intents) @@ -368,7 +380,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, onPointerDown={() => { raise(paneId) }} > -
{renderTab(tab)}
+
)}
- {visited && (retained || (active && selected)) ? callbacks.renderTab(tab) : null} + {visited && (retained || (active && selected)) ? : null} {!floating && }
{floating &&
null) const redraw = (): void => { - view.rerender( null} />) + view.rerender() } const moveFloat = vi.fn((id: PaneId, x: number, y: number) => { controller.moveFloat(id, x, y); redraw() }) const resizeFloat = vi.fn((id: PaneId, next: FloatRect) => { controller.resizeFloat(id, next); redraw() }) @@ -77,7 +78,7 @@ function mounted(rect: FloatRect = { x: 100, y: 80, width: 300, height: 200 }) { floatTab: vi.fn(), unfloatPane: vi.fn(), placeTab: vi.fn(), dropTab: vi.fn(), moveFloat, resizeFloat, resizeSplit: vi.fn(), } - const view = render( null} />) + const view = render() const panel = (): HTMLElement => view.container.querySelector(`[data-dockkit-float="${paneId}"]`) as HTMLElement const grip = (): HTMLElement => panel().querySelector('[data-dockkit-float-grip]') as HTMLElement // A lifted panel writes left and top inline; a resting one publishes them to the `.float` rule. @@ -87,7 +88,7 @@ function mounted(rect: FloatRect = { x: 100, y: 80, width: 300, height: 200 }) { const top = (): number => drawn('top', 'y') const stored = (): FloatRect => floatRect(getPane(controller.getSnapshot().state, paneId)) const corner = (): HTMLElement => panel().querySelector('[data-dockkit-float-resize]') as HTMLElement - return { controller, tabId, paneId, view, redraw, moveFloat, resizeFloat, focusPane, left, top, grip, corner, stored } + return { controller, tabId, paneId, view, redraw, renderTab, moveFloat, resizeFloat, focusPane, left, top, grip, corner, stored } } /** Press the grip at `from`, move to `to`, and optionally release there. */ @@ -123,6 +124,16 @@ describe('dragging past an edge', () => { expect(h.top()).toBe(80) }) + it('leaves the body unrendered through the drag and the return', () => { + const h = mounted() + const rendered = h.renderTab.mock.calls.length + drag(h.grip(), [150, 90], [150 + 744, 90]) + runOut() + expect(h.left()).toBe(724) + // The release's moveFloat redraws the layer, but the tab record and the renderer stay the same. + expect(h.renderTab).toHaveBeenCalledTimes(rendered) + }) + it('undoes the whole gesture in one step', () => { const h = mounted() drag(h.grip(), [150, 90], [150 - 400, 90]) diff --git a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx index 7924776768..8fb30545cc 100644 --- a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx @@ -176,6 +176,25 @@ it('keeps body ancestors through pane moves, floating, raising and docking', () expect(body.closest('[data-dockkit-host]')).toBe(host) }) +it('leaves every body unrendered while a float gesture redraws the layout', () => { + const controller = seededController() + controller.setExpanded(true) + const tab = controller.openContent({ kind: 'file', contentId: 'file:a', title: 'A' }) + controller.floatTab(tab, { x: 100, y: 80, width: 300, height: 200 }) + const h = mounted(controller, { keepMounted: () => true }) + const renderTab = vi.fn(h.callbacks.renderTab) + h.redraw({ callbacks: { ...h.callbacks, renderTab } }) + const rendered = renderTab.mock.calls.length + expect(rendered).toBeGreaterThan(0) + const floating = controller.getSnapshot().state.floats[0]! + fireEvent.pointerDown(h.node(`[data-dockkit-float="${floating}"] [data-dockkit-float-grip]`), + { pointerId: 3, button: 0, clientX: 150, clientY: 90 }) + for (const x of [170, 190, 210]) fireEvent.pointerMove(window, { pointerId: 3, clientX: x, clientY: 90 }) + expect(h.node(`[data-dockkit-float="${floating}"]`).style.left).toBe('160px') + expect(renderTab).toHaveBeenCalledTimes(rendered) + fireEvent.pointerUp(window, { pointerId: 3, clientX: 210, clientY: 90 }) +}) + it('unmounts unretained bodies and restores focus to their replacement strip', () => { const controller = seededController() controller.setExpanded(true)