From 9868adb9932f8009e0710f5cfd2b5c3e1207d15c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 30 Aug 2026 20:13:55 -0400 Subject: [PATCH 1/4] feat(ui): animate sidebar transitions --- .changeset/sliding-files-sidebar.md | 5 + src/ui/App.tsx | 24 +++-- src/ui/hooks/useExtensionPaneController.ts | 2 + src/ui/hooks/useSidebarSlideAnimation.ts | 119 +++++++++++++++++++++ src/ui/lib/sidebarSlide.test.ts | 66 ++++++++++++ src/ui/lib/sidebarSlide.ts | 104 ++++++++++++++++++ 6 files changed, 313 insertions(+), 7 deletions(-) create mode 100644 .changeset/sliding-files-sidebar.md create mode 100644 src/ui/hooks/useSidebarSlideAnimation.ts create mode 100644 src/ui/lib/sidebarSlide.test.ts create mode 100644 src/ui/lib/sidebarSlide.ts diff --git a/.changeset/sliding-files-sidebar.md b/.changeset/sliding-files-sidebar.md new file mode 100644 index 000000000..37367b956 --- /dev/null +++ b/.changeset/sliding-files-sidebar.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Animate the files sidebar as it opens and closes, moving the review pane alongside it. diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ac62e8205..ffa630750 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -70,6 +70,7 @@ import { } from "./hooks/useExtensionWorkspaceControls"; import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge"; import { useMenuController } from "./hooks/useMenuController"; +import { useSidebarSlideAnimation } from "./hooks/useSidebarSlideAnimation"; import { useThemeSelectorController } from "./hooks/useThemeSelectorController"; import { useTimedNotice } from "./hooks/useTimedNotice"; import { useUserNoteComposer } from "./hooks/useUserNoteComposer"; @@ -444,6 +445,7 @@ export function App({ currentLinePaint, currentLinePaintRequested, endPaneResize, + filesPaneKey, filesPaneVisible, onCurrentLinePaintChange, paneLayout, @@ -472,6 +474,14 @@ export function App({ responsiveShowsSidebar: responsiveLayout.showSidebar, }); + const presentedPaneLayout = useSidebarSlideAnimation({ + bodyHeight, + bodyWidth, + filesPaneKey, + paneLayout, + resizing: resizingPaneKey !== null, + }); + useEffect(() => { if (resizingPaneKey === null) { setMouseCapture(renderer, undefined); @@ -634,8 +644,8 @@ export function App({ selectedHunkIndex, themeId, }); - const diffPaneWidth = paneLayout.reviewBounds.width; - const diffPaneHeight = paneLayout.reviewBounds.height; + const diffPaneWidth = presentedPaneLayout.reviewBounds.width; + const diffPaneHeight = presentedPaneLayout.reviewBounds.height; const diffContentWidth = Math.max(0, diffPaneWidth - 2); // Publish the live note geometry for daemon-driven markup validation; the // note markup width mirrors what AgentInlineNote lays STML out at. @@ -1166,7 +1176,7 @@ export function App({ const diffHeaderStatsWidth = maxFileHeaderStatsWidth(filteredFiles); const diffHeaderLabelWidth = Math.max(0, diffContentWidth - diffHeaderStatsWidth - 1); const diffSeparatorWidth = Math.max(0, diffContentWidth - 2); - const diffPaneScreenTop = (showMenuBar ? 1 : 0) + paneLayout.reviewBounds.y; + const diffPaneScreenTop = (showMenuBar ? 1 : 0) + presentedPaneLayout.reviewBounds.y; /** Render one pane from the exact accepted host rectangle. */ const renderPane = (planned: PlannedPane) => { @@ -1299,13 +1309,13 @@ export function App({ cancelCopySelectionRef.current?.(); }} > - {paneLayout.panes.map(renderPane)} - {paneLayout.panes.map(renderDivider)} + {presentedPaneLayout.panes.map(renderPane)} + {presentedPaneLayout.panes.map(renderDivider)} void; + filesPaneKey: string; filesPaneVisible: boolean; onCurrentLinePaintChange: (update: ExtensionCurrentLinePaintUpdate) => void; paneLayout: ExtensionPaneLayoutPlan; @@ -637,6 +638,7 @@ export function useExtensionPaneController({ currentLinePaint, currentLinePaintRequested, endPaneResize, + filesPaneKey: visibleFilesPaneKey, filesPaneVisible: visiblePaneKeys.includes(visibleFilesPaneKey), onCurrentLinePaintChange, paneLayout, diff --git a/src/ui/hooks/useSidebarSlideAnimation.ts b/src/ui/hooks/useSidebarSlideAnimation.ts new file mode 100644 index 000000000..ec457a975 --- /dev/null +++ b/src/ui/hooks/useSidebarSlideAnimation.ts @@ -0,0 +1,119 @@ +/** + * Animates explicit files-sidebar visibility changes while semantic pane planning remains immediate. + * + * The hook retains an exiting files pane only in its presentation projection and moves review + * geometry in the same timeline. Terminal resize, pane resize, registration changes, and the first + * mounted layout snap directly to the semantic plan. + */ + +import { useTimeline } from "@opentui/react"; +import { useLayoutEffect, useRef, useState } from "react"; +import type { ExtensionPaneLayoutPlan } from "../lib/extensionPanes"; +import { + interpolateSidebarLayout, + isSidebarVisibilityTransition, + sidebarSlideAnimationDuration, +} from "../lib/sidebarSlide"; + +interface SidebarSlideAnimationOptions { + bodyHeight: number; + bodyWidth: number; + filesPaneKey: string; + paneLayout: ExtensionPaneLayoutPlan; + resizing: boolean; +} + +interface LayoutSnapshot { + bodyHeight: number; + bodyWidth: number; + filesPaneKey: string; + paneLayout: ExtensionPaneLayoutPlan; +} + +interface ActiveTransition { + from: ExtensionPaneLayoutPlan; + to: ExtensionPaneLayoutPlan; + filesPaneKey: string; +} + +/** Return the presentation pane plan for the current sidebar slide frame. */ +export function useSidebarSlideAnimation({ + bodyHeight, + bodyWidth, + filesPaneKey, + paneLayout, + resizing, +}: SidebarSlideAnimationOptions): ExtensionPaneLayoutPlan { + const duration = sidebarSlideAnimationDuration(); + const timeline = useTimeline({ + autoplay: false, + duration: Math.max(1, duration), + }); + const [presentedLayout, setPresentedLayout] = useState(paneLayout); + const presentedLayoutRef = useRef(paneLayout); + const semanticSnapshotRef = useRef(null); + const activeTransitionRef = useRef(null); + const timelineConfiguredRef = useRef(false); + + useLayoutEffect(() => { + if (timelineConfiguredRef.current) return; + timelineConfiguredRef.current = true; + timeline.add( + { progress: 0 }, + { + progress: 1, + duration, + ease: "outCirc", + onUpdate: (animation) => { + const transition = activeTransitionRef.current; + if (!transition) return; + const nextLayout = interpolateSidebarLayout( + transition.from, + transition.to, + transition.filesPaneKey, + animation.progress, + ); + presentedLayoutRef.current = nextLayout; + setPresentedLayout(nextLayout); + }, + onComplete: () => { + const transition = activeTransitionRef.current; + if (!transition) return; + activeTransitionRef.current = null; + presentedLayoutRef.current = transition.to; + setPresentedLayout(transition.to); + }, + }, + ); + }, [duration, timeline]); + + useLayoutEffect(() => { + const previous = semanticSnapshotRef.current; + semanticSnapshotRef.current = { bodyHeight, bodyWidth, filesPaneKey, paneLayout }; + + const canAnimate = + previous !== null && + !resizing && + previous.bodyHeight === bodyHeight && + previous.bodyWidth === bodyWidth && + previous.filesPaneKey === filesPaneKey && + isSidebarVisibilityTransition(previous.paneLayout, paneLayout, filesPaneKey); + + if (!canAnimate) { + activeTransitionRef.current = null; + timeline.pause(); + presentedLayoutRef.current = paneLayout; + setPresentedLayout(paneLayout); + return; + } + + activeTransitionRef.current = { + from: presentedLayoutRef.current, + to: paneLayout, + filesPaneKey, + }; + timeline.restart(); + }, [bodyHeight, bodyWidth, filesPaneKey, paneLayout, resizing, timeline]); + + return presentedLayout; +} diff --git a/src/ui/lib/sidebarSlide.test.ts b/src/ui/lib/sidebarSlide.test.ts new file mode 100644 index 000000000..563d7ebce --- /dev/null +++ b/src/ui/lib/sidebarSlide.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { HUNK_FILES_PANE_KEY } from "../../extensions/extensionIds"; +import { + buildSessionPanes, + planExtensionPanes, + type ExtensionPaneLayoutPlan, +} from "./extensionPanes"; +import { interpolateSidebarLayout, isSidebarVisibilityTransition } from "./sidebarSlide"; + +/** Build matching open and closed semantic layouts for the bundled files pane. */ +function createSidebarLayouts(): { + closed: ExtensionPaneLayoutPlan; + open: ExtensionPaneLayoutPlan; +} { + const panes = buildSessionPanes(undefined); + const plan = (openKeys: readonly string[]) => + planExtensionPanes({ + panes, + openKeys, + sizes: { [HUNK_FILES_PANE_KEY]: 30 }, + bodyWidth: 100, + bodyHeight: 20, + minReviewWidth: 20, + minReviewHeight: 5, + }); + return { + closed: plan([]), + open: plan([HUNK_FILES_PANE_KEY]), + }; +} + +describe("sidebar slide presentation", () => { + test("recognizes only a files-pane visibility change", () => { + const { closed, open } = createSidebarLayouts(); + + expect(isSidebarVisibilityTransition(closed, open, HUNK_FILES_PANE_KEY)).toBe(true); + expect(isSidebarVisibilityTransition(open, closed, HUNK_FILES_PANE_KEY)).toBe(true); + expect(isSidebarVisibilityTransition(open, open, HUNK_FILES_PANE_KEY)).toBe(false); + }); + + test("slides the sidebar and review geometry together when opening", () => { + const { closed, open } = createSidebarLayouts(); + const start = interpolateSidebarLayout(closed, open, HUNK_FILES_PANE_KEY, 0); + const middle = interpolateSidebarLayout(closed, open, HUNK_FILES_PANE_KEY, 0.5); + const openPane = open.panes.find(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)!; + const middlePane = middle.panes.find(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)!; + + expect(start.reviewBounds).toEqual(closed.reviewBounds); + expect(start.panes.find(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)?.bounds.width).toBe(0); + expect(middlePane.bounds.width).toBeGreaterThan(0); + expect(middlePane.bounds.width).toBeLessThan(openPane.bounds.width); + expect(middle.reviewBounds.x).toBeGreaterThan(closed.reviewBounds.x); + expect(middle.reviewBounds.x).toBeLessThan(open.reviewBounds.x); + }); + + test("retains the exiting files pane until the closing frame completes", () => { + const { closed, open } = createSidebarLayouts(); + const middle = interpolateSidebarLayout(open, closed, HUNK_FILES_PANE_KEY, 0.5); + const end = interpolateSidebarLayout(open, closed, HUNK_FILES_PANE_KEY, 1); + + expect(middle.panes.some(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)).toBe(true); + expect(end.panes.some(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)).toBe(true); + expect(end.panes.find(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)?.bounds.width).toBe(0); + expect(end.reviewBounds).toEqual(closed.reviewBounds); + }); +}); diff --git a/src/ui/lib/sidebarSlide.ts b/src/ui/lib/sidebarSlide.ts new file mode 100644 index 000000000..2fcb18ca8 --- /dev/null +++ b/src/ui/lib/sidebarSlide.ts @@ -0,0 +1,104 @@ +import type { ExtensionPaneLayoutPlan, PaneBounds, PlannedPane } from "./extensionPanes"; + +/** Duration of the files-sidebar reveal and dismissal motion. */ +export const SIDEBAR_SLIDE_DURATION_MS = 180; + +/** Keep test-renderer transitions deterministic without changing interactive timing. */ +export function sidebarSlideAnimationDuration(): number { + return process.env.NODE_ENV === "test" ? 0 : SIDEBAR_SLIDE_DURATION_MS; +} + +/** Interpolate terminal geometry while snapping each value to a whole cell. */ +function interpolateBounds(from: PaneBounds, to: PaneBounds, progress: number): PaneBounds { + const value = (start: number, end: number) => Math.round(start + (end - start) * progress); + return { + x: value(from.x, to.x), + y: value(from.y, to.y), + width: value(from.width, to.width), + height: value(from.height, to.height), + }; +} + +/** Collapse a side pane just beyond the edge it enters from. */ +function collapsedPane(planned: PlannedPane): PlannedPane { + const rightEdge = planned.bounds.x + planned.bounds.width; + const x = planned.pane.placement === "right" ? rightEdge : planned.bounds.x; + return { + ...planned, + bounds: { ...planned.bounds, x, width: 0 }, + ...(planned.divider + ? { + divider: { + ...planned.divider, + x, + width: 0, + }, + } + : {}), + }; +} + +/** Return whether two semantic layouts differ only by files-pane visibility. */ +export function isSidebarVisibilityTransition( + from: ExtensionPaneLayoutPlan, + to: ExtensionPaneLayoutPlan, + filesPaneKey: string, +): boolean { + const fromKeys = from.panes.map(({ pane }) => pane.key); + const toKeys = to.panes.map(({ pane }) => pane.key); + const fromFiles = from.panes.find(({ pane }) => pane.key === filesPaneKey); + const toFiles = to.panes.find(({ pane }) => pane.key === filesPaneKey); + if (Boolean(fromFiles) === Boolean(toFiles)) return false; + const filesPane = fromFiles ?? toFiles; + if (filesPane?.pane.placement !== "left" && filesPane?.pane.placement !== "right") return false; + + const withoutFiles = (keys: readonly string[]) => keys.filter((key) => key !== filesPaneKey); + const fromOtherKeys = withoutFiles(fromKeys); + const toOtherKeys = withoutFiles(toKeys); + return ( + fromOtherKeys.length === toOtherKeys.length && + fromOtherKeys.every((key, index) => key === toOtherKeys[index]) + ); +} + +/** Project one animation frame without changing the authoritative semantic pane plan. */ +export function interpolateSidebarLayout( + from: ExtensionPaneLayoutPlan, + to: ExtensionPaneLayoutPlan, + filesPaneKey: string, + progress: number, +): ExtensionPaneLayoutPlan { + const boundedProgress = Math.min(1, Math.max(0, progress)); + const fromByKey = new Map(from.panes.map((planned) => [planned.pane.key, planned])); + const toByKey = new Map(to.panes.map((planned) => [planned.pane.key, planned])); + const layoutWithFiles = fromByKey.has(filesPaneKey) ? from : to; + const keys = layoutWithFiles.panes.map(({ pane }) => pane.key); + + const panes = keys.flatMap((key) => { + const fromPane = fromByKey.get(key); + const toPane = toByKey.get(key); + if (!fromPane && !toPane) return []; + + const start = fromPane ?? (toPane && key === filesPaneKey ? collapsedPane(toPane) : toPane); + const end = toPane ?? (fromPane && key === filesPaneKey ? collapsedPane(fromPane) : fromPane); + if (!start || !end) return []; + + const divider = + start.divider && end.divider + ? interpolateBounds(start.divider, end.divider, boundedProgress) + : end.divider; + return [ + { + pane: end.pane, + bounds: interpolateBounds(start.bounds, end.bounds, boundedProgress), + ...(divider ? { divider } : {}), + }, + ]; + }); + + return { + panes, + reviewBounds: interpolateBounds(from.reviewBounds, to.reviewBounds, boundedProgress), + omittedKeys: to.omittedKeys, + }; +} From ba13e04c0ce053078e540cd4c8a4eeec646944f6 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 30 Aug 2026 20:42:10 -0400 Subject: [PATCH 2/4] feat(ui): animate all docked panes --- .changeset/sliding-files-sidebar.md | 5 - .changeset/sliding-panes.md | 5 + src/ui/App.tsx | 6 +- src/ui/hooks/useExtensionPaneController.ts | 2 - ...eAnimation.ts => usePaneSlideAnimation.ts} | 50 ++++---- src/ui/lib/paneSlide.test.ts | 100 ++++++++++++++++ src/ui/lib/paneSlide.ts | 113 ++++++++++++++++++ src/ui/lib/sidebarSlide.test.ts | 66 ---------- src/ui/lib/sidebarSlide.ts | 104 ---------------- 9 files changed, 244 insertions(+), 207 deletions(-) delete mode 100644 .changeset/sliding-files-sidebar.md create mode 100644 .changeset/sliding-panes.md rename src/ui/hooks/{useSidebarSlideAnimation.ts => usePaneSlideAnimation.ts} (64%) create mode 100644 src/ui/lib/paneSlide.test.ts create mode 100644 src/ui/lib/paneSlide.ts delete mode 100644 src/ui/lib/sidebarSlide.test.ts delete mode 100644 src/ui/lib/sidebarSlide.ts diff --git a/.changeset/sliding-files-sidebar.md b/.changeset/sliding-files-sidebar.md deleted file mode 100644 index 37367b956..000000000 --- a/.changeset/sliding-files-sidebar.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"hunkdiff": minor ---- - -Animate the files sidebar as it opens and closes, moving the review pane alongside it. diff --git a/.changeset/sliding-panes.md b/.changeset/sliding-panes.md new file mode 100644 index 000000000..788e14c92 --- /dev/null +++ b/.changeset/sliding-panes.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Animate docked panes as they open and close, moving the review pane alongside them. diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ffa630750..02fc75e1a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -70,7 +70,7 @@ import { } from "./hooks/useExtensionWorkspaceControls"; import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge"; import { useMenuController } from "./hooks/useMenuController"; -import { useSidebarSlideAnimation } from "./hooks/useSidebarSlideAnimation"; +import { usePaneSlideAnimation } from "./hooks/usePaneSlideAnimation"; import { useThemeSelectorController } from "./hooks/useThemeSelectorController"; import { useTimedNotice } from "./hooks/useTimedNotice"; import { useUserNoteComposer } from "./hooks/useUserNoteComposer"; @@ -445,7 +445,6 @@ export function App({ currentLinePaint, currentLinePaintRequested, endPaneResize, - filesPaneKey, filesPaneVisible, onCurrentLinePaintChange, paneLayout, @@ -474,10 +473,9 @@ export function App({ responsiveShowsSidebar: responsiveLayout.showSidebar, }); - const presentedPaneLayout = useSidebarSlideAnimation({ + const presentedPaneLayout = usePaneSlideAnimation({ bodyHeight, bodyWidth, - filesPaneKey, paneLayout, resizing: resizingPaneKey !== null, }); diff --git a/src/ui/hooks/useExtensionPaneController.ts b/src/ui/hooks/useExtensionPaneController.ts index 00dc50166..d00be0c5c 100644 --- a/src/ui/hooks/useExtensionPaneController.ts +++ b/src/ui/hooks/useExtensionPaneController.ts @@ -80,7 +80,6 @@ export interface ExtensionPaneController { currentLinePaint: ExtensionCurrentLinePaint | null; currentLinePaintRequested: boolean; endPaneResize: (event?: TuiMouseEvent) => void; - filesPaneKey: string; filesPaneVisible: boolean; onCurrentLinePaintChange: (update: ExtensionCurrentLinePaintUpdate) => void; paneLayout: ExtensionPaneLayoutPlan; @@ -638,7 +637,6 @@ export function useExtensionPaneController({ currentLinePaint, currentLinePaintRequested, endPaneResize, - filesPaneKey: visibleFilesPaneKey, filesPaneVisible: visiblePaneKeys.includes(visibleFilesPaneKey), onCurrentLinePaintChange, paneLayout, diff --git a/src/ui/hooks/useSidebarSlideAnimation.ts b/src/ui/hooks/usePaneSlideAnimation.ts similarity index 64% rename from src/ui/hooks/useSidebarSlideAnimation.ts rename to src/ui/hooks/usePaneSlideAnimation.ts index ec457a975..e316d4733 100644 --- a/src/ui/hooks/useSidebarSlideAnimation.ts +++ b/src/ui/hooks/usePaneSlideAnimation.ts @@ -1,24 +1,23 @@ /** - * Animates explicit files-sidebar visibility changes while semantic pane planning remains immediate. + * Animates one pane visibility change while semantic pane planning remains immediate. * - * The hook retains an exiting files pane only in its presentation projection and moves review - * geometry in the same timeline. Terminal resize, pane resize, registration changes, and the first - * mounted layout snap directly to the semantic plan. + * The hook retains an exiting pane only in its presentation projection and moves the other panes + * and review geometry in the same timeline. Terminal resize, pane resize, broader registration + * changes, and the first mounted layout snap directly to the semantic plan. */ import { useTimeline } from "@opentui/react"; import { useLayoutEffect, useRef, useState } from "react"; import type { ExtensionPaneLayoutPlan } from "../lib/extensionPanes"; import { - interpolateSidebarLayout, - isSidebarVisibilityTransition, - sidebarSlideAnimationDuration, -} from "../lib/sidebarSlide"; + interpolatePaneLayout, + paneSlideAnimationDuration, + paneVisibilityTransitionKey, +} from "../lib/paneSlide"; -interface SidebarSlideAnimationOptions { +interface PaneSlideAnimationOptions { bodyHeight: number; bodyWidth: number; - filesPaneKey: string; paneLayout: ExtensionPaneLayoutPlan; resizing: boolean; } @@ -26,25 +25,23 @@ interface SidebarSlideAnimationOptions { interface LayoutSnapshot { bodyHeight: number; bodyWidth: number; - filesPaneKey: string; paneLayout: ExtensionPaneLayoutPlan; } interface ActiveTransition { from: ExtensionPaneLayoutPlan; to: ExtensionPaneLayoutPlan; - filesPaneKey: string; + paneKey: string; } -/** Return the presentation pane plan for the current sidebar slide frame. */ -export function useSidebarSlideAnimation({ +/** Return the presentation pane plan for the current pane slide frame. */ +export function usePaneSlideAnimation({ bodyHeight, bodyWidth, - filesPaneKey, paneLayout, resizing, -}: SidebarSlideAnimationOptions): ExtensionPaneLayoutPlan { - const duration = sidebarSlideAnimationDuration(); +}: PaneSlideAnimationOptions): ExtensionPaneLayoutPlan { + const duration = paneSlideAnimationDuration(); const timeline = useTimeline({ autoplay: false, duration: Math.max(1, duration), @@ -67,10 +64,10 @@ export function useSidebarSlideAnimation({ onUpdate: (animation) => { const transition = activeTransitionRef.current; if (!transition) return; - const nextLayout = interpolateSidebarLayout( + const nextLayout = interpolatePaneLayout( transition.from, transition.to, - transition.filesPaneKey, + transition.paneKey, animation.progress, ); presentedLayoutRef.current = nextLayout; @@ -89,15 +86,16 @@ export function useSidebarSlideAnimation({ useLayoutEffect(() => { const previous = semanticSnapshotRef.current; - semanticSnapshotRef.current = { bodyHeight, bodyWidth, filesPaneKey, paneLayout }; - + semanticSnapshotRef.current = { bodyHeight, bodyWidth, paneLayout }; + const transitionKey = previous + ? paneVisibilityTransitionKey(previous.paneLayout, paneLayout) + : null; const canAnimate = previous !== null && + transitionKey !== null && !resizing && previous.bodyHeight === bodyHeight && - previous.bodyWidth === bodyWidth && - previous.filesPaneKey === filesPaneKey && - isSidebarVisibilityTransition(previous.paneLayout, paneLayout, filesPaneKey); + previous.bodyWidth === bodyWidth; if (!canAnimate) { activeTransitionRef.current = null; @@ -110,10 +108,10 @@ export function useSidebarSlideAnimation({ activeTransitionRef.current = { from: presentedLayoutRef.current, to: paneLayout, - filesPaneKey, + paneKey: transitionKey, }; timeline.restart(); - }, [bodyHeight, bodyWidth, filesPaneKey, paneLayout, resizing, timeline]); + }, [bodyHeight, bodyWidth, paneLayout, resizing, timeline]); return presentedLayout; } diff --git a/src/ui/lib/paneSlide.test.ts b/src/ui/lib/paneSlide.test.ts new file mode 100644 index 000000000..6595119b9 --- /dev/null +++ b/src/ui/lib/paneSlide.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; +import type { ExtensionPane } from "../../extension-api/types"; +import { HUNK_FILES_PANE_KEY } from "../../extensions/extensionIds"; +import { + buildSessionPanes, + planExtensionPanes, + type ExtensionPaneLayoutPlan, + type SessionPane, +} from "./extensionPanes"; +import { interpolatePaneLayout, paneVisibilityTransitionKey } from "./paneSlide"; + +/** Build matching open and closed layouts for a pane at one edge. */ +function createPaneLayouts(placement: SessionPane["placement"]): { + closed: ExtensionPaneLayoutPlan; + open: ExtensionPaneLayoutPlan; + paneKey: string; +} { + const bundled = buildSessionPanes(undefined)[0]!; + const paneKey = placement === "left" ? HUNK_FILES_PANE_KEY : `test:${placement}`; + const pane: SessionPane = { + ...bundled, + key: paneKey, + placement, + registered: { + ...bundled.registered, + pane: { ...bundled.registered.pane, id: placement, placement } as ExtensionPane, + }, + }; + const plan = (openKeys: readonly string[]) => + planExtensionPanes({ + panes: [pane], + openKeys, + sizes: { [paneKey]: placement === "left" || placement === "right" ? 30 : 8 }, + bodyWidth: 100, + bodyHeight: 30, + minReviewWidth: 20, + minReviewHeight: 5, + }); + return { + closed: plan([]), + open: plan([paneKey]), + paneKey, + }; +} + +describe("pane slide presentation", () => { + test("recognizes any sole pane visibility change", () => { + for (const placement of ["left", "right", "top", "bottom"] as const) { + const { closed, open, paneKey } = createPaneLayouts(placement); + expect(paneVisibilityTransitionKey(closed, open)).toBe(paneKey); + expect(paneVisibilityTransitionKey(open, closed)).toBe(paneKey); + expect(paneVisibilityTransitionKey(open, open)).toBeNull(); + } + }); + + test("slides horizontal panes and review geometry together", () => { + for (const placement of ["left", "right"] as const) { + const { closed, open, paneKey } = createPaneLayouts(placement); + const start = interpolatePaneLayout(closed, open, paneKey, 0); + const middle = interpolatePaneLayout(closed, open, paneKey, 0.5); + const openPane = open.panes.find(({ pane }) => pane.key === paneKey)!; + const middlePane = middle.panes.find(({ pane }) => pane.key === paneKey)!; + + expect(start.reviewBounds).toEqual(closed.reviewBounds); + expect(start.panes.find(({ pane }) => pane.key === paneKey)?.bounds.width).toBe(0); + expect(middlePane.bounds.width).toBeGreaterThan(0); + expect(middlePane.bounds.width).toBeLessThan(openPane.bounds.width); + expect(middle.reviewBounds.width).toBeGreaterThan(open.reviewBounds.width); + expect(middle.reviewBounds.width).toBeLessThan(closed.reviewBounds.width); + } + }); + + test("slides vertical panes and review geometry together", () => { + for (const placement of ["top", "bottom"] as const) { + const { closed, open, paneKey } = createPaneLayouts(placement); + const start = interpolatePaneLayout(closed, open, paneKey, 0); + const middle = interpolatePaneLayout(closed, open, paneKey, 0.5); + const openPane = open.panes.find(({ pane }) => pane.key === paneKey)!; + const middlePane = middle.panes.find(({ pane }) => pane.key === paneKey)!; + + expect(start.reviewBounds).toEqual(closed.reviewBounds); + expect(start.panes.find(({ pane }) => pane.key === paneKey)?.bounds.height).toBe(0); + expect(middlePane.bounds.height).toBeGreaterThan(0); + expect(middlePane.bounds.height).toBeLessThan(openPane.bounds.height); + expect(middle.reviewBounds.height).toBeGreaterThan(open.reviewBounds.height); + expect(middle.reviewBounds.height).toBeLessThan(closed.reviewBounds.height); + } + }); + + test("retains an exiting pane until the closing frame completes", () => { + const { closed, open, paneKey } = createPaneLayouts("bottom"); + const middle = interpolatePaneLayout(open, closed, paneKey, 0.5); + const end = interpolatePaneLayout(open, closed, paneKey, 1); + + expect(middle.panes.some(({ pane }) => pane.key === paneKey)).toBe(true); + expect(end.panes.some(({ pane }) => pane.key === paneKey)).toBe(true); + expect(end.panes.find(({ pane }) => pane.key === paneKey)?.bounds.height).toBe(0); + expect(end.reviewBounds).toEqual(closed.reviewBounds); + }); +}); diff --git a/src/ui/lib/paneSlide.ts b/src/ui/lib/paneSlide.ts new file mode 100644 index 000000000..cd0fddda5 --- /dev/null +++ b/src/ui/lib/paneSlide.ts @@ -0,0 +1,113 @@ +import type { ExtensionPaneLayoutPlan, PaneBounds, PlannedPane } from "./extensionPanes"; + +/** Duration of pane reveal and dismissal motion. */ +export const PANE_SLIDE_DURATION_MS = 180; + +/** Keep test-renderer transitions deterministic without changing interactive timing. */ +export function paneSlideAnimationDuration(): number { + return process.env.NODE_ENV === "test" ? 0 : PANE_SLIDE_DURATION_MS; +} + +/** Interpolate terminal geometry while snapping each value to a whole cell. */ +function interpolateBounds(from: PaneBounds, to: PaneBounds, progress: number): PaneBounds { + const value = (start: number, end: number) => Math.round(start + (end - start) * progress); + return { + x: value(from.x, to.x), + y: value(from.y, to.y), + width: value(from.width, to.width), + height: value(from.height, to.height), + }; +} + +/** Collapse a pane against the edge it enters from. */ +function collapsedPane(planned: PlannedPane): PlannedPane { + const horizontal = planned.pane.placement === "left" || planned.pane.placement === "right"; + const trailing = planned.pane.placement === "right" || planned.pane.placement === "bottom"; + const collapse = (bounds: PaneBounds): PaneBounds => + horizontal + ? { + ...bounds, + x: trailing ? bounds.x + bounds.width : bounds.x, + width: 0, + } + : { + ...bounds, + y: trailing ? bounds.y + bounds.height : bounds.y, + height: 0, + }; + + return { + ...planned, + bounds: collapse(planned.bounds), + ...(planned.divider ? { divider: collapse(planned.divider) } : {}), + }; +} + +/** Return the sole pane whose visibility changed, or null for a broader layout change. */ +export function paneVisibilityTransitionKey( + from: ExtensionPaneLayoutPlan, + to: ExtensionPaneLayoutPlan, +): string | null { + const fromKeys = from.panes.map(({ pane }) => pane.key); + const toKeys = to.panes.map(({ pane }) => pane.key); + const fromSet = new Set(fromKeys); + const toSet = new Set(toKeys); + const changedKeys = [ + ...fromKeys.filter((key) => !toSet.has(key)), + ...toKeys.filter((key) => !fromSet.has(key)), + ]; + if (changedKeys.length !== 1) return null; + + const changedKey = changedKeys[0]!; + const withoutChanged = (keys: readonly string[]) => keys.filter((key) => key !== changedKey); + const fromOtherKeys = withoutChanged(fromKeys); + const toOtherKeys = withoutChanged(toKeys); + return fromOtherKeys.length === toOtherKeys.length && + fromOtherKeys.every((key, index) => key === toOtherKeys[index]) + ? changedKey + : null; +} + +/** Project one pane animation frame without changing the authoritative semantic plan. */ +export function interpolatePaneLayout( + from: ExtensionPaneLayoutPlan, + to: ExtensionPaneLayoutPlan, + transitioningPaneKey: string, + progress: number, +): ExtensionPaneLayoutPlan { + const boundedProgress = Math.min(1, Math.max(0, progress)); + const fromByKey = new Map(from.panes.map((planned) => [planned.pane.key, planned])); + const toByKey = new Map(to.panes.map((planned) => [planned.pane.key, planned])); + const layoutWithTransitioningPane = fromByKey.has(transitioningPaneKey) ? from : to; + const keys = layoutWithTransitioningPane.panes.map(({ pane }) => pane.key); + + const panes = keys.flatMap((key) => { + const fromPane = fromByKey.get(key); + const toPane = toByKey.get(key); + if (!fromPane && !toPane) return []; + + const start = + fromPane ?? (toPane && key === transitioningPaneKey ? collapsedPane(toPane) : toPane); + const end = + toPane ?? (fromPane && key === transitioningPaneKey ? collapsedPane(fromPane) : fromPane); + if (!start || !end) return []; + + const divider = + start.divider && end.divider + ? interpolateBounds(start.divider, end.divider, boundedProgress) + : end.divider; + return [ + { + pane: end.pane, + bounds: interpolateBounds(start.bounds, end.bounds, boundedProgress), + ...(divider ? { divider } : {}), + }, + ]; + }); + + return { + panes, + reviewBounds: interpolateBounds(from.reviewBounds, to.reviewBounds, boundedProgress), + omittedKeys: to.omittedKeys, + }; +} diff --git a/src/ui/lib/sidebarSlide.test.ts b/src/ui/lib/sidebarSlide.test.ts deleted file mode 100644 index 563d7ebce..000000000 --- a/src/ui/lib/sidebarSlide.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { HUNK_FILES_PANE_KEY } from "../../extensions/extensionIds"; -import { - buildSessionPanes, - planExtensionPanes, - type ExtensionPaneLayoutPlan, -} from "./extensionPanes"; -import { interpolateSidebarLayout, isSidebarVisibilityTransition } from "./sidebarSlide"; - -/** Build matching open and closed semantic layouts for the bundled files pane. */ -function createSidebarLayouts(): { - closed: ExtensionPaneLayoutPlan; - open: ExtensionPaneLayoutPlan; -} { - const panes = buildSessionPanes(undefined); - const plan = (openKeys: readonly string[]) => - planExtensionPanes({ - panes, - openKeys, - sizes: { [HUNK_FILES_PANE_KEY]: 30 }, - bodyWidth: 100, - bodyHeight: 20, - minReviewWidth: 20, - minReviewHeight: 5, - }); - return { - closed: plan([]), - open: plan([HUNK_FILES_PANE_KEY]), - }; -} - -describe("sidebar slide presentation", () => { - test("recognizes only a files-pane visibility change", () => { - const { closed, open } = createSidebarLayouts(); - - expect(isSidebarVisibilityTransition(closed, open, HUNK_FILES_PANE_KEY)).toBe(true); - expect(isSidebarVisibilityTransition(open, closed, HUNK_FILES_PANE_KEY)).toBe(true); - expect(isSidebarVisibilityTransition(open, open, HUNK_FILES_PANE_KEY)).toBe(false); - }); - - test("slides the sidebar and review geometry together when opening", () => { - const { closed, open } = createSidebarLayouts(); - const start = interpolateSidebarLayout(closed, open, HUNK_FILES_PANE_KEY, 0); - const middle = interpolateSidebarLayout(closed, open, HUNK_FILES_PANE_KEY, 0.5); - const openPane = open.panes.find(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)!; - const middlePane = middle.panes.find(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)!; - - expect(start.reviewBounds).toEqual(closed.reviewBounds); - expect(start.panes.find(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)?.bounds.width).toBe(0); - expect(middlePane.bounds.width).toBeGreaterThan(0); - expect(middlePane.bounds.width).toBeLessThan(openPane.bounds.width); - expect(middle.reviewBounds.x).toBeGreaterThan(closed.reviewBounds.x); - expect(middle.reviewBounds.x).toBeLessThan(open.reviewBounds.x); - }); - - test("retains the exiting files pane until the closing frame completes", () => { - const { closed, open } = createSidebarLayouts(); - const middle = interpolateSidebarLayout(open, closed, HUNK_FILES_PANE_KEY, 0.5); - const end = interpolateSidebarLayout(open, closed, HUNK_FILES_PANE_KEY, 1); - - expect(middle.panes.some(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)).toBe(true); - expect(end.panes.some(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)).toBe(true); - expect(end.panes.find(({ pane }) => pane.key === HUNK_FILES_PANE_KEY)?.bounds.width).toBe(0); - expect(end.reviewBounds).toEqual(closed.reviewBounds); - }); -}); diff --git a/src/ui/lib/sidebarSlide.ts b/src/ui/lib/sidebarSlide.ts deleted file mode 100644 index 2fcb18ca8..000000000 --- a/src/ui/lib/sidebarSlide.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { ExtensionPaneLayoutPlan, PaneBounds, PlannedPane } from "./extensionPanes"; - -/** Duration of the files-sidebar reveal and dismissal motion. */ -export const SIDEBAR_SLIDE_DURATION_MS = 180; - -/** Keep test-renderer transitions deterministic without changing interactive timing. */ -export function sidebarSlideAnimationDuration(): number { - return process.env.NODE_ENV === "test" ? 0 : SIDEBAR_SLIDE_DURATION_MS; -} - -/** Interpolate terminal geometry while snapping each value to a whole cell. */ -function interpolateBounds(from: PaneBounds, to: PaneBounds, progress: number): PaneBounds { - const value = (start: number, end: number) => Math.round(start + (end - start) * progress); - return { - x: value(from.x, to.x), - y: value(from.y, to.y), - width: value(from.width, to.width), - height: value(from.height, to.height), - }; -} - -/** Collapse a side pane just beyond the edge it enters from. */ -function collapsedPane(planned: PlannedPane): PlannedPane { - const rightEdge = planned.bounds.x + planned.bounds.width; - const x = planned.pane.placement === "right" ? rightEdge : planned.bounds.x; - return { - ...planned, - bounds: { ...planned.bounds, x, width: 0 }, - ...(planned.divider - ? { - divider: { - ...planned.divider, - x, - width: 0, - }, - } - : {}), - }; -} - -/** Return whether two semantic layouts differ only by files-pane visibility. */ -export function isSidebarVisibilityTransition( - from: ExtensionPaneLayoutPlan, - to: ExtensionPaneLayoutPlan, - filesPaneKey: string, -): boolean { - const fromKeys = from.panes.map(({ pane }) => pane.key); - const toKeys = to.panes.map(({ pane }) => pane.key); - const fromFiles = from.panes.find(({ pane }) => pane.key === filesPaneKey); - const toFiles = to.panes.find(({ pane }) => pane.key === filesPaneKey); - if (Boolean(fromFiles) === Boolean(toFiles)) return false; - const filesPane = fromFiles ?? toFiles; - if (filesPane?.pane.placement !== "left" && filesPane?.pane.placement !== "right") return false; - - const withoutFiles = (keys: readonly string[]) => keys.filter((key) => key !== filesPaneKey); - const fromOtherKeys = withoutFiles(fromKeys); - const toOtherKeys = withoutFiles(toKeys); - return ( - fromOtherKeys.length === toOtherKeys.length && - fromOtherKeys.every((key, index) => key === toOtherKeys[index]) - ); -} - -/** Project one animation frame without changing the authoritative semantic pane plan. */ -export function interpolateSidebarLayout( - from: ExtensionPaneLayoutPlan, - to: ExtensionPaneLayoutPlan, - filesPaneKey: string, - progress: number, -): ExtensionPaneLayoutPlan { - const boundedProgress = Math.min(1, Math.max(0, progress)); - const fromByKey = new Map(from.panes.map((planned) => [planned.pane.key, planned])); - const toByKey = new Map(to.panes.map((planned) => [planned.pane.key, planned])); - const layoutWithFiles = fromByKey.has(filesPaneKey) ? from : to; - const keys = layoutWithFiles.panes.map(({ pane }) => pane.key); - - const panes = keys.flatMap((key) => { - const fromPane = fromByKey.get(key); - const toPane = toByKey.get(key); - if (!fromPane && !toPane) return []; - - const start = fromPane ?? (toPane && key === filesPaneKey ? collapsedPane(toPane) : toPane); - const end = toPane ?? (fromPane && key === filesPaneKey ? collapsedPane(fromPane) : fromPane); - if (!start || !end) return []; - - const divider = - start.divider && end.divider - ? interpolateBounds(start.divider, end.divider, boundedProgress) - : end.divider; - return [ - { - pane: end.pane, - bounds: interpolateBounds(start.bounds, end.bounds, boundedProgress), - ...(divider ? { divider } : {}), - }, - ]; - }); - - return { - panes, - reviewBounds: interpolateBounds(from.reviewBounds, to.reviewBounds, boundedProgress), - omittedKeys: to.omittedKeys, - }; -} From f206a0795bc8a0f04d55c232fad53f4ceb7c40a3 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 30 Aug 2026 22:05:51 -0400 Subject: [PATCH 3/4] perf(ui): smooth pane slide rendering --- src/ui/hooks/usePaneSlideAnimation.ts | 4 +- src/ui/lib/paneSlide.test.ts | 37 ++++++++++++---- src/ui/lib/paneSlide.ts | 62 ++++++++++++++++++--------- 3 files changed, 74 insertions(+), 29 deletions(-) diff --git a/src/ui/hooks/usePaneSlideAnimation.ts b/src/ui/hooks/usePaneSlideAnimation.ts index e316d4733..ca4e3f351 100644 --- a/src/ui/hooks/usePaneSlideAnimation.ts +++ b/src/ui/hooks/usePaneSlideAnimation.ts @@ -11,6 +11,7 @@ import { useLayoutEffect, useRef, useState } from "react"; import type { ExtensionPaneLayoutPlan } from "../lib/extensionPanes"; import { interpolatePaneLayout, + paneLayoutGeometryEqual, paneSlideAnimationDuration, paneVisibilityTransitionKey, } from "../lib/paneSlide"; @@ -60,7 +61,7 @@ export function usePaneSlideAnimation({ { progress: 1, duration, - ease: "outCirc", + ease: "outQuad", onUpdate: (animation) => { const transition = activeTransitionRef.current; if (!transition) return; @@ -70,6 +71,7 @@ export function usePaneSlideAnimation({ transition.paneKey, animation.progress, ); + if (paneLayoutGeometryEqual(presentedLayoutRef.current, nextLayout)) return; presentedLayoutRef.current = nextLayout; setPresentedLayout(nextLayout); }, diff --git a/src/ui/lib/paneSlide.test.ts b/src/ui/lib/paneSlide.test.ts index 6595119b9..49a5c5721 100644 --- a/src/ui/lib/paneSlide.test.ts +++ b/src/ui/lib/paneSlide.test.ts @@ -7,7 +7,11 @@ import { type ExtensionPaneLayoutPlan, type SessionPane, } from "./extensionPanes"; -import { interpolatePaneLayout, paneVisibilityTransitionKey } from "./paneSlide"; +import { + interpolatePaneLayout, + paneLayoutGeometryEqual, + paneVisibilityTransitionKey, +} from "./paneSlide"; /** Build matching open and closed layouts for a pane at one edge. */ function createPaneLayouts(placement: SessionPane["placement"]): { @@ -61,10 +65,12 @@ describe("pane slide presentation", () => { const openPane = open.panes.find(({ pane }) => pane.key === paneKey)!; const middlePane = middle.panes.find(({ pane }) => pane.key === paneKey)!; + const startPane = start.panes.find(({ pane }) => pane.key === paneKey)!; expect(start.reviewBounds).toEqual(closed.reviewBounds); - expect(start.panes.find(({ pane }) => pane.key === paneKey)?.bounds.width).toBe(0); - expect(middlePane.bounds.width).toBeGreaterThan(0); - expect(middlePane.bounds.width).toBeLessThan(openPane.bounds.width); + expect(startPane.bounds.width).toBe(openPane.bounds.width); + expect(startPane.bounds.x).not.toBe(openPane.bounds.x); + expect(middlePane.bounds.width).toBe(openPane.bounds.width); + expect(middlePane.bounds.x).not.toBe(openPane.bounds.x); expect(middle.reviewBounds.width).toBeGreaterThan(open.reviewBounds.width); expect(middle.reviewBounds.width).toBeLessThan(closed.reviewBounds.width); } @@ -78,15 +84,27 @@ describe("pane slide presentation", () => { const openPane = open.panes.find(({ pane }) => pane.key === paneKey)!; const middlePane = middle.panes.find(({ pane }) => pane.key === paneKey)!; + const startPane = start.panes.find(({ pane }) => pane.key === paneKey)!; expect(start.reviewBounds).toEqual(closed.reviewBounds); - expect(start.panes.find(({ pane }) => pane.key === paneKey)?.bounds.height).toBe(0); - expect(middlePane.bounds.height).toBeGreaterThan(0); - expect(middlePane.bounds.height).toBeLessThan(openPane.bounds.height); + expect(startPane.bounds.height).toBe(openPane.bounds.height); + expect(startPane.bounds.y).not.toBe(openPane.bounds.y); + expect(middlePane.bounds.height).toBe(openPane.bounds.height); + expect(middlePane.bounds.y).not.toBe(openPane.bounds.y); expect(middle.reviewBounds.height).toBeGreaterThan(open.reviewBounds.height); expect(middle.reviewBounds.height).toBeLessThan(closed.reviewBounds.height); } }); + test("deduplicates timeline updates that round to the same terminal cells", () => { + const { closed, open, paneKey } = createPaneLayouts("top"); + const first = interpolatePaneLayout(closed, open, paneKey, 0.1); + const sameCells = interpolatePaneLayout(closed, open, paneKey, 0.101); + const later = interpolatePaneLayout(closed, open, paneKey, 0.5); + + expect(paneLayoutGeometryEqual(first, sameCells)).toBe(true); + expect(paneLayoutGeometryEqual(first, later)).toBe(false); + }); + test("retains an exiting pane until the closing frame completes", () => { const { closed, open, paneKey } = createPaneLayouts("bottom"); const middle = interpolatePaneLayout(open, closed, paneKey, 0.5); @@ -94,7 +112,10 @@ describe("pane slide presentation", () => { expect(middle.panes.some(({ pane }) => pane.key === paneKey)).toBe(true); expect(end.panes.some(({ pane }) => pane.key === paneKey)).toBe(true); - expect(end.panes.find(({ pane }) => pane.key === paneKey)?.bounds.height).toBe(0); + const openPane = open.panes.find(({ pane }) => pane.key === paneKey)!; + const endPane = end.panes.find(({ pane }) => pane.key === paneKey)!; + expect(endPane.bounds.height).toBe(openPane.bounds.height); + expect(endPane.bounds.y).toBeGreaterThan(openPane.bounds.y); expect(end.reviewBounds).toEqual(closed.reviewBounds); }); }); diff --git a/src/ui/lib/paneSlide.ts b/src/ui/lib/paneSlide.ts index cd0fddda5..57f660c4e 100644 --- a/src/ui/lib/paneSlide.ts +++ b/src/ui/lib/paneSlide.ts @@ -19,27 +19,23 @@ function interpolateBounds(from: PaneBounds, to: PaneBounds, progress: number): }; } -/** Collapse a pane against the edge it enters from. */ -function collapsedPane(planned: PlannedPane): PlannedPane { - const horizontal = planned.pane.placement === "left" || planned.pane.placement === "right"; - const trailing = planned.pane.placement === "right" || planned.pane.placement === "bottom"; - const collapse = (bounds: PaneBounds): PaneBounds => - horizontal - ? { - ...bounds, - x: trailing ? bounds.x + bounds.width : bounds.x, - width: 0, - } - : { - ...bounds, - y: trailing ? bounds.y + bounds.height : bounds.y, - height: 0, - }; +/** Move a full-size pane just beyond the edge it enters from. */ +function offscreenPane(planned: PlannedPane): PlannedPane { + const { placement } = planned.pane; + const offset = + placement === "left" || placement === "right" + ? { x: placement === "left" ? -planned.bounds.width : planned.bounds.width, y: 0 } + : { x: 0, y: placement === "top" ? -planned.bounds.height : planned.bounds.height }; + const translate = (bounds: PaneBounds): PaneBounds => ({ + ...bounds, + x: bounds.x + offset.x, + y: bounds.y + offset.y, + }); return { ...planned, - bounds: collapse(planned.bounds), - ...(planned.divider ? { divider: collapse(planned.divider) } : {}), + bounds: translate(planned.bounds), + ...(planned.divider ? { divider: translate(planned.divider) } : {}), }; } @@ -68,6 +64,32 @@ export function paneVisibilityTransitionKey( : null; } +/** Return whether two presentation plans occupy the same terminal cells. */ +export function paneLayoutGeometryEqual( + left: ExtensionPaneLayoutPlan, + right: ExtensionPaneLayoutPlan, +): boolean { + const boundsEqual = (a: PaneBounds, b: PaneBounds) => + a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + if ( + !boundsEqual(left.reviewBounds, right.reviewBounds) || + left.panes.length !== right.panes.length + ) { + return false; + } + return left.panes.every((planned, index) => { + const other = right.panes[index]; + return ( + other !== undefined && + planned.pane.key === other.pane.key && + boundsEqual(planned.bounds, other.bounds) && + (planned.divider === undefined + ? other.divider === undefined + : other.divider !== undefined && boundsEqual(planned.divider, other.divider)) + ); + }); +} + /** Project one pane animation frame without changing the authoritative semantic plan. */ export function interpolatePaneLayout( from: ExtensionPaneLayoutPlan, @@ -87,9 +109,9 @@ export function interpolatePaneLayout( if (!fromPane && !toPane) return []; const start = - fromPane ?? (toPane && key === transitioningPaneKey ? collapsedPane(toPane) : toPane); + fromPane ?? (toPane && key === transitioningPaneKey ? offscreenPane(toPane) : toPane); const end = - toPane ?? (fromPane && key === transitioningPaneKey ? collapsedPane(fromPane) : fromPane); + toPane ?? (fromPane && key === transitioningPaneKey ? offscreenPane(fromPane) : fromPane); if (!start || !end) return []; const divider = From 86aef54547725f32d726366429a28128efa8de63 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 30 Aug 2026 22:18:39 -0400 Subject: [PATCH 4/4] fix(ui): harden pane animation transitions --- src/ui/App.tsx | 4 +- src/ui/hooks/usePaneSlideAnimation.ts | 17 ++++++-- src/ui/lib/paneSlide.test.ts | 58 ++++++++++++++++++++++----- src/ui/lib/paneSlide.ts | 43 +++++++++++++------- 4 files changed, 93 insertions(+), 29 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 02fc75e1a..70d9d4572 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -473,7 +473,7 @@ export function App({ responsiveShowsSidebar: responsiveLayout.showSidebar, }); - const presentedPaneLayout = usePaneSlideAnimation({ + const { animating: paneLayoutAnimating, layout: presentedPaneLayout } = usePaneSlideAnimation({ bodyHeight, bodyWidth, paneLayout, @@ -1236,7 +1236,7 @@ export function App({ }; const renderDivider = (planned: PlannedPane) => - planned.divider ? ( + planned.divider && !paneLayoutAnimating ? ( planExtensionPanes({ panes: [pane], @@ -95,6 +105,36 @@ describe("pane slide presentation", () => { } }); + test("starts an additional same-edge pane beyond the occupied outer edge", () => { + const first = createTestPane("left", "first"); + const second = createTestPane("left", "second"); + const plan = (openKeys: readonly string[]) => + planExtensionPanes({ + panes: [first, second], + openKeys, + sizes: { [first.key]: 20, [second.key]: 20 }, + bodyWidth: 100, + bodyHeight: 30, + minReviewWidth: 20, + minReviewHeight: 5, + }); + const before = plan([first.key]); + const after = plan([first.key, second.key]); + const start = interpolatePaneLayout(before, after, second.key, 0); + const firstBounds = start.panes.find(({ pane }) => pane.key === first.key)!.bounds; + const secondBounds = start.panes.find(({ pane }) => pane.key === second.key)!.bounds; + + expect(secondBounds.x + secondBounds.width).toBeLessThanOrEqual(firstBounds.x); + }); + + test("keeps interpolated review edges inside the body", () => { + const { closed, open, paneKey } = createPaneLayouts("left"); + for (const progress of [0.1, 0.25, 0.5, 0.75, 0.9]) { + const { reviewBounds } = interpolatePaneLayout(closed, open, paneKey, progress); + expect(reviewBounds.x + reviewBounds.width).toBe(100); + } + }); + test("deduplicates timeline updates that round to the same terminal cells", () => { const { closed, open, paneKey } = createPaneLayouts("top"); const first = interpolatePaneLayout(closed, open, paneKey, 0.1); diff --git a/src/ui/lib/paneSlide.ts b/src/ui/lib/paneSlide.ts index 57f660c4e..d73b015b0 100644 --- a/src/ui/lib/paneSlide.ts +++ b/src/ui/lib/paneSlide.ts @@ -8,24 +8,36 @@ export function paneSlideAnimationDuration(): number { return process.env.NODE_ENV === "test" ? 0 : PANE_SLIDE_DURATION_MS; } -/** Interpolate terminal geometry while snapping each value to a whole cell. */ +/** Interpolate terminal edges and derive dimensions so rounded bounds stay internally exact. */ function interpolateBounds(from: PaneBounds, to: PaneBounds, progress: number): PaneBounds { const value = (start: number, end: number) => Math.round(start + (end - start) * progress); - return { - x: value(from.x, to.x), - y: value(from.y, to.y), - width: value(from.width, to.width), - height: value(from.height, to.height), - }; + const x = value(from.x, to.x); + const y = value(from.y, to.y); + const right = value(from.x + from.width, to.x + to.width); + const bottom = value(from.y + from.height, to.y + to.height); + return { x, y, width: right - x, height: bottom - y }; } -/** Move a full-size pane just beyond the edge it enters from. */ -function offscreenPane(planned: PlannedPane): PlannedPane { +/** Move a full-size pane beyond the outer body edge it enters from. */ +function offscreenPane(planned: PlannedPane, layout: ExtensionPaneLayoutPlan): PlannedPane { + const bodyRight = Math.max( + layout.reviewBounds.x + layout.reviewBounds.width, + ...layout.panes.map(({ bounds }) => bounds.x + bounds.width), + ); + const bodyBottom = Math.max( + layout.reviewBounds.y + layout.reviewBounds.height, + ...layout.panes.map(({ bounds }) => bounds.y + bounds.height), + ); const { placement } = planned.pane; - const offset = - placement === "left" || placement === "right" - ? { x: placement === "left" ? -planned.bounds.width : planned.bounds.width, y: 0 } - : { x: 0, y: placement === "top" ? -planned.bounds.height : planned.bounds.height }; + const target = + placement === "left" + ? { x: -planned.bounds.width, y: planned.bounds.y } + : placement === "right" + ? { x: bodyRight, y: planned.bounds.y } + : placement === "top" + ? { x: planned.bounds.x, y: -planned.bounds.height } + : { x: planned.bounds.x, y: bodyBottom }; + const offset = { x: target.x - planned.bounds.x, y: target.y - planned.bounds.y }; const translate = (bounds: PaneBounds): PaneBounds => ({ ...bounds, x: bounds.x + offset.x, @@ -109,9 +121,10 @@ export function interpolatePaneLayout( if (!fromPane && !toPane) return []; const start = - fromPane ?? (toPane && key === transitioningPaneKey ? offscreenPane(toPane) : toPane); + fromPane ?? (toPane && key === transitioningPaneKey ? offscreenPane(toPane, to) : toPane); const end = - toPane ?? (fromPane && key === transitioningPaneKey ? offscreenPane(fromPane) : fromPane); + toPane ?? + (fromPane && key === transitioningPaneKey ? offscreenPane(fromPane, from) : fromPane); if (!start || !end) return []; const divider =