From c014e360d7eb8a555c9113a8d7659142f01f6633 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:24:20 -0700 Subject: [PATCH] refactor(web): resize drags, panel shells and the browser host own their listeners The right panel, sidebar rail and Electron browser host kept DOM listeners, leases and drag state alive through effects that re-read "latest" refs, and the panel shell re-rendered two frames after every resize to restore its width transition. - useResizeDrag: one drag object per handle; blur is listened to only during a drag, the handle's ref abandons a drag on unmount, and bounds travel with the session instead of a latest-options ref. - useResizableWidth: a drag commits to the storage key and width it started from; only the mid-drag bounds refresh stays a layout effect. - usePreviewPanelInlineSize / PreviewPanelShell: the shell's ref callback measures the row; the width transition is disabled in the commit that changes width and re-enabled by the next open/close, so no rAF effect or extra render. Focus/inert effects are unchanged. - sidebar: open state lives in a per-provider store, so collapsing moves focus out and cancels a rail drag from the handler; inert is a prop. The rail's width-restore effect is dropped (AppSidebarLayout already restores). - RightPanelTabs: the tab strip's scroll, resize and wheel listeners are owned by its ref callback. - BrowserSurfaceSlot: the surface lease is owned by the slot's ref callback. - ElectronBrowserHost: annotation theme sync, agent pointer and open-link listeners start with the app services. Effects in touched files: 21 -> 7. Kept: tab scrollIntoView, the two keyboard listeners in RightPanelTabs, the panel shell focus/inert pair, the slot's presentation update, and the resizable width bounds refresh. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/appInit.tsx | 2 + apps/web/src/browser/BrowserSurfaceSlot.tsx | 10 +- apps/web/src/browser/ElectronBrowserHost.tsx | 142 ++++++------ apps/web/src/components/RightPanelTabs.tsx | 48 ++-- .../src/components/chat/ChatRightPanel.tsx | 2 +- .../components/preview/PreviewPanelShell.tsx | 67 +++--- .../components/ui/sidebar.browser.test.tsx | 74 +++++++ apps/web/src/components/ui/sidebar.tsx | 205 ++++++++++-------- .../src/hooks/usePreviewPanelInlineSize.ts | 37 ++-- apps/web/src/hooks/useResizableWidth.ts | 47 ++-- apps/web/src/hooks/useResizeDrag.ts | 111 ++++++---- apps/web/src/previewStateStore.ts | 4 + 12 files changed, 435 insertions(+), 314 deletions(-) create mode 100644 apps/web/src/components/ui/sidebar.browser.test.tsx diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 3356d51b98374..bbfbcbdc516e6 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -4,6 +4,7 @@ import { createBrowserHistory, createHashHistory } from "@tanstack/react-router" import { applyAppearanceContrast } from "./appearanceContrast"; import { applyAppearanceFontVariables } from "./appearanceFonts"; +import { startElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention"; import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer"; import { startSshPasswordPromptQueue } from "./components/desktop/SshPasswordPromptDialog"; @@ -141,6 +142,7 @@ export function startAppServices(): AppRouter { startDefaultThemeAdoption(); startModelPreferencesMigration(); startAgentOpenedPreviews(); + if (isElectron) startElectronBrowserHost(); startThreadOverlayRetention(router); startMainAppLocationTracking(router); startBackgroundThreadAnnouncements(appAtomRegistry, router); diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index 9a14ab8350c84..7525f66350ff9 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -21,13 +21,11 @@ export function BrowserSurfaceSlot(props: { className, fitSourceContent = false, } = props; - const elementRef = useRef(null); const presentationRef = useRef({ visible, cornerRadius, zIndex }); const updateRef = useRef<(() => void) | null>(null); - useLayoutEffect(() => { - const element = elementRef.current; - if (!element) return; + // The lease lives as long as this element for one tab, so the ref callback owns it. + const attachSurface = (element: HTMLDivElement) => { let lease = acquireBrowserSurface(tabId, fitSourceContent); const update = () => { const rect = element.getBoundingClientRect(); @@ -77,7 +75,7 @@ export function BrowserSurfaceSlot(props: { if (updateRef.current === update) updateRef.current = null; lease.release(); }; - }, [fitSourceContent, tabId]); + }; useLayoutEffect(() => { presentationRef.current = { visible, cornerRadius, zIndex }; @@ -90,5 +88,5 @@ export function BrowserSurfaceSlot(props: { zIndex, ]); - return
; + return
; } diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 1d35a6e933fcc..d71b7a2b6b2ba 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,21 +1,28 @@ import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { AuthPreviewOperateScope, + type DesktopPreviewBridge, type DesktopPreviewOpenLinkEvent, + type EnvironmentId, FILL_PREVIEW_VIEWPORT, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { type ComponentProps, useEffect, useEffectEvent, useState } from "react"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { type ComponentProps, useState } from "react"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { isElectron } from "~/env"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { useDesktopHostedPreviewSessions } from "~/components/preview/usePreviewSession"; -import { useActivePreviewSessions } from "~/previewStateStore"; +import { + readActivePreviewSessions, + type ThreadPreviewState, + useActivePreviewSessions, +} from "~/previewStateStore"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; -import { useAtomCommand } from "~/state/use-atom-command"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserDefaults } from "./browserDefaults"; @@ -25,10 +32,11 @@ import { openUrlInPreview } from "./openFileInPreview"; import { rendersServerTabNatively } from "./previewRuntime"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; -export function ElectronBrowserHost() { - const previewByThreadKey = useActivePreviewSessions(); - const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); - useDesktopHostedPreviewSessions(primaryEnvironmentId); +/** Every session this window renders as a hosted webview, in a fixed (non-display) order. */ +function hostedBrowserSessions( + previewByThreadKey: Record, + primaryEnvironmentId: EnvironmentId | null, +) { const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); return threadRef @@ -50,68 +58,78 @@ export function ElectronBrowserHost() { }); // Electron destroys a moved webview's guest, so the render order must not follow the server's // updatedAt order. Any fixed order works; this one is not a display order. - const sessions = activeSessions.toSorted((left, right) => + return activeSessions.toSorted((left, right) => left.runtimeTabId < right.runtimeTabId ? -1 : left.runtimeTabId > right.runtimeTabId ? 1 : 0, ); +} - useEffect(() => { - const preview = window.desktopBridge?.preview; - if (!preview) return; - - let lastSerializedTheme = ""; - const syncTheme = () => { - const theme = readPreviewAnnotationTheme(); - const serializedTheme = JSON.stringify(theme); - if (serializedTheme === lastSerializedTheme) return; - lastSerializedTheme = serializedTheme; - void preview.setAnnotationTheme(theme).catch(() => { - lastSerializedTheme = ""; - }); - }; - const frameId = window.requestAnimationFrame(syncTheme); - const observer = new MutationObserver(syncTheme); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class", "style"], - }); - const headObserver = new MutationObserver(syncTheme); - headObserver.observe(document.head, { - childList: true, - subtree: true, - characterData: true, +function startAnnotationThemeSync(preview: DesktopPreviewBridge): () => void { + let lastSerializedTheme = ""; + const syncTheme = () => { + const theme = readPreviewAnnotationTheme(); + const serializedTheme = JSON.stringify(theme); + if (serializedTheme === lastSerializedTheme) return; + lastSerializedTheme = serializedTheme; + void preview.setAnnotationTheme(theme).catch(() => { + lastSerializedTheme = ""; }); - return () => { - window.cancelAnimationFrame(frameId); - observer.disconnect(); - headObserver.disconnect(); - }; - }, []); + }; + // App services start before the first paint, when the theme styles may not be applied yet. + const frameId = window.requestAnimationFrame(syncTheme); + const observer = new MutationObserver(syncTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + const headObserver = new MutationObserver(syncTheme); + headObserver.observe(document.head, { + childList: true, + subtree: true, + characterData: true, + }); + return () => { + window.cancelAnimationFrame(frameId); + observer.disconnect(); + headObserver.disconnect(); + }; +} - useEffect(() => { - const preview = window.desktopBridge?.preview; - if (!preview) return; - return preview.onPointerEvent((event) => { +function openLinkInPreview({ tabId, url, background }: DesktopPreviewOpenLinkEvent): void { + const source = hostedBrowserSessions( + readActivePreviewSessions(), + appAtomRegistry.get(primaryEnvironmentIdAtom), + ).find((session) => session.runtimeTabId === tabId); + if (!source) return; + void openUrlInPreview({ + threadRef: source.threadRef, + url, + openPreview: (input) => runAtomCommand(appAtomRegistry, previewEnvironment.open, input), + profileId: source.snapshot.profileId, + background, + }); +} + +/** App-lifetime listeners for the desktop preview bridge: annotation theme, agent pointer, links. */ +export function startElectronBrowserHost(): () => void { + const preview = window.desktopBridge?.preview; + if (!preview) return () => {}; + const disposers = [ + startAnnotationThemeSync(preview), + preview.onPointerEvent((event) => { useBrowserPointerStore.getState().apply(event); - }); - }, []); + }), + preview.onOpenLink(openLinkInPreview), + ]; + return () => { + for (const dispose of disposers) dispose(); + }; +} - const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: true }); - const onOpenLink = useEffectEvent(({ tabId, url, background }: DesktopPreviewOpenLinkEvent) => { - const source = sessions.find((session) => session.runtimeTabId === tabId); - if (!source) return; - void openUrlInPreview({ - threadRef: source.threadRef, - url, - openPreview, - profileId: source.snapshot.profileId, - background, - }); - }); - useEffect(() => { - const preview = window.desktopBridge?.preview; - if (!preview) return; - return preview.onOpenLink(onOpenLink); - }, []); +export function ElectronBrowserHost() { + const previewByThreadKey = useActivePreviewSessions(); + const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + useDesktopHostedPreviewSessions(primaryEnvironmentId); + const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId); if (!isElectron) return null; return ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 59bc657242806..4cab22ef83871 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -981,10 +981,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); }, [props.open]); - const updateTabScrollState = () => { - const viewport = tabScrollViewport(tabListRef.current); - if (!viewport) return; - + const updateTabScrollState = (viewport: HTMLDivElement) => { const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; const canScrollRight = @@ -1183,34 +1180,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) { setRenamingDevice(null); }); + // A newly active tab, or tabs that start overflowing, can sit outside the scrolled strip. useEffect(() => { if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [props.activeSurfaceId, tabScrollState.hasOverflow]); - useEffect(() => { - const viewport = tabScrollViewport(tabListRef.current); - if (!viewport) return; - - const content = viewport.firstElementChild; - const stopObserving = observeResize( - content ? [viewport, content] : viewport, - updateTabScrollState, - ); - viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); - updateTabScrollState(); - - return () => { - stopObserving(); - viewport.removeEventListener("scroll", updateTabScrollState); - }; - }, [updateTabScrollState]); - - useEffect(() => { - const viewport = tabScrollViewport(tabListRef.current); + const attachTabList = (root: HTMLDivElement | null) => { + tabListRef.current = root; + const viewport = tabScrollViewport(root); if (!viewport) return; + const update = () => updateTabScrollState(viewport); const handleWheel = (event: WheelEvent) => { if (event.ctrlKey) return; let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; @@ -1222,12 +1204,22 @@ export function RightPanelTabs(props: RightPanelTabsProps) { viewport.scrollLeft += delta; if (viewport.scrollLeft === previousScrollLeft) return; event.preventDefault(); - updateTabScrollState(); + update(); }; + const content = viewport.firstElementChild; + const stopObserving = observeResize(content ? [viewport, content] : viewport, update); + viewport.addEventListener("scroll", update, { passive: true }); viewport.addEventListener("wheel", handleWheel, { passive: false }); - return () => viewport.removeEventListener("wheel", handleWheel); - }, [updateTabScrollState]); + update(); + + return () => { + tabListRef.current = null; + stopObserving(); + viewport.removeEventListener("scroll", update); + viewport.removeEventListener("wheel", handleWheel); + }; + }; return ( (null); - const inlineSize = usePreviewPanelInlineSize(hostRef, { + const inlineSize = usePreviewPanelInlineSize({ enabled: props.mode === "inline" && !props.maximized, widthStorageKey: props.widthStorageKey, defaultWidth: props.defaultWidth, }); - return ; + return ; } function PreviewPanelShellFrame( - props: PreviewPanelShellProps & { - inlineSize: PreviewPanelInlineSize; - hostRef?: RefObject; - }, + props: PreviewPanelShellProps & { inlineSize: PreviewPanelInlineSize }, ) { const isInline = props.mode === "inline"; const collapsible = isInline && props.open !== undefined; const open = props.open ?? true; const maximized = props.maximized ?? false; - const localHostRef = useRef(null); - const hostRef = props.hostRef ?? localHostRef; + const hostRef = useRef(null); const returnedFocus = useRef(false); - const { width, handlers } = props.inlineSize; - // Suppress transitions before commit so resize/maximize changes never start width transitions. - const [layoutTransition, setLayoutTransition] = useState(() => ({ + const { width, handlers, observeRow } = props.inlineSize; + // Width animates only for an open or close. Any other width change disables + // the transition in the same commit that changes the width, so the browser + // never starts one; the next open or close re-enables it in its own commit, + // so nothing waits for a paint. + const [widthTransition, setWidthTransition] = useState(() => ({ open, width, maximized, - suppressed: false, + enabled: true, })); if ( - layoutTransition.open !== open || - layoutTransition.width !== width || - layoutTransition.maximized !== maximized + widthTransition.open !== open || + widthTransition.width !== width || + widthTransition.maximized !== maximized ) { - setLayoutTransition({ + setWidthTransition({ open, width, maximized, - suppressed: - collapsible && - layoutTransition.open === open && - (layoutTransition.width !== width || layoutTransition.maximized !== maximized), + enabled: widthTransition.open !== open, }); } - const suppressWidthTransition = layoutTransition.suppressed; - useLayoutEffect(() => { - if (!suppressWidthTransition) return; - let restoreFrame = 0; - const paintFrame = window.requestAnimationFrame(() => { - restoreFrame = window.requestAnimationFrame(() => { - setLayoutTransition((current) => ({ ...current, suppressed: false })); - }); - }); + const suppressWidthTransition = collapsible && !widthTransition.enabled; + const attachHost = (host: HTMLDivElement) => { + hostRef.current = host; + const stopObservingRow = observeRow(host); return () => { - window.cancelAnimationFrame(paintFrame); - window.cancelAnimationFrame(restoreFrame); + hostRef.current = null; + stopObservingRow(); }; - }, [suppressWidthTransition]); + }; useLayoutEffect(() => { const host = hostRef.current; if (!host || !collapsible) return; @@ -126,7 +109,7 @@ function PreviewPanelShellFrame( }, [collapsible, open, hostRef]); return (
setOpen(false)}> + Collapse + + ); +} + +function Harness(props: { readonly onResize?: (width: number) => void }) { + return ( + + + + + + + + ); +} + +describe("Sidebar", () => { + it("hands focus to the outside trigger when it collapses around the focused control", async () => { + await page.viewport(1280, 800); + await using screen = await render(, { router: false /* no routes involved */ }); + + await userEvent.click(screen.getByRole("button", { name: "Collapse" })); + + const sidebar = screen.container.querySelector('[data-slot="sidebar"]')!; + await vi.waitFor(() => expect(sidebar.inert).toBe(true)); + const trigger = screen.container.querySelector('[data-slot="sidebar-trigger"]'); + await vi.waitFor(() => expect(document.activeElement).toBe(trigger)); + }); + + it("abandons a rail drag when the sidebar collapses mid-drag", async () => { + await page.viewport(1280, 800); + const onResize = vi.fn(); + await using screen = await render(, { + router: false /* no routes involved */, + }); + const rail = screen.container.querySelector('[data-sidebar="rail"]')!; + const { left, top } = rail.getBoundingClientRect(); + const pointer = { bubbles: true, cancelable: true, pointerId: 1, button: 0, clientY: top + 10 }; + const dragTo = (clientX: number) => { + rail.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientX: left })); + rail.dispatchEvent(new PointerEvent("pointermove", { ...pointer, clientX })); + }; + // A completed drag commits, so the abandoned one below is a real drag too. + dragTo(left + 40); + rail.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: left + 40 })); + expect(onResize).toHaveBeenCalledTimes(1); + + dragTo(left + 80); + await userEvent.click( + screen.container.querySelector('[data-slot="sidebar-trigger"]')!, + ); + rail.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: left + 80 })); + + expect(onResize).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 226d168715640..f9ab933310541 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -17,8 +17,8 @@ import { } from "~/components/ui/sheet"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useResizeDrag } from "~/hooks/useResizeDrag"; -import { useIsMobile } from "~/hooks/useMediaQuery"; -import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { setLocalStorageItem } from "~/hooks/useLocalStorage"; import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; import * as Schema from "effect/Schema"; @@ -40,6 +40,8 @@ type SidebarContextProps = { setOpenMobile: (open: boolean) => void; isMobile: boolean; toggleSidebar: () => void; + /** Runs before the desktop sidebar collapses. */ + onCollapse: (listener: () => void) => () => void; }; type SidebarResizableOptions = { @@ -94,48 +96,101 @@ function useSidebarVisibility() { return isMobile ? openMobile : open; } +// Same breakpoint as useIsMobile, also read when a shortcut fires. +const SIDEBAR_MOBILE_QUERY = "(max-width: 767px)"; + +function isMobileViewport(): boolean { + return typeof window !== "undefined" && window.matchMedia(SIDEBAR_MOBILE_QUERY).matches; +} + +async function persistSidebarOpen(open: boolean) { + await cookieStore.set({ + expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000, + name: SIDEBAR_COOKIE_NAME, + path: "/", + value: String(open), + }); +} + +// An offcanvas sidebar turns inert once collapsed, which drops focus to the body. +function moveFocusOutOfCollapsingSidebar() { + const sidebar = document.activeElement?.closest( + '[data-slot="sidebar"][data-collapsible-mode="offcanvas"]', + ); + const wrapper = sidebar?.closest('[data-slot="sidebar-wrapper"]'); + if (!sidebar || !wrapper) return; + const trigger = Array.from( + wrapper.querySelectorAll('[data-sidebar="trigger"]'), + ).find((element) => !sidebar.contains(element)); + trigger?.focus({ preventScroll: true }); +} + +interface SidebarOpenState { + readonly open: boolean; + readonly openMobile: boolean; +} + +/** Per-provider open state, kept outside React so shortcuts can toggle it at event time. */ +function createSidebarOpenStore(defaultOpen: boolean) { + let state: SidebarOpenState = { open: defaultOpen, openMobile: false }; + const listeners = new Set<() => void>(); + const collapseListeners = new Set<() => void>(); + const publish = (next: SidebarOpenState) => { + state = next; + for (const listener of listeners) listener(); + }; + const setOpen = (open: boolean) => { + if (!open && state.open) { + for (const listener of collapseListeners) listener(); + moveFocusOutOfCollapsingSidebar(); + } + if (open !== state.open) publish({ ...state, open }); + void persistSidebarOpen(open); + }; + const setOpenMobile = (openMobile: boolean) => { + if (openMobile !== state.openMobile) publish({ ...state, openMobile }); + }; + return { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: () => state, + setOpen, + setOpenMobile, + toggle: () => (isMobileViewport() ? setOpenMobile(!state.openMobile) : setOpen(!state.open)), + onCollapse: (listener: () => void) => { + collapseListeners.add(listener); + return () => { + collapseListeners.delete(listener); + }; + }, + }; +} + function SidebarProvider({ defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, className, style, children, ...props }: React.ComponentProps<"div"> & { defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; }) { - const isMobile = useIsMobile(); - const [openMobile, setOpenMobile] = React.useState(false); + const isMobile = useMediaQuery(SIDEBAR_MOBILE_QUERY); + const [store] = React.useState(() => createSidebarOpenStore(defaultOpen)); + const { open, openMobile } = React.useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); const [mobileSheetMounted, setMobileSheetMounted] = React.useState(false); const setMobileSheetElement = (element: HTMLDivElement | null) => { setMobileSheetMounted(element !== null); }; - const [_open, _setOpen] = React.useState(defaultOpen); - const open = openProp ?? _open; - const setOpen = async (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); - } - - await cookieStore.set({ - expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000, - name: SIDEBAR_COOKIE_NAME, - path: "/", - value: String(openState), - }); - }; - - const toggleSidebar = () => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); - }; - const state = resolveSidebarState({ isMobile, open, openMobile }); const contextValue: SidebarContextProps = { @@ -144,10 +199,11 @@ function SidebarProvider({ openMobile, mobileSheetMounted, setMobileSheetElement, - setOpen, - setOpenMobile, + setOpen: store.setOpen, + setOpenMobile: store.setOpenMobile, state, - toggleSidebar, + toggleSidebar: store.toggle, + onCollapse: store.onCollapse, }; return ( @@ -215,24 +271,8 @@ function Sidebar({ resizable?: boolean | SidebarResizableOptions; }) { const { isMobile, state, openMobile, setOpenMobile, setMobileSheetElement } = useSidebar(); - const desktopSidebarRef = React.useRef(null); const offcanvasCollapsed = !isMobile && collapsible === "offcanvas" && state === "collapsed"; - React.useLayoutEffect(() => { - const sidebar = desktopSidebarRef.current; - if (!sidebar) return; - - // Transfer focus before inert causes the browser to clear the focused descendant. - if (offcanvasCollapsed && sidebar.contains(document.activeElement)) { - const wrapper = sidebar.closest('[data-slot="sidebar-wrapper"]'); - const trigger = Array.from( - wrapper?.querySelectorAll('[data-sidebar="trigger"]') ?? [], - ).find((element) => !sidebar.contains(element)); - trigger?.focus({ preventScroll: true }); - } - sidebar.toggleAttribute("inert", offcanvasCollapsed); - }, [offcanvasCollapsed]); - const resolvedResizable = resolveSidebarResizable({ collapsible, isMobile, resizable }); const instanceContextValue: SidebarInstanceContextProps = { side, resizable: resolvedResizable }; @@ -297,9 +337,10 @@ function Sidebar({ return (
) { - const { open, toggleSidebar } = useSidebar(); + const { open, toggleSidebar, onCollapse } = useSidebar(); const sidebarInstance = React.use(SidebarInstanceContext); - const railRef = React.useRef(null); const suppressClickRef = React.useRef(false); const resolvedResizable = sidebarInstance?.resizable ?? null; - const latestResizable = React.useRef(resolvedResizable); const canResize = resolvedResizable !== null && open; const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar"; const railTitle = canResize ? "Drag to resize sidebar" : "Toggle Sidebar"; const resize = useResizeDrag((event) => { - if (!resolvedResizable || !open) return null; + const options = resolvedResizable; + if (!options || !open) return null; const rail = event.currentTarget; const wrapper = rail.closest("[data-slot='sidebar-wrapper']"); const sidebarRoot = rail.closest("[data-slot='sidebar']"); @@ -406,10 +446,7 @@ function SidebarRail({ if (!wrapper || !sidebarRoot || !sidebarContainer) return null; const side = sidebarInstance?.side ?? "left"; - let width = clampSidebarWidth( - sidebarContainer.getBoundingClientRect().width, - resolvedResizable, - ); + let width = clampSidebarWidth(sidebarContainer.getBoundingClientRect().width, options); // Write width only to gap/container during drag; inherited --sidebar-width restyles the whole app, so // update it at commit. const widthTargets = [ @@ -429,10 +466,8 @@ function SidebarRail({ return { width, edge: side === "left" ? "right" : "left", - resize(value) { - const options = latestResizable.current; - if (!options) return width; - const nextWidth = clampSidebarWidth(value, options); + bounds: { min: options.minWidth, max: options.maxWidth }, + resize(nextWidth) { const accepted = options.shouldAcceptWidth?.({ currentWidth: width, @@ -451,15 +486,14 @@ function SidebarRail({ finish(finalWidth, moved) { wrapper.style.setProperty("--sidebar-width", `${finalWidth}px`); suppressClickRef.current = moved; - const options = latestResizable.current; - if (options?.storageKey) { + if (options.storageKey) { try { setLocalStorageItem(options.storageKey, finalWidth, Schema.Finite); } catch (error) { console.error("Could not persist sidebar width.", error); } } - options?.onResize?.(finalWidth); + options.onResize?.(finalWidth); }, cleanup() { widthTargets.forEach((element) => { @@ -468,12 +502,21 @@ function SidebarRail({ }); }, }; - }, String(open)); - const refreshResize = React.useEffectEvent(() => resize.refresh()); - React.useLayoutEffect(() => { - latestResizable.current = resolvedResizable; - refreshResize(); - }, [resolvedResizable]); + }); + // Collapsing mid-drag abandons the drag instead of committing a width to a hidden sidebar. + // Created once: a new ref callback would run the old one's cleanup, which ends a live drag. + const [railRef] = React.useState(() => { + const { ref: dragRef, cancel } = resize; + return (node: HTMLButtonElement | null) => { + const releaseDrag = dragRef(node); + if (!releaseDrag) return undefined; + const stopCancelOnCollapse = onCollapse(cancel); + return () => { + stopCancelOnCollapse(); + releaseDrag(); + }; + }; + }); const handleClick = (event: React.MouseEvent) => { onClick?.(event); @@ -490,26 +533,6 @@ function SidebarRail({ toggleSidebar(); }; - React.useLayoutEffect(() => { - if (!resolvedResizable?.storageKey || typeof window === "undefined") return; - const rail = railRef.current; - if (!rail) return; - const wrapper = rail.closest("[data-slot='sidebar-wrapper']"); - if (!wrapper) return; - - let storedWidth: number | null; - try { - storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); - } catch (error) { - console.error("Could not restore persisted sidebar width.", error); - return; - } - if (storedWidth === null) return; - const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); - wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); - resolvedResizable.onResize?.(clampedWidth); - }, [resolvedResizable]); - return ( () => void; } const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; @@ -16,7 +18,6 @@ const SIBLING_COLUMN_MIN_WIDTH = 360; const PREVIEW_PANEL_DEFAULT_WIDTH = 540; export function usePreviewPanelInlineSize( - hostRef?: RefObject, options: { readonly enabled?: boolean | undefined; readonly widthStorageKey?: string | undefined; @@ -24,28 +25,23 @@ export function usePreviewPanelInlineSize( readonly container?: HTMLElement | null | undefined; } = {}, ): PreviewPanelInlineSize { - const maxWidth = useViewportClampedMaxWidth(hostRef, options.enabled ?? true, options.container); - return useResizableWidth({ + const enabled = options.enabled ?? true; + const container = options.container; + const [maxWidth, setMaxWidth] = useState(() => + getPreviewPanelMaxWidth(typeof window === "undefined" ? 1280 : window.innerWidth), + ); + const { width, handlers } = useResizableWidth({ storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, minWidth: PREVIEW_PANEL_MIN_WIDTH, maxWidth, edge: "left", }); -} - -function useViewportClampedMaxWidth( - hostRef: RefObject | undefined, - enabled: boolean, - container: HTMLElement | null | undefined, -): number { - const [maxWidth, setMaxWidth] = useState(() => - getPreviewPanelMaxWidth(typeof window === "undefined" ? 1280 : window.innerWidth), - ); - useLayoutEffect(() => { - if (typeof window === "undefined") return; - const row = container ?? (enabled ? hostRef?.current?.parentElement : null) ?? null; - // Clamp before paint to avoid flashing; store only the cap, which rarely changes during sidebar drags. + const observeRow = (host: HTMLElement) => { + const row = container ?? (enabled ? host.parentElement : null) ?? null; + // Ref callbacks run before paint, so the width is clamped without an over-wide + // flash on mount. Only the derived cap is stored: the row resizes every frame + // of a sidebar drag, but an unchanged cap skips re-rendering the owner. const measure = () => { setMaxWidth(getPreviewPanelMaxWidth(window.innerWidth, row?.clientWidth)); }; @@ -65,9 +61,10 @@ function useViewportClampedMaxWidth( if (frame !== 0) window.cancelAnimationFrame(frame); stopObserving?.(); }; - }, [container, hostRef, enabled]); - return maxWidth; + }; + return { width, handlers, observeRow }; } + function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); const containerCap = diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 3c76f607d5e69..39585837dfb83 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react"; +import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useState } from "react"; import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; import { useResizeDrag } from "./useResizeDrag"; @@ -18,6 +18,7 @@ export interface UseResizableWidthOptions { } export interface ResizableWidthHandlers { + readonly ref: (node: HTMLElement | null) => (() => void) | undefined; readonly onPointerDown: (event: ReactPointerEvent) => void; readonly onPointerMove: (event: ReactPointerEvent) => void; readonly onPointerUp: (event: ReactPointerEvent) => void; @@ -53,42 +54,52 @@ export function useResizableWidth(options: UseResizableWidthOptions): { } const clampedWidth = clamp(widthState.width); - const latestOptions = useRef({ clamp, storageKey, width: clampedWidth }); - useLayoutEffect(() => { - latestOptions.current = { clamp, storageKey, width: clampedWidth }; - }, [clamp, clampedWidth, storageKey]); - const { refresh, ...handlers } = useResizeDrag((event) => { + const drag = useResizeDrag((event) => { const host = event.currentTarget.parentElement; // Disable drag transitions through transition-property; the host renders transition-duration. host?.style.setProperty("transition-property", "none"); + // The drag commits to the key and width it started from, even if the panel + // switches keys before the pointer is released. + const startStorageKey = storageKey; + const startWidth = clampedWidth; return { - width: clampedWidth, + width: startWidth, edge, - resize(value) { - const nextWidth = latestOptions.current.clamp(value); + bounds: { min: minWidth, max: maxWidth }, + resize(nextWidth) { host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`); return nextWidth; }, finish(finalWidth) { - setWidthState({ storageKey, width: finalWidth }); + setWidthState({ storageKey: startStorageKey, width: finalWidth }); try { - setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); + setLocalStorageItem(startStorageKey, finalWidth, WidthSchema); } catch (error) { console.error("Could not persist panel width.", error); } }, cleanup(committed) { host?.style.removeProperty("transition-property"); - if (!committed) { - host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${latestOptions.current.width}px`); - } + if (!committed) host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${startWidth}px`); }, }; - }, storageKey); + }); - // Mid-drag bound changes rewrite committed width; restore the live pointer position. - useLayoutEffect(refresh, [clamp, clampedWidth, refresh]); + // Bounds can change mid-drag (sidebar opens, window narrows), and the render + // that carries them rewrites the committed width, so re-apply the live pointer position. + const { refresh } = drag; + useLayoutEffect(() => refresh({ min: minWidth, max: maxWidth }), [refresh, minWidth, maxWidth]); - return { width: clampedWidth, handlers }; + return { + width: clampedWidth, + handlers: { + ref: drag.ref, + onPointerDown: drag.onPointerDown, + onPointerMove: drag.onPointerMove, + onPointerUp: drag.onPointerUp, + onPointerCancel: drag.onPointerCancel, + onLostPointerCapture: drag.onLostPointerCapture, + }, + }; } diff --git a/apps/web/src/hooks/useResizeDrag.ts b/apps/web/src/hooks/useResizeDrag.ts index 4788856cc6522..06a65d9ec93c9 100644 --- a/apps/web/src/hooks/useResizeDrag.ts +++ b/apps/web/src/hooks/useResizeDrag.ts @@ -1,43 +1,57 @@ -import { type PointerEvent, useEffect, useEffectEvent, useLayoutEffect, useRef } from "react"; +import { type PointerEvent, useState } from "react"; + +export interface ResizeBounds { + readonly min: number; + readonly max: number; +} interface ResizeSession { width: number; edge: "left" | "right"; + /** Pointer widths are clamped to these before `resize` sees them. */ + bounds: ResizeBounds; resize: (width: number) => number; finish: (width: number, moved: boolean) => void; cleanup?: (committed: boolean) => void; } -export function useResizeDrag( - start: (event: PointerEvent) => ResizeSession | null, - resetKey?: string, -) { - const drag = useRef<{ - session: ResizeSession; - target: T; - pointerId: number; - startX: number; - pendingX: number; - width: number; - moved: boolean; - frame: number | null; - } | null>(null); +function clampToBounds(width: number, bounds: ResizeBounds): number { + return Math.max(bounds.min, Math.min(bounds.max, width)); +} + +interface ActiveDrag { + session: ResizeSession; + target: T; + pointerId: number; + startX: number; + pendingX: number; + width: number; + moved: boolean; + frame: number | null; + onBlur: () => void; +} + +function createResizeDrag() { + let drag: ActiveDrag | null = null; const flush = () => { - const active = drag.current; + const active = drag; if (!active) return; const delta = (active.pendingX - active.startX) * (active.session.edge === "left" ? -1 : 1); if (Math.abs(delta) > 2) active.moved = true; - active.width = active.session.resize(active.session.width + delta); + active.width = active.session.resize( + clampToBounds(active.session.width + delta, active.session.bounds), + ); }; - const finish = (commit = true) => { - const active = drag.current; + const finish = (commit: boolean) => { + const active = drag; if (!active) return; if (active.frame !== null) cancelAnimationFrame(active.frame); + window.removeEventListener("blur", active.onBlur); if (commit) flush(); // Release can synchronously dispatch lostpointercapture. - drag.current = null; + drag = null; try { if (active.target.hasPointerCapture(active.pointerId)) { active.target.releasePointerCapture(active.pointerId); @@ -49,37 +63,24 @@ export function useResizeDrag( if (commit) active.session.finish(active.width, active.moved); }; - const finishDrag = useEffectEvent((commit: boolean) => finish(commit)); - - const previousResetKey = useRef(resetKey); - useLayoutEffect(() => { - if (previousResetKey.current !== resetKey) { - finishDrag(false); - previousResetKey.current = resetKey; - } - }, [resetKey]); - - useEffect(() => { - const onBlur = () => finishDrag(true); - window.addEventListener("blur", onBlur); - return () => { - window.removeEventListener("blur", onBlur); - finishDrag(false); - }; - }, []); - const end = (event: PointerEvent, usePosition: boolean) => { - const active = drag.current; + const active = drag; if (!active || active.pointerId !== event.pointerId) return; event.preventDefault(); if (usePosition) active.pendingX = event.clientX; - finish(); + finish(true); }; return { - refresh: flush, - onPointerDown(event: PointerEvent) { - if (event.button !== 0 || drag.current) return; + ref: (node: T | null) => (node === null ? undefined : () => finish(false)), + cancel: () => finish(false), + refresh(bounds?: ResizeBounds) { + if (!drag) return; + if (bounds) drag.session.bounds = bounds; + flush(); + }, + begin(event: PointerEvent, start: (event: PointerEvent) => ResizeSession | null) { + if (event.button !== 0 || drag) return; const session = start(event); if (!session) return; try { @@ -90,7 +91,10 @@ export function useResizeDrag( } event.preventDefault(); event.stopPropagation(); - drag.current = { + // Leaving the window mid-drag commits where the pointer was. + const onBlur = () => finish(true); + window.addEventListener("blur", onBlur); + drag = { session, target: event.currentTarget, pointerId: event.pointerId, @@ -99,12 +103,13 @@ export function useResizeDrag( width: session.width, moved: false, frame: null, + onBlur, }; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; }, onPointerMove(event: PointerEvent) { - const active = drag.current; + const active = drag; if (!active || active.pointerId !== event.pointerId) return; event.preventDefault(); active.pendingX = event.clientX; @@ -120,3 +125,17 @@ export function useResizeDrag( onLostPointerCapture: (event: PointerEvent) => end(event, false), }; } + +/** + * A pointer drag that resizes along one edge. The session `start` returns owns + * everything the drag needs, so options read at pointer-down hold for the whole + * drag. Attach `ref` to the handle so unmounting it abandons the drag. + */ +export function useResizeDrag( + start: (event: PointerEvent) => ResizeSession | null, +) { + // Created once: `ref` must keep its identity, or a re-render mid-drag would run its cleanup. + const [drag] = useState(createResizeDrag); + const { begin, ...rest } = drag; + return { ...rest, onPointerDown: (event: PointerEvent) => begin(event, start) }; +} diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index ce9be08062aa4..37a5bec395175 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -171,6 +171,10 @@ export function useActivePreviewSessions(): Record { return useAtomValue(activePreviewSessionsAtom); } +export function readActivePreviewSessions(): Record { + return appAtomRegistry.get(activePreviewSessionsAtom); +} + export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState { return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref))); }