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(() => {