diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 11030fcc5fa4..794d1a3ee2a8 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -35,6 +35,7 @@ const clientSettings: ClientSettings = {
fontSizeTerminal: 12,
fontSmoothing: true,
glassOpacity: 80,
+ panelAnimations: false,
planModeEnabled: false,
showSkillsInSlashMenu: false,
providerModelPreferences: {},
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index cb1cf698535a..a30e9ba12c25 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -201,7 +201,9 @@ import {
useClientSettings,
useClientSettingsHydrated,
useEnvironmentSettings,
+ usePanelAnimations,
} from "../hooks/useSettings";
+import { type PanelCollapseFlight, PanelCollapseFrame, usePanelCollapse } from "./PanelCollapse";
import { useNowMinute } from "../hooks/useNowMinute";
import { useNewThreadHandler } from "../hooks/useHandleNewThread";
import { resolveAppModelSelectionForInstance } from "../modelSelection";
@@ -691,6 +693,9 @@ interface PersistentThreadTerminalDrawerProps {
visible: boolean;
launchContext: PersistentTerminalLaunchContext | null;
focusRequestId: number;
+ /** Collapse flight for the active drawer only; background drawers pass no-ops. */
+ collapseRef: (node: HTMLElement | null) => void;
+ collapseFlight: PanelCollapseFlight | null;
splitShortcutLabel: string | undefined;
splitVerticalShortcutLabel: string | undefined;
newShortcutLabel: string | undefined;
@@ -699,12 +704,16 @@ interface PersistentThreadTerminalDrawerProps {
onAddTerminalContext: (selection: TerminalContextSelection) => void;
}
+const noopRef = () => {};
+
const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({
threadRef,
threadId,
visible,
launchContext,
focusRequestId,
+ collapseRef,
+ collapseFlight,
splitShortcutLabel,
splitVerticalShortcutLabel,
newShortcutLabel,
@@ -1015,7 +1024,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
}
return (
-
+
);
});
@@ -2928,6 +2941,19 @@ function ChatViewContent(props: ChatViewProps) {
},
[activeThreadRef, storeSetTerminalOpen],
);
+ // The active drawer's close is deferred until its height collapse lands,
+ // so during the animation every reader of terminalOpen still sees open.
+ const panelAnimationsEnabled = usePanelAnimations();
+ const drawerCollapse = usePanelCollapse({
+ open: Boolean(activeThreadKey && terminalUiState.terminalOpen),
+ enabled: panelAnimationsEnabled,
+ dimension: "height",
+ identity: routeThreadKey,
+ // Opening, closing, or focusing sessions mid-collapse supersedes a
+ // pending hide.
+ supersedeKey: `${terminalUiState.terminalIds.join("|")}:${terminalUiState.activeTerminalId ?? ""}`,
+ onClose: () => setTerminalOpen(false),
+ });
const toggleTerminalVisibility = useCallback(() => {
if (!activeThreadRef) return;
const nextOpen = !terminalUiState.terminalOpen;
@@ -2956,13 +2982,24 @@ function ChatViewContent(props: ChatViewProps) {
});
return;
}
- setTerminalOpen(nextOpen);
+ if (nextOpen) {
+ setTerminalOpen(true);
+ return;
+ }
+ // A second toggle mid-collapse finishes the close instead of queueing a
+ // reopen; otherwise the close animates and the store flips when it lands.
+ if (drawerCollapse.flight?.direction === "out") {
+ drawerCollapse.settle();
+ return;
+ }
+ drawerCollapse.requestClose();
}, [
activeProject,
activeThreadId,
activeThreadRef,
activeThreadWorktreePath,
allocatableActiveTerminalIds,
+ drawerCollapse,
environmentId,
gitCwd,
openTerminal,
@@ -3448,6 +3485,24 @@ function ChatViewContent(props: ChatViewProps) {
useRightPanelStore.getState().close(activeThreadRef);
}
}, [activeThreadRef]);
+ // Same deferred-close contract as the drawer: the store stays open for the
+ // width collapse, so titlebar and layout reads need no animation awareness.
+ const rightPanelCollapse = usePanelCollapse({
+ open: rightPanelOpen,
+ enabled: panelAnimationsEnabled,
+ dimension: "width",
+ identity: routeThreadKey,
+ // Opening or closing surfaces mid-collapse supersedes a pending close.
+ supersedeKey: `${rightPanelState.surfaces.map((surface) => surface.id).join("|")}:${
+ activeRightPanelSurface?.id ?? ""
+ }`,
+ onClose: () => closePreviewPanel(),
+ });
+ // During a close from maximized, the wrapper is pinned to its measured px
+ // width and the chat column takes the row back while the panel collapses;
+ // the panel's own content stays maximized-styled and just gets clipped.
+ const rightPanelMaximizedLayout =
+ rightPanelMaximized && rightPanelCollapse.flight?.direction !== "out";
const addTerminalSurface = useCallback(() => {
if (!activeThreadRef || !activeThreadId || !activeProject) return;
const cwd = gitCwd ?? activeProject.workspaceRoot;
@@ -3582,12 +3637,16 @@ function ChatViewContent(props: ChatViewProps) {
);
const toggleRightPanel = useCallback(() => {
if (!activeThreadRef) return;
+ if (rightPanelCollapse.flight?.direction === "out") {
+ rightPanelCollapse.settle();
+ return;
+ }
if (rightPanelOpen) {
- closePreviewPanel();
+ rightPanelCollapse.requestClose();
return;
}
useRightPanelStore.getState().toggleVisibility(activeThreadRef);
- }, [activeThreadRef, closePreviewPanel, rightPanelOpen]);
+ }, [activeThreadRef, rightPanelCollapse, rightPanelOpen]);
const toggleRightPanelMaximized = useCallback(() => {
if (!canMaximizeRightPanel) return;
setMaximizedRightPanelThreadKey((threadKey) =>
@@ -6607,9 +6666,9 @@ function ChatViewContent(props: ChatViewProps) {
{/* Top bar */}
{/* end horizontal flex container */}
- {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => (
-
- ))}
+ {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => {
+ const isActiveDrawer = mountedThreadKey === activeThreadKey;
+ return (
+
+ );
+ })}
- {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? (
-
- {rightPanelContent}
-
+
+ {rightPanelContent}
+
+
) : null}
{shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? (
diff --git a/apps/web/src/components/PanelCollapse.logic.test.ts b/apps/web/src/components/PanelCollapse.logic.test.ts
new file mode 100644
index 000000000000..b47364a0227e
--- /dev/null
+++ b/apps/web/src/components/PanelCollapse.logic.test.ts
@@ -0,0 +1,131 @@
+import { beforeEach, describe, expect, it } from "@effect/vitest";
+import { vi } from "vite-plus/test";
+
+import { reactHookHarness } from "~/test/reactHookHarness";
+
+import { usePanelCollapse } from "./PanelCollapse";
+
+vi.mock("react", async (importOriginal) => {
+ const actual = (await importOriginal()) as typeof import("react");
+ const { reactHookHarness } = await import("~/test/reactHookHarness");
+ return {
+ ...actual,
+ useCallback: reactHookHarness.useCallback,
+ useMemo: reactHookHarness.useMemo,
+ useRef: reactHookHarness.useRef,
+ useState: reactHookHarness.useState,
+ // Effects never run under the plain-function harness; register nothing.
+ useEffect: () => undefined,
+ useLayoutEffect: () => undefined,
+ };
+});
+
+function makeNode(size: number): HTMLElement {
+ return {
+ getBoundingClientRect: () => ({ width: size, height: size }) as unknown as DOMRect,
+ style: {},
+ offsetWidth: size,
+ } as unknown as HTMLElement;
+}
+
+function renderCollapse(input: {
+ open: boolean;
+ enabled?: boolean;
+ supersedeKey?: string;
+ onClose: () => void;
+}) {
+ reactHookHarness.beginRender();
+ return usePanelCollapse({
+ enabled: input.enabled ?? true,
+ dimension: "width",
+ ...input,
+ });
+}
+
+describe("usePanelCollapse", () => {
+ beforeEach(() => {
+ reactHookHarness.reset();
+ });
+
+ it("defers onClose until the collapse settles", () => {
+ const onClose = vi.fn();
+ let panel = renderCollapse({ open: true, onClose });
+ panel.ref(makeNode(420));
+
+ panel.requestClose();
+ expect(onClose).not.toHaveBeenCalled();
+
+ // Re-render mirrors React reading the armed flight state.
+ panel = renderCollapse({ open: true, onClose });
+ expect(panel.flight?.direction).toBe("out");
+
+ panel.settle();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ panel = renderCollapse({ open: false, onClose });
+ expect(panel.flight).toBeNull();
+ });
+
+ it("settles exactly once when called repeatedly", () => {
+ const onClose = vi.fn();
+ const panel = renderCollapse({ open: true, onClose });
+ panel.ref(makeNode(420));
+ panel.requestClose();
+ panel.settle();
+ panel.settle();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("snaps closed without a flight when animations are disabled", () => {
+ const onClose = vi.fn();
+ let panel = renderCollapse({ open: true, enabled: false, onClose });
+ panel.ref(makeNode(420));
+ panel.requestClose();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ panel = renderCollapse({ open: false, enabled: false, onClose });
+ expect(panel.flight).toBeNull();
+ });
+
+ it("ignores requestClose while already closed", () => {
+ const onClose = vi.fn();
+ const panel = renderCollapse({ open: false, onClose });
+ panel.ref(makeNode(420));
+ panel.requestClose();
+ expect(onClose).not.toHaveBeenCalled();
+ expect(panel.flight).toBeNull();
+ });
+
+ it("explicit settle commits even when the supersede key changed", () => {
+ const onClose = vi.fn();
+ let panel = renderCollapse({ open: true, onClose, supersedeKey: "a" });
+ panel.ref(makeNode(420));
+ panel.requestClose();
+ panel = renderCollapse({ open: true, onClose, supersedeKey: "b" });
+ panel.settle();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ panel = renderCollapse({ open: true, onClose, supersedeKey: "b" });
+ expect(panel.flight).toBeNull();
+ });
+
+ it("commits when the supersede key is unchanged", () => {
+ const onClose = vi.fn();
+ let panel = renderCollapse({ open: true, onClose, supersedeKey: "a" });
+ panel.ref(makeNode(420));
+ panel.requestClose();
+ panel = renderCollapse({ open: true, onClose, supersedeKey: "a" });
+ panel.settle();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps the captured onClose across re-renders mid-flight", () => {
+ const firstClose = vi.fn();
+ const secondClose = vi.fn();
+ let panel = renderCollapse({ open: true, onClose: firstClose });
+ panel.ref(makeNode(420));
+ panel.requestClose();
+ // The owning component re-renders with a new callback before landing.
+ panel = renderCollapse({ open: true, onClose: secondClose });
+ panel.settle();
+ expect(firstClose).toHaveBeenCalledTimes(1);
+ expect(secondClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/components/PanelCollapse.tsx b/apps/web/src/components/PanelCollapse.tsx
new file mode 100644
index 000000000000..94636120e856
--- /dev/null
+++ b/apps/web/src/components/PanelCollapse.tsx
@@ -0,0 +1,319 @@
+import * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+/** Matches the left sidebar's collapse timing (`duration-200 ease-linear`). */
+export const PANEL_COLLAPSE_DURATION_MS = 200;
+/** Slack over the transition duration before the timeout fallback commits. */
+const PANEL_COLLAPSE_TIMER_SLACK_MS = 60;
+
+export type CollapseDimension = "width" | "height";
+
+export interface PanelCollapseFlight {
+ direction: "in" | "out";
+ /** Wrapper size in px when the flight started; the wrapper is pinned to it. */
+ size: number;
+}
+
+/**
+ * Animates a panel collapsing to zero and growing back with a CSS transition
+ * while keeping a single source of truth: the store stays "open" for the
+ * whole exit, so nothing downstream needs a mounted-vs-open workaround.
+ *
+ * - `requestClose()` starts the collapse and defers `onClose` until the
+ * flight lands (transitionend, with a timer as the primary fallback).
+ * The callback is captured at call time, so identity changes mid-flight
+ * still commit against the panel that started closing.
+ * - Opening animates the entrance of a freshly mounted wrapper.
+ * - With `enabled` false every path snaps and the wrapper renders exactly
+ * like the unwrapped panel.
+ */
+export function usePanelCollapse(input: {
+ open: boolean;
+ enabled: boolean;
+ dimension: CollapseDimension;
+ /**
+ * Switching identity mid-flight settles it immediately so an exit cannot
+ * leak into another thread's panel layout.
+ */
+ identity?: string | number;
+ /**
+ * Snapshot at requestClose; if it differs at settle time, something else
+ * interacted with the panel meanwhile (a row picked, a surface opened) and
+ * the deferred close is dropped instead of wiping that newer intent.
+ */
+ supersedeKey?: string;
+ onClose: () => void;
+}): {
+ ref: (node: HTMLElement | null) => void;
+ requestClose: () => void;
+ /** Ends the current flight now: an exit commits, an entrance cancels. */
+ settle: () => void;
+ flight: PanelCollapseFlight | null;
+} {
+ const { open, enabled, dimension } = input;
+
+ const nodeRef = React.useRef(null);
+ const [flight, setFlight] = React.useState(null);
+ const flightRef = React.useRef(null);
+ // Captured at arm time. Retirement must clean up THIS element: the shared
+ // ref can be reattached to another drawer's wrapper before the old
+ // wrapper's detach runs (thread switches), so nodeRef cannot be trusted
+ // to point at the flying node.
+ const flightNodeRef = React.useRef(null);
+ const endTimerRef = React.useRef(null);
+ // Captured at requestClose so a late commit targets the panel that began
+ // closing, even if the surrounding component re-rendered meanwhile.
+ const pendingOnCloseRef = React.useRef<(() => void) | null>(null);
+ const pendingSupersedeRef = React.useRef(undefined);
+
+ const latest = {
+ open,
+ enabled,
+ dimension,
+ supersedeKey: input.supersedeKey,
+ onClose: input.onClose,
+ };
+ const latestRef = React.useRef(latest);
+ latestRef.current = latest;
+
+ const clearWrapperStyles = React.useCallback((target?: HTMLElement | null) => {
+ const node = target ?? nodeRef.current;
+ if (!node) return;
+ node.style.transition = "";
+ node.style.width = "";
+ node.style.height = "";
+ node.style.flex = "";
+ }, []);
+
+ /**
+ * Ends the current flight. Explicit finishes (`commit: true`) always run
+ * the captured close; natural completion (transitionend, fallback timer,
+ * detach) honors supersession so a stale close cannot wipe newer intent.
+ */
+ const endFlight = React.useCallback(
+ (options: { commit: boolean }) => {
+ const current = flightRef.current;
+ if (!current) return;
+ if (endTimerRef.current != null) {
+ window.clearTimeout(endTimerRef.current);
+ endTimerRef.current = null;
+ }
+ flightRef.current = null;
+ clearWrapperStyles(flightNodeRef.current ?? nodeRef.current);
+ flightNodeRef.current = null;
+ setFlight(null);
+ const onClose = pendingOnCloseRef.current;
+ const superseded =
+ pendingSupersedeRef.current !== undefined &&
+ pendingSupersedeRef.current !== latestRef.current.supersedeKey;
+ pendingOnCloseRef.current = null;
+ pendingSupersedeRef.current = undefined;
+ if (current.direction === "out" && (options.commit || !superseded)) {
+ onClose?.();
+ }
+ },
+ [clearWrapperStyles],
+ );
+
+ /** Ends the current flight now: an exit commits, an entrance cancels. */
+ const settle = React.useCallback(() => endFlight({ commit: true }), [endFlight]);
+ /** Natural completion: an exit commits unless superseded mid-flight. */
+ const retireFlight = React.useCallback(() => endFlight({ commit: false }), [endFlight]);
+
+ // Runs one animation frame after a flight's styles are applied so the
+ // transition actually interpolates between the pinned start and end values.
+ React.useEffect(() => {
+ if (!flight) return;
+ const node = flightNodeRef.current ?? nodeRef.current;
+ if (!node) {
+ // The wrapper never mounted or already detached (caller bailed on
+ // missing data); retire synchronously instead of sticking in flight.
+ retireFlight();
+ return;
+ }
+ const raf = window.requestAnimationFrame(() => {
+ node.style.transition = `${dimension} ${PANEL_COLLAPSE_DURATION_MS}ms linear`;
+ node.style[dimension] = flight.direction === "in" ? `${flight.size}px` : "0px";
+ endTimerRef.current = window.setTimeout(
+ () => retireFlight(),
+ PANEL_COLLAPSE_DURATION_MS + PANEL_COLLAPSE_TIMER_SLACK_MS,
+ );
+ });
+ const onTransitionEnd = (event: TransitionEvent) => {
+ if (event.target !== node || event.propertyName !== dimension) return;
+ retireFlight();
+ };
+ node.addEventListener("transitionend", onTransitionEnd);
+ return () => {
+ window.cancelAnimationFrame(raf);
+ node.removeEventListener("transitionend", onTransitionEnd);
+ // Retargeting a flight must retire its fallback timer too, or the old
+ // timer settles the replacement flight early.
+ if (endTimerRef.current != null) {
+ window.clearTimeout(endTimerRef.current);
+ endTimerRef.current = null;
+ }
+ };
+ }, [flight, retireFlight]);
+
+ // Apply the pinned start value in the same pre-paint pass that mounts the
+ // flight, then let the effect above flip to the end value next frame.
+ React.useLayoutEffect(() => {
+ if (!flight) return;
+ const node = flightNodeRef.current ?? nodeRef.current;
+ if (!node) return;
+ node.style.flex = "0 0 auto";
+ node.style.transition = "none";
+ node.style[dimension] = flight.direction === "in" ? "0px" : `${flight.size}px`;
+ void node.offsetWidth;
+ }, [flight, dimension]);
+
+ // Entrance: `open` flipping true outside the initial mount arms a grow
+ // flight measured off the freshly mounted wrapper. Identity switches snap.
+ const firstRenderRef = React.useRef(true);
+ const prevIdentityRef = React.useRef(input.identity);
+ React.useLayoutEffect(() => {
+ const wasFirstRender = firstRenderRef.current;
+ firstRenderRef.current = false;
+ const switchedIdentity = prevIdentityRef.current !== input.identity;
+ prevIdentityRef.current = input.identity;
+ // An identity switch retires any flight: an exit commits against its own
+ // identity, an entrance snaps so it cannot grow the new identity's panel.
+ if (switchedIdentity) {
+ settle();
+ }
+ // An exit bypassed by a direct store close (tab/session close paths that
+ // never went through requestClose) commits immediately; its onClose is
+ // idempotent against the already-closed store.
+ if (!open && flightRef.current?.direction === "out") {
+ settle();
+ }
+ if (wasFirstRender || switchedIdentity) return;
+ if (!open || flightRef.current || !latestRef.current.enabled) return;
+ const node = nodeRef.current;
+ if (!node) return;
+ const rect = node.getBoundingClientRect();
+ const natural = Math.ceil(dimension === "width" ? rect.width : rect.height);
+ if (natural <= 0) return;
+ const nextFlight: PanelCollapseFlight = { direction: "in", size: natural };
+ flightNodeRef.current = node;
+ flightRef.current = nextFlight;
+ setFlight(nextFlight);
+ }, [open, input.identity]);
+
+ // Disabling animations mid-flight (setting toggle, OS reduced motion)
+ // snaps the panel to its current endpoint instead of finishing animated.
+ React.useLayoutEffect(() => {
+ if (enabled) return;
+ retireFlight();
+ }, [enabled, retireFlight]);
+
+ // A newer interaction supersedes an active close; cancel it immediately
+ // instead of letting the collapse run out over the newer intent and snap
+ // back at completion.
+ React.useLayoutEffect(() => {
+ if (flightRef.current?.direction !== "out") return;
+ if (
+ pendingSupersedeRef.current === undefined ||
+ pendingSupersedeRef.current === latestRef.current.supersedeKey
+ ) {
+ return;
+ }
+ endFlight({ commit: false });
+ }, [input.supersedeKey, endFlight]);
+
+ const requestClose = React.useCallback(() => {
+ const current = latestRef.current;
+ if (!current.open || flightRef.current?.direction === "out") return;
+ // An entrance still running retargets into an exit from wherever it is.
+ const node = nodeRef.current;
+ const rect = node?.getBoundingClientRect();
+ const size = Math.ceil(rect ? (current.dimension === "width" ? rect.width : rect.height) : 0);
+ if (!node || !current.enabled || size <= 1) {
+ current.onClose();
+ return;
+ }
+ pendingOnCloseRef.current = current.onClose;
+ pendingSupersedeRef.current = current.supersedeKey;
+ const nextFlight: PanelCollapseFlight = { direction: "out", size };
+ flightNodeRef.current = node;
+ flightRef.current = nextFlight;
+ setFlight(nextFlight);
+ }, []);
+
+ const ref = React.useCallback(
+ (node: HTMLElement | null) => {
+ if (node == null && flightRef.current) {
+ // Retire before dropping the reference so clearWrapperStyles can
+ // reset the inline animation styles on the detaching element, and
+ // commit so an active-drawer ref swap cannot pre-empt the
+ // identity-switch close below.
+ settle();
+ }
+ nodeRef.current = node;
+ },
+ [settle],
+ );
+
+ // Memoized so consumers can list the state object in dependency arrays
+ // without resubscribing effects on every render.
+ return React.useMemo(
+ () => ({ ref, requestClose, settle, flight }),
+ [ref, requestClose, settle, flight],
+ );
+}
+
+export type PanelCollapseState = ReturnType;
+
+/**
+ * Wrapper around a collapsible panel. Renders identically to the bare panel
+ * while idle; while a flight runs it pins the animated dimension, clips
+ * overflow, and holds the content box at its measured size so flexible
+ * children (a maximized `flex-1` shell) get clipped instead of reflowing on
+ * every frame.
+ *
+ * The content box stays mounted in every state (`display: contents` while
+ * idle): swapping element types at this slot would remount the whole panel
+ * subtree on each animated open and close, tearing down terminals, previews,
+ * and other local state mid-transition. Both boxes carry
+ * `data-panel-collapse` so PreviewPanelShell's clamp walk reaches past them.
+ */
+export function PanelCollapseFrame(props: {
+ state: Pick;
+ dimension: CollapseDimension;
+ className?: string | undefined;
+ children: React.ReactNode;
+}) {
+ const flight = props.state.flight;
+ return (
+
+
+
+ {props.children}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/WorkspacePageHeader.tsx b/apps/web/src/components/WorkspacePageHeader.tsx
index cd8a96273c00..6b383658137f 100644
--- a/apps/web/src/components/WorkspacePageHeader.tsx
+++ b/apps/web/src/components/WorkspacePageHeader.tsx
@@ -1,5 +1,6 @@
import type { ComponentPropsWithoutRef } from "react";
+import { usePanelAnimations } from "../hooks/useSettings";
import { cn } from "../lib/utils";
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar";
@@ -13,10 +14,13 @@ export function WorkspacePageHeader({
readonly electron?: boolean;
readonly reserveNativeControls?: boolean;
}) {
+ const animate = usePanelAnimations();
return (
, enabled:
}, []);
useLayoutEffect(() => {
if (!enabled) return;
- const parent = hostRef.current?.parentElement;
+ // PanelCollapseFrame wraps the panel while it animates open/closed;
+ // measure through that wrapper so the clamp tracks the flex row the
+ // panel actually shares with its sibling column.
+ let parent = hostRef.current?.parentElement;
+ while (parent?.hasAttribute("data-panel-collapse")) {
+ parent = parent.parentElement;
+ }
if (!parent) return;
// Measure before first paint: the persisted width must be clamped
// against the row on the initial render, not one observer tick later
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index e77c05549265..bfcbfb2363e3 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -481,6 +481,9 @@ export function useSettingsRestore(onRestored?: () => void) {
? ["Contrast"]
: []),
...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
+ ...(settings.panelAnimations !== DEFAULT_UNIFIED_SETTINGS.panelAnimations
+ ? ["Panel animations"]
+ : []),
...(settings.environmentIdentificationMode !==
DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
? ["Environment identification"]
@@ -570,6 +573,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.fontSizePrompt,
settings.fontSizeTerminal,
settings.glassOpacity,
+ settings.panelAnimations,
settings.enableLegacyTokenStreaming,
settings.enableProviderUpdateChecks,
settings.sidebarAutoSettleAfterDays,
@@ -655,6 +659,7 @@ export function useSettingsRestore(onRestored?: () => void) {
showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
+ panelAnimations: DEFAULT_UNIFIED_SETTINGS.panelAnimations,
sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
@@ -1119,6 +1124,8 @@ export function AppearanceSettingsPanel() {
}
/>
+
+
{showEnvironmentIdentification ? (
+ updateSettings({ panelAnimations: DEFAULT_UNIFIED_SETTINGS.panelAnimations })
+ }
+ />
+ ) : null
+ }
+ control={
+ updateSettings({ panelAnimations: Boolean(checked) })}
+ aria-label="Panel animations"
+ />
+ }
+ />
+ );
+}
+
function FontSmoothingRow() {
const settings = usePrimarySettings();
const updateSettings = useUpdatePrimarySettings();
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 5213cb55a503..d9f53f34ca33 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -69,6 +69,11 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Glass opacity",
to: "/settings/appearance",
},
+ {
+ id: "panel-animations",
+ title: "Panel animations",
+ to: "/settings/appearance",
+ },
{
id: "environment-identification",
title: "Environment identification",
diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx
index ce6e4cc78ca9..18eebd64fa65 100644
--- a/apps/web/src/components/ui/sidebar.tsx
+++ b/apps/web/src/components/ui/sidebar.tsx
@@ -19,6 +19,7 @@ import { Skeleton } from "~/components/ui/skeleton";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
+import { usePanelAnimations } from "~/hooks/useSettings";
import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState";
import * as Schema from "effect/Schema";
@@ -194,6 +195,9 @@ function Sidebar({
resizable?: boolean | SidebarResizableOptions;
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+ // Collapse/expand transitions are opt-in (Settings → Appearance); off means
+ // the panel snaps so toggling feels immediate.
+ const animate = usePanelAnimations();
const resolvedResizable = React.useMemo(() => {
if (isMobile || collapsible === "none" || !resizable) {
return null;
@@ -281,7 +285,8 @@ function Sidebar({
{/* This is what handles the sidebar gap on desktop */}
) {
}
function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentProps<"div">) {
+ const animate = usePanelAnimations();
const defaultProps = {
className: cn(
- "flex h-8 shrink-0 items-center rounded-lg px-2 font-medium text-sidebar-foreground text-xs outline-hidden ring-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ "flex h-8 shrink-0 items-center rounded-lg px-2 font-medium text-sidebar-foreground text-xs outline-hidden ring-ring focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ animate && "transition-[margin,opacity] duration-200 ease-linear",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
),
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index 5e633a5ded59..44f3a3d98b45 100644
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -38,6 +38,7 @@ import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
import { usePrimaryEnvironment } from "~/state/environments";
import { useAtomCommand } from "~/state/use-atom-command";
import { useTheme } from "./useTheme";
+import { useMediaQuery } from "./useMediaQuery";
const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
@@ -289,6 +290,19 @@ export function useLegacySidebarEnabled(): boolean {
return settingsHydrated && legacySidebarEnabled;
}
+/**
+ * Whether collapsing/expanding the left sidebar, right panel, and terminal
+ * drawer animates. Opt-in via Settings → Appearance; panels snap until then.
+ * The OS reduce-motion preference wins over the setting. Resolves false
+ * until client settings hydrate so the first paint is stable.
+ */
+export function usePanelAnimations(): boolean {
+ const settingsHydrated = useClientSettingsHydrated();
+ const panelAnimations = useClientSettingsValue().panelAnimations;
+ const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
+ return settingsHydrated && panelAnimations && !prefersReducedMotion;
+}
+
/** Read current settings for one environment, merged with client-local preferences. */
export function useEnvironmentSettings
(
environmentId: EnvironmentId,
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx
index f8894ea57775..d140b9e58005 100644
--- a/apps/web/src/routes/_chat.pull-requests.tsx
+++ b/apps/web/src/routes/_chat.pull-requests.tsx
@@ -11,7 +11,7 @@ import type {
PullRequestListState,
SourceControlProviderKind,
} from "@t3tools/contracts";
-import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import {
ChevronDownIcon,
EyeIcon,
@@ -69,6 +69,8 @@ import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhost
import { PullRequestRow } from "../components/pullRequest/PullRequestRow";
import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState";
import { RightPanelTabs, type PullRequestTabStatus } from "../components/RightPanelTabs";
+import { PanelCollapseFrame, usePanelCollapse } from "../components/PanelCollapse";
+import { usePanelAnimations } from "../hooks/useSettings";
import {
WorkspaceBreadcrumb,
WorkspaceBreadcrumbItem,
@@ -1213,11 +1215,42 @@ function PullRequestsRouteView() {
},
);
+ // The panel close is deferred until its width collapse lands, so during
+ // the animation the store, URL selection, and titlebar all still read open.
+ // The panel close is deferred until its width collapse lands, so during
+ // the animation the store, URL selection, and titlebar all still read open.
+ // The deferred URL write only makes sense while this route is the one on
+ // screen: updateSearch is bound to its path with replace, and running it
+ // after navigating away mid-collapse would yank the user back here. The
+ // router's live location is checked at commit time rather than a mount
+ // flag because ref detach can outrun any unmount cleanup.
+ const router = useRouter();
+ const panelCollapse = usePanelCollapse({
+ open:
+ rightPanelState.isOpen && activePullRequestSurface !== null && panelEnvironmentId !== null,
+ enabled: usePanelAnimations(),
+ dimension: "width",
+ // Picking a different row mid-collapse supersedes the pending close so
+ // its deferred commit cannot clear the newer selection.
+ supersedeKey: `${rightPanelState.surfaces.map((surface) => surface.id).join("|")}:${
+ activePullRequestSurface?.id ?? ""
+ }`,
+ onClose: () => {
+ if (rightPanelRef === null) return;
+ useRightPanelStore.getState().close(rightPanelRef);
+ if (router.state.location.pathname !== Route.fullPath) return;
+ updateSearch(clearedSelection);
+ },
+ });
+
const toggleRightPanel = () => {
if (rightPanelRef === null) return;
+ if (panelCollapse.flight?.direction === "out") {
+ panelCollapse.settle();
+ return;
+ }
if (rightPanelState.isOpen) {
- useRightPanelStore.getState().close(rightPanelRef);
- updateSearch(clearedSelection);
+ panelCollapse.requestClose();
return;
}
if (selectedPullRequestSurface === null) return;
@@ -1550,72 +1583,78 @@ function PullRequestsRouteView() {
return (
- {pullRequestsSupported && rightPanelState.isOpen ? openPanelControls : null}
+ {pullRequestsSupported && (rightPanelState.isOpen || panelCollapse.flight)
+ ? openPanelControls
+ : null}
- {rightPanelState.isOpen && activePullRequestSurface && panelEnvironmentId !== null ? (
-
{
- if (surface.kind === "pull-request") activateSurface(surface);
- }}
- onCloseSurface={(surface) => {
- if (surface.kind === "pull-request") closeSurface(surface);
- }}
- onCloseOtherSurfaces={(surface) => {
- if (surface.kind === "pull-request") closeOtherSurfaces(surface);
- }}
- onCloseSurfacesToRight={(surface) => {
- if (surface.kind === "pull-request") closeSurfacesToRight(surface);
- }}
- onCloseAllSurfaces={closeAllSurfaces}
- onCopyFilePath={() => undefined}
- onAddBrowser={() => undefined}
- onAddTerminal={() => undefined}
- onAddDiff={() => undefined}
- onAddFiles={() => undefined}
- onAddPullRequest={() => undefined}
- onAddAgents={() => undefined}
- browserAvailable={false}
- terminalAvailable={false}
- diffAvailable={false}
- filesAvailable={false}
- pullRequestAvailable={false}
- agentsAvailable={false}
- liveAgentCount={0}
- pullRequestStatuses={pullRequestTabStatuses}
- >
-
+ {
+ if (surface.kind === "pull-request") activateSurface(surface);
}}
- refreshToken={detailRefreshToken}
- // Merging, closing or reopening changes the row this panel was opened from, so
- // the list behind it is out of date the moment the host takes the action.
- onActed={() => {
- refreshList();
- baselineQuery.refresh();
- authoredQuery.refresh();
- reviewingQuery.refresh();
+ onCloseSurface={(surface) => {
+ if (surface.kind === "pull-request") closeSurface(surface);
}}
- onStateChange={handlePullRequestTabStatusChange}
- />
-
+ onCloseOtherSurfaces={(surface) => {
+ if (surface.kind === "pull-request") closeOtherSurfaces(surface);
+ }}
+ onCloseSurfacesToRight={(surface) => {
+ if (surface.kind === "pull-request") closeSurfacesToRight(surface);
+ }}
+ onCloseAllSurfaces={closeAllSurfaces}
+ onCopyFilePath={() => undefined}
+ onAddBrowser={() => undefined}
+ onAddTerminal={() => undefined}
+ onAddDiff={() => undefined}
+ onAddFiles={() => undefined}
+ onAddPullRequest={() => undefined}
+ onAddAgents={() => undefined}
+ browserAvailable={false}
+ terminalAvailable={false}
+ diffAvailable={false}
+ filesAvailable={false}
+ pullRequestAvailable={false}
+ agentsAvailable={false}
+ liveAgentCount={0}
+ pullRequestStatuses={pullRequestTabStatuses}
+ >
+ {
+ refreshList();
+ baselineQuery.refresh();
+ authoredQuery.refresh();
+ reviewingQuery.refresh();
+ }}
+ onStateChange={handlePullRequestTabStatusChange}
+ />
+
+
) : null}
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 80e03b8c879e..4a763814bb07 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -196,6 +196,9 @@ export const ClientSettingsSchema = Schema.Struct({
// Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes);
// disabling restores the platform's heavier default. No effect off macOS.
fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+ // Animate collapsing/expanding the left sidebar, right panel, and terminal
+ // drawer. Off by default: panels snap so toggling feels immediate.
+ panelAnimations: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
// Model favorites. Historically keyed by provider kind, now
// widened to `ProviderInstanceId` so users can favorite a specific model
// on a custom provider instance (e.g. "Codex Personal · gpt-5") without
@@ -892,6 +895,7 @@ export const ClientSettingsPatch = Schema.Struct({
fontFamilySans: Schema.optionalKey(FontFamilyPreference),
fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference),
fontSmoothing: Schema.optionalKey(Schema.Boolean),
+ panelAnimations: Schema.optionalKey(Schema.Boolean),
favorites: Schema.optionalKey(
Schema.Array(
Schema.Struct({