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 */}