Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 32 additions & 18 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -109,15 +110,36 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
const useSidebarV2Theme = useSidebarV2 || isOnSettings;
const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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"
? getWindowFullscreenState()
: false;
});
const sidebarProviderStyle = {
"--sidebar-width": `${sidebarWidth}px`,
"--sidebar-width": resolveThreadSidebarCssWidth(sidebarWidth),
...(isMacosDesktop && !isWindowFullscreen
? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
: {}),
Expand Down Expand Up @@ -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 ? <ThreadSidebarV2 /> : <ThreadSidebar />}
<SidebarRail />
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/threadSidebarWidth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
});
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/threadSidebarWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/ui/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
90 changes: 81 additions & 9 deletions apps/web/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand Down Expand Up @@ -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 } : {}),
};
Expand Down Expand Up @@ -343,8 +349,27 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
);
}

export function resolveSidebarMaximumWidth(maxWidth: number | (() => 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({
Expand Down Expand Up @@ -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);
Comment thread
cursor[bot] marked this conversation as resolved.
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);
Expand Down Expand Up @@ -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<HTMLElement>("[data-slot='sidebar-wrapper']");
Expand All @@ -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<HTMLElement>("[data-slot='sidebar-wrapper']");
const sidebarRoot = rail?.closest<HTMLElement>("[data-slot='sidebar']");
if (!wrapper || !sidebarRoot) return;

const transitionTargets = [
sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-gap']"),
sidebarRoot.querySelector<HTMLElement>("[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(() => {
Expand Down
Loading