diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 3a70390d0c4..f3cb7a0c4c0 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; -import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; @@ -13,9 +13,10 @@ import ThreadSidebar from "./Sidebar"; import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { - resolveInitialThreadSidebarWidth, + resolveThreadSidebarCssWidth, resolveThreadSidebarMaximumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, + THREAD_SIDEBAR_DEFAULT_WIDTH, THREAD_SIDEBAR_MIN_WIDTH, THREAD_SIDEBAR_WIDTH_STORAGE_KEY, } from "./threadSidebarWidth"; @@ -33,13 +34,13 @@ const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; function readInitialThreadSidebarWidth(): number { try { - return resolveInitialThreadSidebarWidth( - getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite), - window.innerWidth, - ); + const storedWidth = getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite); + return storedWidth === null + ? THREAD_SIDEBAR_DEFAULT_WIDTH + : Math.max(THREAD_SIDEBAR_MIN_WIDTH, storedWidth); } catch (error) { console.error("Could not read persisted thread sidebar width.", error); - return resolveInitialThreadSidebarWidth(null, window.innerWidth); + return THREAD_SIDEBAR_DEFAULT_WIDTH; } } @@ -109,7 +110,28 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); - const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); + const sidebarResizable = useMemo( + () => ({ + getCssWidth: resolveThreadSidebarCssWidth, + maxWidth: () => resolveThreadSidebarMaximumWidth(window.innerWidth), + minWidth: THREAD_SIDEBAR_MIN_WIDTH, + shouldAcceptWidth: ({ + currentWidth, + nextWidth, + wrapper, + }: { + currentWidth: number; + nextWidth: number; + wrapper: HTMLElement; + }) => + nextWidth <= currentWidth || + wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + hydrateStoredWidth: false, + onResize: setSidebarWidth, + }), + [], + ); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" @@ -117,7 +139,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { : false; }); const sidebarProviderStyle = { - "--sidebar-width": `${sidebarWidth}px`, + "--sidebar-width": resolveThreadSidebarCssWidth(sidebarWidth), ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } : {}), @@ -168,15 +190,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { data-app-sidebar="" data-sidebar-version={useSidebarV2Theme ? "v2" : "v1"} className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" - resizable={{ - maxWidth: sidebarMaximumWidth, - minWidth: THREAD_SIDEBAR_MIN_WIDTH, - shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => - nextWidth <= currentWidth || - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, - storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, - onResize: setSidebarWidth, - }} + resizable={sidebarResizable} > {useSidebarV2 ? : } diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts index 12aef63bd5e..471fbaef25c 100644 --- a/apps/web/src/components/threadSidebarWidth.test.ts +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveInitialThreadSidebarWidth, + resolveThreadSidebarCssWidth, + resolveThreadSidebarMaximumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, THREAD_SIDEBAR_DEFAULT_WIDTH, THREAD_SIDEBAR_MIN_WIDTH, @@ -28,6 +30,17 @@ describe("thread sidebar width", () => { ); }); + it("raises the maximum when the viewport grows", () => { + expect(resolveThreadSidebarMaximumWidth(1000)).toBe(1000 - THREAD_MAIN_CONTENT_MIN_WIDTH); + expect(resolveThreadSidebarMaximumWidth(1800)).toBe(1800 - THREAD_MAIN_CONTENT_MIN_WIDTH); + }); + + it("expresses the preferred width with a live viewport clamp", () => { + expect(resolveThreadSidebarCssWidth(400)).toBe( + `min(400px, max(${THREAD_SIDEBAR_MIN_WIDTH}px, calc(100vw - ${THREAD_MAIN_CONTENT_MIN_WIDTH}px)))`, + ); + }); + it("keeps the sidebar minimum when the whole layout is narrower than its minimums", () => { expect(resolveInitialThreadSidebarWidth(900, 700)).toBe(THREAD_SIDEBAR_MIN_WIDTH); }); diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts index 93dd196e84d..1aa181106de 100644 --- a/apps/web/src/components/threadSidebarWidth.ts +++ b/apps/web/src/components/threadSidebarWidth.ts @@ -10,6 +10,10 @@ export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number ); } +export function resolveThreadSidebarCssWidth(width: number): string { + return `min(${width}px, max(${THREAD_SIDEBAR_MIN_WIDTH}px, calc(100vw - ${THREAD_MAIN_CONTENT_MIN_WIDTH}px)))`; +} + export function resolveInitialThreadSidebarWidth( storedWidth: number | null, viewportWidth: number, diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index 904f8664772..e492ef238be 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -2,6 +2,9 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { + clampSidebarWidthValue, + resolveSidebarCssWidth, + resolveSidebarMaximumWidth, SidebarMenuAction, SidebarMenuButton, SidebarMenuSubButton, @@ -19,6 +22,25 @@ function renderSidebarButton(className?: string) { } describe("sidebar interactive cursors", () => { + it("resolves and clamps against a live maximum width", () => { + let maximumWidth = 360; + const liveMaximumWidth = () => maximumWidth; + + expect(resolveSidebarMaximumWidth(liveMaximumWidth)).toBe(360); + expect(clampSidebarWidthValue(340, 208, liveMaximumWidth)).toBe(340); + + maximumWidth = 208; + expect(resolveSidebarMaximumWidth(liveMaximumWidth)).toBe(208); + expect(clampSidebarWidthValue(340, 208, liveMaximumWidth)).toBe(208); + }); + + it("preserves a dynamic CSS width when committing a rail resize", () => { + const getCssWidth = (width: number) => `min(${width}px, calc(100vw - 640px))`; + + expect(resolveSidebarCssWidth(340, getCssWidth)).toBe("min(340px, calc(100vw - 640px))"); + expect(resolveSidebarCssWidth(340)).toBe("340px"); + }); + it("uses mobile sheet visibility for the shared responsive state", () => { expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe( "collapsed", diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index af90a7cc65b..c11236d8d38 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -40,7 +40,9 @@ type SidebarContextProps = { }; type SidebarResizableOptions = { - maxWidth?: number; + getCssWidth?: (width: number) => string; + hydrateStoredWidth?: boolean; + maxWidth?: number | (() => number); minWidth?: number; onResize?: (width: number) => void; shouldAcceptWidth?: (context: { @@ -55,7 +57,9 @@ type SidebarResizableOptions = { }; type SidebarResolvedResizableOptions = { - maxWidth: number; + getCssWidth?: (width: number) => string; + hydrateStoredWidth: boolean; + maxWidth: number | (() => number); minWidth: number; onResize?: (width: number) => void; shouldAcceptWidth?: (context: { @@ -199,9 +203,11 @@ function Sidebar({ const options = typeof resizable === "boolean" ? {} : resizable; return { + hydrateStoredWidth: options.hydrateStoredWidth ?? true, maxWidth: options.maxWidth ?? Number.POSITIVE_INFINITY, minWidth: options.minWidth ?? SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH, storageKey: options.storageKey ?? null, + ...(options.getCssWidth ? { getCssWidth: options.getCssWidth } : {}), ...(options.onResize ? { onResize: options.onResize } : {}), ...(options.shouldAcceptWidth ? { shouldAcceptWidth: options.shouldAcceptWidth } : {}), }; @@ -343,8 +349,27 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps number)): number { + return typeof maxWidth === "function" ? maxWidth() : maxWidth; +} + +export function clampSidebarWidthValue( + width: number, + minWidth: number, + maxWidth: number | (() => number), +): number { + return Math.max(minWidth, Math.min(width, resolveSidebarMaximumWidth(maxWidth))); +} + +export function resolveSidebarCssWidth( + width: number, + getCssWidth?: (width: number) => string, +): string { + return getCssWidth?.(width) ?? `${width}px`; +} + function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptions): number { - return Math.max(options.minWidth, Math.min(width, options.maxWidth)); + return clampSidebarWidthValue(width, options.minWidth, options.maxWidth); } function SidebarRail({ @@ -388,13 +413,21 @@ function SidebarRail({ if (resizeState.rafId !== null) { window.cancelAnimationFrame(resizeState.rafId); } + const width = resolvedResizable + ? clampSidebarWidth(resizeState.width, resolvedResizable) + : resizeState.width; + // Keep caller-owned viewport clamps even when onResize does not trigger a render. + resizeState.wrapper.style.setProperty( + "--sidebar-width", + resolveSidebarCssWidth(width, resolvedResizable?.getCssWidth), + ); + if (resolvedResizable?.storageKey && typeof window !== "undefined") { + setLocalStorageItem(resolvedResizable.storageKey, width, Schema.Finite); + } + resolvedResizable?.onResize?.(width); resizeState.transitionTargets.forEach((element) => { element.style.removeProperty("transition-duration"); }); - if (resolvedResizable?.storageKey && typeof window !== "undefined") { - setLocalStorageItem(resolvedResizable.storageKey, resizeState.width, Schema.Finite); - } - resolvedResizable?.onResize?.(resizeState.width); resizeStateRef.current = null; if (resizeState.rail.hasPointerCapture(pointerId)) { resizeState.rail.releasePointerCapture(pointerId); @@ -556,7 +589,13 @@ function SidebarRail({ ); React.useLayoutEffect(() => { - if (!resolvedResizable?.storageKey || typeof window === "undefined") return; + if ( + !resolvedResizable?.storageKey || + !resolvedResizable.hydrateStoredWidth || + typeof window === "undefined" + ) { + return; + } const rail = railRef.current; if (!rail) return; const wrapper = rail.closest("[data-slot='sidebar-wrapper']"); @@ -574,7 +613,40 @@ function SidebarRail({ // Hydrate the CSS variable before the browser paints so a restored sidebar // never flashes at the default width first. wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); - resolvedResizable.onResize?.(clampedWidth); + // Preserve the user's stored preference even when the current viewport + // temporarily applies a smaller maximum width. + resolvedResizable.onResize?.(storedWidth); + }, [resolvedResizable]); + + React.useEffect(() => { + if (!resolvedResizable || typeof window === "undefined") return; + + const suppressViewportResizeTransition = () => { + // Pointer moves resolve the live maximum. Do not interrupt their + // imperative width; stopResize clamps once more before persisting. + if (resizeStateRef.current) return; + + const rail = railRef.current; + const wrapper = rail?.closest("[data-slot='sidebar-wrapper']"); + const sidebarRoot = rail?.closest("[data-slot='sidebar']"); + if (!wrapper || !sidebarRoot) return; + + const transitionTargets = [ + sidebarRoot.querySelector("[data-slot='sidebar-gap']"), + sidebarRoot.querySelector("[data-slot='sidebar-container']"), + ].filter((element): element is HTMLElement => element !== null); + transitionTargets.forEach((element) => { + element.style.setProperty("transition-duration", "0ms"); + }); + window.requestAnimationFrame(() => { + transitionTargets.forEach((element) => { + element.style.removeProperty("transition-duration"); + }); + }); + }; + + window.addEventListener("resize", suppressViewportResizeTransition); + return () => window.removeEventListener("resize", suppressViewportResizeTransition); }, [resolvedResizable]); React.useEffect(() => {