From 6d4f5cf581021e4e6726b777c9156555864d8393 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 14:43:20 -0700 Subject: [PATCH 1/3] fix(web): update sidebar resize limit with viewport --- apps/web/src/components/AppSidebarLayout.tsx | 19 ++++++++++++++++++- .../src/components/threadSidebarWidth.test.ts | 6 ++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 3a70390d0c4..ccb8ed892c2 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -43,6 +43,23 @@ function readInitialThreadSidebarWidth(): number { } } +/** Keep the resize cap aligned with Electron/browser window changes after the initial render. */ +function useThreadSidebarMaximumWidth(): number { + const [maximumWidth, setMaximumWidth] = useState(() => + resolveThreadSidebarMaximumWidth(window.innerWidth), + ); + + useEffect(() => { + const updateMaximumWidth = () => + setMaximumWidth(resolveThreadSidebarMaximumWidth(window.innerWidth)); + + window.addEventListener("resize", updateMaximumWidth); + return () => window.removeEventListener("resize", updateMaximumWidth); + }, []); + + return maximumWidth; +} + function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); @@ -109,7 +126,7 @@ 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 sidebarMaximumWidth = useThreadSidebarMaximumWidth(); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts index 12aef63bd5e..346b604b42d 100644 --- a/apps/web/src/components/threadSidebarWidth.test.ts +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveInitialThreadSidebarWidth, + resolveThreadSidebarMaximumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, THREAD_SIDEBAR_DEFAULT_WIDTH, THREAD_SIDEBAR_MIN_WIDTH, @@ -28,6 +29,11 @@ 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("keeps the sidebar minimum when the whole layout is narrower than its minimums", () => { expect(resolveInitialThreadSidebarWidth(900, 700)).toBe(THREAD_SIDEBAR_MIN_WIDTH); }); From bbffaa471573e5386d7261553486b5ab0510e810 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 17:08:05 -0700 Subject: [PATCH 2/3] fix(web): clamp sidebar during viewport resize --- apps/web/src/components/AppSidebarLayout.tsx | 65 ++++++++--------- apps/web/src/components/ui/sidebar.test.tsx | 14 ++++ apps/web/src/components/ui/sidebar.tsx | 76 +++++++++++++++++--- 3 files changed, 111 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index ccb8ed892c2..dc64754fd06 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,9 @@ import ThreadSidebar from "./Sidebar"; import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { - resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, + THREAD_SIDEBAR_DEFAULT_WIDTH, THREAD_SIDEBAR_MIN_WIDTH, THREAD_SIDEBAR_WIDTH_STORAGE_KEY, } from "./threadSidebarWidth"; @@ -33,33 +33,16 @@ 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; } } -/** Keep the resize cap aligned with Electron/browser window changes after the initial render. */ -function useThreadSidebarMaximumWidth(): number { - const [maximumWidth, setMaximumWidth] = useState(() => - resolveThreadSidebarMaximumWidth(window.innerWidth), - ); - - useEffect(() => { - const updateMaximumWidth = () => - setMaximumWidth(resolveThreadSidebarMaximumWidth(window.innerWidth)); - - window.addEventListener("resize", updateMaximumWidth); - return () => window.removeEventListener("resize", updateMaximumWidth); - }, []); - - return maximumWidth; -} - function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); @@ -126,7 +109,27 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); - const sidebarMaximumWidth = useThreadSidebarMaximumWidth(); + const sidebarResizable = useMemo( + () => ({ + 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" @@ -134,7 +137,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { : false; }); const sidebarProviderStyle = { - "--sidebar-width": `${sidebarWidth}px`, + "--sidebar-width": `min(${sidebarWidth}px, max(${THREAD_SIDEBAR_MIN_WIDTH}px, calc(100vw - ${THREAD_MAIN_CONTENT_MIN_WIDTH}px)))`, ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } : {}), @@ -185,15 +188,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/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index 904f8664772..1f38e386c46 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -2,6 +2,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { + clampSidebarWidthValue, + resolveSidebarMaximumWidth, SidebarMenuAction, SidebarMenuButton, SidebarMenuSubButton, @@ -19,6 +21,18 @@ 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("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..1549594bf73 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -40,7 +40,8 @@ type SidebarContextProps = { }; type SidebarResizableOptions = { - maxWidth?: number; + hydrateStoredWidth?: boolean; + maxWidth?: number | (() => number); minWidth?: number; onResize?: (width: number) => void; shouldAcceptWidth?: (context: { @@ -55,7 +56,8 @@ type SidebarResizableOptions = { }; type SidebarResolvedResizableOptions = { - maxWidth: number; + hydrateStoredWidth: boolean; + maxWidth: number | (() => number); minWidth: number; onResize?: (width: number) => void; shouldAcceptWidth?: (context: { @@ -199,6 +201,7 @@ 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, @@ -343,8 +346,20 @@ 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))); +} + 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 +403,17 @@ function SidebarRail({ if (resizeState.rafId !== null) { window.cancelAnimationFrame(resizeState.rafId); } + const width = resolvedResizable + ? clampSidebarWidth(resizeState.width, resolvedResizable) + : resizeState.width; + resizeState.wrapper.style.setProperty("--sidebar-width", `${width}px`); + 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 +575,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 +599,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(() => { From ea0cea17c7cad9af1c75353c707c2882d83be0d8 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 17:49:45 -0700 Subject: [PATCH 3/3] fix(web): preserve sidebar viewport clamp after drag --- apps/web/src/components/AppSidebarLayout.tsx | 4 +++- .../src/components/threadSidebarWidth.test.ts | 7 +++++++ apps/web/src/components/threadSidebarWidth.ts | 4 ++++ apps/web/src/components/ui/sidebar.test.tsx | 8 ++++++++ apps/web/src/components/ui/sidebar.tsx | 16 +++++++++++++++- 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index dc64754fd06..f3cb7a0c4c0 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -13,6 +13,7 @@ import ThreadSidebar from "./Sidebar"; import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { + resolveThreadSidebarCssWidth, resolveThreadSidebarMaximumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, THREAD_SIDEBAR_DEFAULT_WIDTH, @@ -111,6 +112,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); const sidebarResizable = useMemo( () => ({ + getCssWidth: resolveThreadSidebarCssWidth, maxWidth: () => resolveThreadSidebarMaximumWidth(window.innerWidth), minWidth: THREAD_SIDEBAR_MIN_WIDTH, shouldAcceptWidth: ({ @@ -137,7 +139,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { : false; }); const sidebarProviderStyle = { - "--sidebar-width": `min(${sidebarWidth}px, max(${THREAD_SIDEBAR_MIN_WIDTH}px, calc(100vw - ${THREAD_MAIN_CONTENT_MIN_WIDTH}px)))`, + "--sidebar-width": resolveThreadSidebarCssWidth(sidebarWidth), ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } : {}), diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts index 346b604b42d..471fbaef25c 100644 --- a/apps/web/src/components/threadSidebarWidth.test.ts +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveInitialThreadSidebarWidth, + resolveThreadSidebarCssWidth, resolveThreadSidebarMaximumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, THREAD_SIDEBAR_DEFAULT_WIDTH, @@ -34,6 +35,12 @@ describe("thread sidebar 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 1f38e386c46..e492ef238be 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { clampSidebarWidthValue, + resolveSidebarCssWidth, resolveSidebarMaximumWidth, SidebarMenuAction, SidebarMenuButton, @@ -33,6 +34,13 @@ describe("sidebar interactive cursors", () => { 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 1549594bf73..c11236d8d38 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -40,6 +40,7 @@ type SidebarContextProps = { }; type SidebarResizableOptions = { + getCssWidth?: (width: number) => string; hydrateStoredWidth?: boolean; maxWidth?: number | (() => number); minWidth?: number; @@ -56,6 +57,7 @@ type SidebarResizableOptions = { }; type SidebarResolvedResizableOptions = { + getCssWidth?: (width: number) => string; hydrateStoredWidth: boolean; maxWidth: number | (() => number); minWidth: number; @@ -205,6 +207,7 @@ function Sidebar({ 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 } : {}), }; @@ -358,6 +361,13 @@ export function clampSidebarWidthValue( 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 clampSidebarWidthValue(width, options.minWidth, options.maxWidth); } @@ -406,7 +416,11 @@ function SidebarRail({ const width = resolvedResizable ? clampSidebarWidth(resizeState.width, resolvedResizable) : resizeState.width; - resizeState.wrapper.style.setProperty("--sidebar-width", `${width}px`); + // 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); }