From 1e954a6a5ecf85e2c68166e8cdb2a6b43c0f41fb Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 24 Aug 2026 16:59:23 +0530 Subject: [PATCH 01/11] feat(web): rebuild panel animations on a deferred close commit The previous presence engine kept a panel mounted through its exit while the store flipped closed instantly, so every consumer needed open-or-mounted workarounds; each fix seeded the next bug (stuck phases, frozen-content flicker, titlebar drift). This replaces it with one rule: the store stays open for the whole collapse, and commits when it lands. - PanelCollapse defers onClose until the CSS transition ends (timer fallback), captures the callback at request time, and animates both directions with plain transitions; disabled means zero divergence from the unwrapped DOM. - ChatView right panel and terminal drawer plus the pull requests route route their toggles through it; no mounted-vs-open checks remain. --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 179 ++++++++++----- .../components/PanelCollapse.logic.test.ts | 103 +++++++++ apps/web/src/components/PanelCollapse.tsx | 210 ++++++++++++++++++ .../src/components/WorkspacePageHeader.tsx | 6 +- .../components/preview/PreviewPanelShell.tsx | 8 +- .../components/settings/SettingsPanels.tsx | 35 +++ .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/components/ui/sidebar.tsx | 14 +- apps/web/src/hooks/useSettings.ts | 14 ++ apps/web/src/routes/_chat.pull-requests.tsx | 153 +++++++------ packages/contracts/src/settings.ts | 4 + 12 files changed, 605 insertions(+), 127 deletions(-) create mode 100644 apps/web/src/components/PanelCollapse.logic.test.ts create mode 100644 apps/web/src/components/PanelCollapse.tsx 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..486a1e9624c8 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,16 @@ 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, + onClose: () => setTerminalOpen(false), + }); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; @@ -2956,13 +2979,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 +3482,20 @@ 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, + 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 +3630,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 +6659,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..449dd522af0f --- /dev/null +++ b/apps/web/src/components/PanelCollapse.logic.test.ts @@ -0,0 +1,103 @@ +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, + 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; 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("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..50c8fdad3d5a --- /dev/null +++ b/apps/web/src/components/PanelCollapse.tsx @@ -0,0 +1,210 @@ +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; + 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); + 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 latest = { open, enabled, dimension, onClose: input.onClose }; + const latestRef = React.useRef(latest); + latestRef.current = latest; + + const clearWrapperStyles = React.useCallback(() => { + const node = nodeRef.current; + if (!node) return; + node.style.transition = ""; + node.style.width = ""; + node.style.height = ""; + node.style.flex = ""; + }, []); + + const settle = React.useCallback(() => { + const current = flightRef.current; + if (!current) return; + if (endTimerRef.current != null) { + window.clearTimeout(endTimerRef.current); + endTimerRef.current = null; + } + flightRef.current = null; + clearWrapperStyles(); + setFlight(null); + if (current.direction === "out") { + pendingOnCloseRef.current?.(); + } + pendingOnCloseRef.current = null; + }, [clearWrapperStyles]); + + // 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 = nodeRef.current; + if (!node) { + // The wrapper never mounted or already detached (caller bailed on + // missing data); settle synchronously instead of sticking in flight. + settle(); + 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( + () => settle(), + PANEL_COLLAPSE_DURATION_MS + PANEL_COLLAPSE_TIMER_SLACK_MS, + ); + }); + const onTransitionEnd = (event: TransitionEvent) => { + if (event.target !== node || event.propertyName !== dimension) return; + settle(); + }; + node.addEventListener("transitionend", onTransitionEnd); + return () => { + window.cancelAnimationFrame(raf); + node.removeEventListener("transitionend", onTransitionEnd); + }; + }, [flight]); + + // 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 = 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; + // A pending exit commits right away so the old close lands against its + // own identity instead of leaking into the new one. + if (switchedIdentity && 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 }; + flightRef.current = nextFlight; + setFlight(nextFlight); + }, [open, input.identity]); + + 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; + const nextFlight: PanelCollapseFlight = { direction: "out", size }; + flightRef.current = nextFlight; + setFlight(nextFlight); + }, []); + + const ref = React.useCallback( + (node: HTMLElement | null) => { + nodeRef.current = node; + if (node == null && flightRef.current) { + // Detached mid-flight (caller bailed); commit or drop without styles. + settle(); + } + }, + [settle], + ); + + return { 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 and clips + * overflow so content holds still instead of squashing. + */ +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 */}
); } From ce354a5f96e6288d1b0efa15948985f4acbd5fa0 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 24 Aug 2026 19:36:01 +0530 Subject: [PATCH 03/11] fix(web): keep panel content mounted across collapse flights The pinned sizer introduced for maximized closes was conditional on the flight, which flips element types at that tree slot and remounts the whole panel subtree on every animated open/close (Ghostty surfaces were disposed and recreated mid-toggle). The box is now always mounted with display:contents while idle, so child fiber identity never changes and idle layout stays identical to the bare panel. --- apps/web/src/components/PanelCollapse.tsx | 41 +++++++++++++---------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/PanelCollapse.tsx b/apps/web/src/components/PanelCollapse.tsx index 740eed024f3f..b7a162b0a4c8 100644 --- a/apps/web/src/components/PanelCollapse.tsx +++ b/apps/web/src/components/PanelCollapse.tsx @@ -205,11 +205,16 @@ 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 and clips + * 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 pinned box carries `data-panel-collapse` too so - * PreviewPanelShell's clamp walk still reaches past both wrappers. + * 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; @@ -226,21 +231,21 @@ export function PanelCollapseFrame(props: { style={flight ? { flex: "0 0 auto" } : undefined} > - {flight ? ( -
- {props.children} -
- ) : ( - props.children - )} +
+ {props.children} +
); From 336b3052d6226f52432dcea1a55484367ce12e29 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 24 Aug 2026 19:49:51 +0530 Subject: [PATCH 04/11] fix(web): pin collapse sizer cross axis during width flights The sizer box pinned only the animated axis, so at the flex-none width call sites its height stayed auto and h-full content shrank to intrinsic height for the duration of a close/open. Pin the cross axis to 100% so the panel is clipped at full size, matching the height path. --- apps/web/src/components/PanelCollapse.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/PanelCollapse.tsx b/apps/web/src/components/PanelCollapse.tsx index b7a162b0a4c8..7354406aa126 100644 --- a/apps/web/src/components/PanelCollapse.tsx +++ b/apps/web/src/components/PanelCollapse.tsx @@ -237,6 +237,10 @@ export function PanelCollapseFrame(props: { flight ? { [props.dimension]: `${flight.size}px`, + // Pin the cross axis too: the wrapper is a plain block at + // both width call sites, so an auto-height box would let + // h-full content shrink to intrinsic height mid-flight. + ...(props.dimension === "width" ? { height: "100%" } : { width: "100%" }), flex: "0 0 auto", minWidth: 0, minHeight: 0, From 0bd713cb9c477583baab4c85c49e6ee8b65c5a69 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 24 Aug 2026 20:16:10 +0530 Subject: [PATCH 05/11] fix(web): drop deferred panel closes superseded mid-flight A pending exit always ran its captured onClose, so reopening intent that landed during the 200ms collapse got wiped: picking another PR row cleared the fresh selection, and a surface opened in chat was closed again by the stale commit. requestClose now snapshots a supersedeKey and skips the commit when it changed by settle time. Call sites key on surface/session identity. Direct store closes during an exit still commit immediately since their onClose is idempotent. --- apps/web/src/components/ChatView.tsx | 6 ++++ .../components/PanelCollapse.logic.test.ts | 30 ++++++++++++++++- apps/web/src/components/PanelCollapse.tsx | 32 +++++++++++++++++-- apps/web/src/routes/_chat.pull-requests.tsx | 3 ++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 486a1e9624c8..2138d65d490a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2949,6 +2949,8 @@ function ChatViewContent(props: ChatViewProps) { enabled: panelAnimationsEnabled, dimension: "height", identity: routeThreadKey, + // Opening/closing sessions mid-collapse supersedes a pending hide. + supersedeKey: terminalUiState.terminalIds.join("|"), onClose: () => setTerminalOpen(false), }); const toggleTerminalVisibility = useCallback(() => { @@ -3489,6 +3491,10 @@ function ChatViewContent(props: ChatViewProps) { 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 diff --git a/apps/web/src/components/PanelCollapse.logic.test.ts b/apps/web/src/components/PanelCollapse.logic.test.ts index 224f0d6bf46f..9aea924c3609 100644 --- a/apps/web/src/components/PanelCollapse.logic.test.ts +++ b/apps/web/src/components/PanelCollapse.logic.test.ts @@ -28,7 +28,12 @@ function makeNode(size: number): HTMLElement { } as unknown as HTMLElement; } -function renderCollapse(input: { open: boolean; enabled?: boolean; onClose: () => void }) { +function renderCollapse(input: { + open: boolean; + enabled?: boolean; + supersedeKey?: string; + onClose: () => void; +}) { reactHookHarness.beginRender(); return usePanelCollapse({ enabled: input.enabled ?? true, @@ -89,6 +94,29 @@ describe("usePanelCollapse", () => { expect(panel.flight).toBeNull(); }); + it("drops a deferred close when the supersede key changed mid-flight", () => { + const onClose = vi.fn(); + let panel = renderCollapse({ open: true, onClose, supersedeKey: "a" }); + panel.ref(makeNode(420)); + panel.requestClose(); + // Something else interacted with the panel while the collapse ran. + panel = renderCollapse({ open: true, onClose, supersedeKey: "b" }); + panel.settle(); + expect(onClose).not.toHaveBeenCalled(); + 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(); diff --git a/apps/web/src/components/PanelCollapse.tsx b/apps/web/src/components/PanelCollapse.tsx index 7354406aa126..2e373bfb1aad 100644 --- a/apps/web/src/components/PanelCollapse.tsx +++ b/apps/web/src/components/PanelCollapse.tsx @@ -37,6 +37,12 @@ export function usePanelCollapse(input: { * 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; @@ -54,8 +60,15 @@ export function usePanelCollapse(input: { // 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, onClose: input.onClose }; + const latest = { + open, + enabled, + dimension, + supersedeKey: input.supersedeKey, + onClose: input.onClose, + }; const latestRef = React.useRef(latest); latestRef.current = latest; @@ -79,9 +92,17 @@ export function usePanelCollapse(input: { clearWrapperStyles(); setFlight(null); if (current.direction === "out") { - pendingOnCloseRef.current?.(); + // A changed supersede key means newer panel intent landed while the + // collapse ran; drop the stale close instead of wiping it. + const superseded = + pendingSupersedeRef.current !== undefined && + pendingSupersedeRef.current !== latestRef.current.supersedeKey; + if (!superseded) { + pendingOnCloseRef.current?.(); + } } pendingOnCloseRef.current = null; + pendingSupersedeRef.current = undefined; }, [clearWrapperStyles]); // Runs one animation frame after a flight's styles are applied so the @@ -146,6 +167,12 @@ export function usePanelCollapse(input: { 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; @@ -177,6 +204,7 @@ export function usePanelCollapse(input: { return; } pendingOnCloseRef.current = current.onClose; + pendingSupersedeRef.current = current.supersedeKey; const nextFlight: PanelCollapseFlight = { direction: "out", size }; flightRef.current = nextFlight; setFlight(nextFlight); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 1abbcbd2db16..e3fa974cd37f 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1222,6 +1222,9 @@ function PullRequestsRouteView() { 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("|"), onClose: () => { if (rightPanelRef === null) return; useRightPanelStore.getState().close(rightPanelRef); From 37a7573463754f01c45f55db58d0822412c2ce0d Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 24 Aug 2026 20:35:33 +0530 Subject: [PATCH 06/11] fix(web): commit identity-switch closes and cover tab reactivation The supersede check ran on every settle, so a mid-collapse thread switch compared the old key against the new thread key and almost always dropped the commit it existed to force, stranding the previous panel as open. Explicit finishes (toggle settle, identity switch, bypassing store close) now always commit; only natural completion honors supersession. Also keys supersession on the active surface/session id: activating a PR row that already has a tab leaves the surfaces array unchanged (rightPanelStore upsert), and focusing an existing terminal session did the same for drawers. --- apps/web/src/components/ChatView.tsx | 5 +- .../components/PanelCollapse.logic.test.ts | 5 +- apps/web/src/components/PanelCollapse.tsx | 66 +++++++++++-------- apps/web/src/routes/_chat.pull-requests.tsx | 4 +- 4 files changed, 46 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2138d65d490a..a30e9ba12c25 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2949,8 +2949,9 @@ function ChatViewContent(props: ChatViewProps) { enabled: panelAnimationsEnabled, dimension: "height", identity: routeThreadKey, - // Opening/closing sessions mid-collapse supersedes a pending hide. - supersedeKey: terminalUiState.terminalIds.join("|"), + // Opening, closing, or focusing sessions mid-collapse supersedes a + // pending hide. + supersedeKey: `${terminalUiState.terminalIds.join("|")}:${terminalUiState.activeTerminalId ?? ""}`, onClose: () => setTerminalOpen(false), }); const toggleTerminalVisibility = useCallback(() => { diff --git a/apps/web/src/components/PanelCollapse.logic.test.ts b/apps/web/src/components/PanelCollapse.logic.test.ts index 9aea924c3609..b47364a0227e 100644 --- a/apps/web/src/components/PanelCollapse.logic.test.ts +++ b/apps/web/src/components/PanelCollapse.logic.test.ts @@ -94,15 +94,14 @@ describe("usePanelCollapse", () => { expect(panel.flight).toBeNull(); }); - it("drops a deferred close when the supersede key changed mid-flight", () => { + 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(); - // Something else interacted with the panel while the collapse ran. panel = renderCollapse({ open: true, onClose, supersedeKey: "b" }); panel.settle(); - expect(onClose).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); panel = renderCollapse({ open: true, onClose, supersedeKey: "b" }); expect(panel.flight).toBeNull(); }); diff --git a/apps/web/src/components/PanelCollapse.tsx b/apps/web/src/components/PanelCollapse.tsx index 2e373bfb1aad..f71cc2078ad2 100644 --- a/apps/web/src/components/PanelCollapse.tsx +++ b/apps/web/src/components/PanelCollapse.tsx @@ -81,29 +81,38 @@ export function usePanelCollapse(input: { node.style.flex = ""; }, []); - const settle = React.useCallback(() => { - const current = flightRef.current; - if (!current) return; - if (endTimerRef.current != null) { - window.clearTimeout(endTimerRef.current); - endTimerRef.current = null; - } - flightRef.current = null; - clearWrapperStyles(); - setFlight(null); - if (current.direction === "out") { - // A changed supersede key means newer panel intent landed while the - // collapse ran; drop the stale close instead of wiping it. + /** + * 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(); + setFlight(null); const superseded = pendingSupersedeRef.current !== undefined && pendingSupersedeRef.current !== latestRef.current.supersedeKey; - if (!superseded) { + if (current.direction === "out" && (options.commit || !superseded)) { pendingOnCloseRef.current?.(); } - } - pendingOnCloseRef.current = null; - pendingSupersedeRef.current = undefined; - }, [clearWrapperStyles]); + pendingOnCloseRef.current = null; + pendingSupersedeRef.current = undefined; + }, + [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. @@ -112,21 +121,21 @@ export function usePanelCollapse(input: { const node = nodeRef.current; if (!node) { // The wrapper never mounted or already detached (caller bailed on - // missing data); settle synchronously instead of sticking in flight. - settle(); + // 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( - () => settle(), + () => retireFlight(), PANEL_COLLAPSE_DURATION_MS + PANEL_COLLAPSE_TIMER_SLACK_MS, ); }); const onTransitionEnd = (event: TransitionEvent) => { if (event.target !== node || event.propertyName !== dimension) return; - settle(); + retireFlight(); }; node.addEventListener("transitionend", onTransitionEnd); return () => { @@ -139,7 +148,7 @@ export function usePanelCollapse(input: { endTimerRef.current = null; } }; - }, [flight]); + }, [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. @@ -189,8 +198,8 @@ export function usePanelCollapse(input: { // snaps the panel to its current endpoint instead of finishing animated. React.useLayoutEffect(() => { if (enabled) return; - settle(); - }, [enabled, settle]); + retireFlight(); + }, [enabled, retireFlight]); const requestClose = React.useCallback(() => { const current = latestRef.current; @@ -214,11 +223,12 @@ export function usePanelCollapse(input: { (node: HTMLElement | null) => { nodeRef.current = node; if (node == null && flightRef.current) { - // Detached mid-flight (caller bailed); commit or drop without styles. - settle(); + // Detached mid-flight (caller bailed); retire with supersession + // semantics so a bail cannot wipe newer panel intent. + retireFlight(); } }, - [settle], + [retireFlight], ); // Memoized so consumers can list the state object in dependency arrays diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index e3fa974cd37f..e3ae7b150d37 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1224,7 +1224,9 @@ function PullRequestsRouteView() { 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("|"), + supersedeKey: `${rightPanelState.surfaces.map((surface) => surface.id).join("|")}:${ + activePullRequestSurface?.id ?? "" + }`, onClose: () => { if (rightPanelRef === null) return; useRightPanelStore.getState().close(rightPanelRef); From b0ae0d28023b17476e6c1e6dd908d5e1c8b5b927 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 24 Aug 2026 20:47:55 +0530 Subject: [PATCH 07/11] fix(web): retire collapse flights before dropping the node ref The ref callback nulled nodeRef before retiring, so clearWrapperStyles saw no node and the detaching wrapper kept its inline height:0px; a thread switch inside a drawer collapse then repainted the terminal outside a zero-height box with nothing to recover it. The detach also pre-empted the identity-switch commit via supersede-checked retirement, stranding the old thread terminalOpen. Retire with an explicit commit while the ref still points at the element. --- apps/web/src/components/PanelCollapse.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/PanelCollapse.tsx b/apps/web/src/components/PanelCollapse.tsx index f71cc2078ad2..f438fc3ffb59 100644 --- a/apps/web/src/components/PanelCollapse.tsx +++ b/apps/web/src/components/PanelCollapse.tsx @@ -221,14 +221,16 @@ export function usePanelCollapse(input: { const ref = React.useCallback( (node: HTMLElement | null) => { - nodeRef.current = node; if (node == null && flightRef.current) { - // Detached mid-flight (caller bailed); retire with supersession - // semantics so a bail cannot wipe newer panel intent. - retireFlight(); + // 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; }, - [retireFlight], + [settle], ); // Memoized so consumers can list the state object in dependency arrays From c9958dd1bed4412df0c6aea53ffa5a5a52ba16db Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 24 Aug 2026 20:56:12 +0530 Subject: [PATCH 08/11] fix(web): cancel superseded panel closes immediately A changed supersedeKey was only honored at natural completion, so the collapse kept running over the newer intent (freshly selected surface) and snapped open 200ms later. Cancel the outbound flight as soon as the key diverges. --- apps/web/src/components/PanelCollapse.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/web/src/components/PanelCollapse.tsx b/apps/web/src/components/PanelCollapse.tsx index f438fc3ffb59..00c6c2dd0476 100644 --- a/apps/web/src/components/PanelCollapse.tsx +++ b/apps/web/src/components/PanelCollapse.tsx @@ -201,6 +201,20 @@ export function usePanelCollapse(input: { 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; From 44c1714d86e44569a3e0f0f05f991ce5ecd5f11b Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 24 Aug 2026 21:13:41 +0530 Subject: [PATCH 09/11] fix(web): bind collapse flights to their own wrapper node The shared drawer ref can swap across two different wrappers on a thread switch, and React may attach the new ref before detaching the old one; retiring through nodeRef could then clear the new drawer DOM while the old wrapper kept its inline height. Flights now capture their wrapper at arm time and all style application and cleanup target it, whatever order refs interleave in. --- apps/web/src/components/PanelCollapse.tsx | 25 +++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/PanelCollapse.tsx b/apps/web/src/components/PanelCollapse.tsx index 00c6c2dd0476..94636120e856 100644 --- a/apps/web/src/components/PanelCollapse.tsx +++ b/apps/web/src/components/PanelCollapse.tsx @@ -56,6 +56,11 @@ export function usePanelCollapse(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. @@ -72,8 +77,8 @@ export function usePanelCollapse(input: { const latestRef = React.useRef(latest); latestRef.current = latest; - const clearWrapperStyles = React.useCallback(() => { - const node = nodeRef.current; + const clearWrapperStyles = React.useCallback((target?: HTMLElement | null) => { + const node = target ?? nodeRef.current; if (!node) return; node.style.transition = ""; node.style.width = ""; @@ -95,16 +100,18 @@ export function usePanelCollapse(input: { endTimerRef.current = null; } flightRef.current = null; - clearWrapperStyles(); + clearWrapperStyles(flightNodeRef.current ?? nodeRef.current); + flightNodeRef.current = null; setFlight(null); + const onClose = pendingOnCloseRef.current; const superseded = pendingSupersedeRef.current !== undefined && pendingSupersedeRef.current !== latestRef.current.supersedeKey; - if (current.direction === "out" && (options.commit || !superseded)) { - pendingOnCloseRef.current?.(); - } pendingOnCloseRef.current = null; pendingSupersedeRef.current = undefined; + if (current.direction === "out" && (options.commit || !superseded)) { + onClose?.(); + } }, [clearWrapperStyles], ); @@ -118,7 +125,7 @@ export function usePanelCollapse(input: { // transition actually interpolates between the pinned start and end values. React.useEffect(() => { if (!flight) return; - const node = nodeRef.current; + 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. @@ -154,7 +161,7 @@ export function usePanelCollapse(input: { // flight, then let the effect above flip to the end value next frame. React.useLayoutEffect(() => { if (!flight) return; - const node = nodeRef.current; + const node = flightNodeRef.current ?? nodeRef.current; if (!node) return; node.style.flex = "0 0 auto"; node.style.transition = "none"; @@ -190,6 +197,7 @@ export function usePanelCollapse(input: { 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]); @@ -229,6 +237,7 @@ export function usePanelCollapse(input: { pendingOnCloseRef.current = current.onClose; pendingSupersedeRef.current = current.supersedeKey; const nextFlight: PanelCollapseFlight = { direction: "out", size }; + flightNodeRef.current = node; flightRef.current = nextFlight; setFlight(nextFlight); }, []); From c6279b5413a889c8f3c98b46092d1494501dacc9 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 24 Aug 2026 21:33:25 +0530 Subject: [PATCH 10/11] fix(web): skip deferred PR panel URL write after route unmount Toggling the panel closed and navigating away within the collapse ran the deferred onClose after the route unmounted, and updateSearch bound to the old route path with replace yanked the user back to /pull-requests. The store close still lands; only the URL write is guarded on the route being alive. --- apps/web/src/routes/_chat.pull-requests.tsx | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index e3ae7b150d37..f005a95d4367 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -26,7 +26,15 @@ import { RefreshCwIcon, SearchIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { filterPullRequestsByInvolvement, @@ -1217,6 +1225,18 @@ 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 must not fire after the route unmounted + // mid-collapse: updateSearch is bound to this route's path with + // replace, which would yank the user back here from wherever they went. + const routeAliveRef = useRef(true); + useLayoutEffect(() => { + routeAliveRef.current = true; + return () => { + routeAliveRef.current = false; + }; + }, []); const panelCollapse = usePanelCollapse({ open: rightPanelState.isOpen && activePullRequestSurface !== null && panelEnvironmentId !== null, @@ -1230,6 +1250,7 @@ function PullRequestsRouteView() { onClose: () => { if (rightPanelRef === null) return; useRightPanelStore.getState().close(rightPanelRef); + if (!routeAliveRef.current) return; updateSearch(clearedSelection); }, }); From 65e0d059149d71cb7c3d7a6b843b94ef1b52b7d2 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Mon, 24 Aug 2026 21:49:38 +0530 Subject: [PATCH 11/11] fix(web): gate deferred PR URL write on live router location The mount-flag guard raced the ref detach: unmount cleanup ordering versus mutation-phase detach is not something to bet on. Check the router live location at commit time instead, which encodes the actual intent (rewrite this route URL only while this route is on screen) with no lifecycle phase dependence. --- apps/web/src/routes/_chat.pull-requests.tsx | 30 +++++++-------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index f005a95d4367..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, @@ -26,15 +26,7 @@ import { RefreshCwIcon, SearchIcon, } from "lucide-react"; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { filterPullRequestsByInvolvement, @@ -1227,16 +1219,12 @@ function PullRequestsRouteView() { // 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 must not fire after the route unmounted - // mid-collapse: updateSearch is bound to this route's path with - // replace, which would yank the user back here from wherever they went. - const routeAliveRef = useRef(true); - useLayoutEffect(() => { - routeAliveRef.current = true; - return () => { - routeAliveRef.current = false; - }; - }, []); + // 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, @@ -1250,7 +1238,7 @@ function PullRequestsRouteView() { onClose: () => { if (rightPanelRef === null) return; useRightPanelStore.getState().close(rightPanelRef); - if (!routeAliveRef.current) return; + if (router.state.location.pathname !== Route.fullPath) return; updateSearch(clearedSelection); }, });