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))); }