Skip to content
Merged
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
14 changes: 9 additions & 5 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,16 @@ import LegacyThreadSidebar from "./LegacySidebar";
import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
import ThreadSidebar from "./Sidebar";
import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome";
import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
import { useProjects } from "../state/entities";
import {
clampThreadSidebarWidth,
resolveInitialThreadSidebarWidth,
resolveThreadSidebarMaximumWidth,
resolveThreadSidebarMinimumWidth,
THREAD_MAIN_CONTENT_MIN_WIDTH,
THREAD_SIDEBAR_MIN_WIDTH,
THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
} from "./threadSidebarWidth";
import {
Expand Down Expand Up @@ -234,7 +235,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
// and a clamped drag ends with an unchanged width, which skips the re-render
// that would otherwise refresh a render-time snapshot.
const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth);
const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth);
const [brandWidth, setBrandWidth] = useState(0);
const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth);
const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth);
const resetSidebarWidth = () => {
try {
removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY);
Expand All @@ -250,7 +253,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
: false;
});
const sidebarProviderStyle = {
"--sidebar-width": `${sidebarWidth}px`,
"--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`,
"--panel-animation-duration": `${panelAnimationDurationMs}ms`,
...(isMacosDesktop && !isWindowFullscreen
? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
Expand Down Expand Up @@ -302,6 +305,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
defaultOpen
style={sidebarProviderStyle}
>
<SidebarBrandWidthProbe onWidthChange={setBrandWidth} />
<ProjectProjectionRetention />
<Sidebar
side="left"
Expand All @@ -311,7 +315,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
aria-label={isOnSettings ? "Settings" : "Threads"}
resizable={{
maxWidth: sidebarMaximumWidth,
minWidth: THREAD_SIDEBAR_MIN_WIDTH,
minWidth: sidebarMinimumWidth,
shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
nextWidth <= currentWidth ||
wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
Expand Down
86 changes: 62 additions & 24 deletions apps/web/src/components/sidebar/SidebarChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
// The titlebar row, not a padded SidebarHeader: it aligns to the window controls.
<div
className={cn(
"@container/sidebar-header relative flex h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center gap-2 px-3 md:px-0",
"relative flex h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center gap-2 px-3 md:pl-0",
isElectron && "drag-region",
)}
>
Expand All @@ -60,21 +60,53 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
variant={backdropVariant ? "media-navigation" : "ghost"}
className="relative top-auto z-10 translate-y-0 md:hidden"
/>
<SidebarBrand onBackdrop={backdropVariant !== null} />
{pillLabel ? (
<Badge
className="relative z-10 ml-1 hidden @[15rem]/sidebar-header:inline-flex"
data-environment-identification="pill"
size="sm"
variant="secondary"
>
{pillLabel}
</Badge>
) : null}
{/* One visible line: the pill wraps onto the clipped second line once it no longer fits.
The padding keeps the brand's focus ring inside the clip. */}
<div className="relative z-10 flex h-8 min-w-0 flex-1 flex-wrap content-start items-center gap-x-2 overflow-hidden py-0.5">
<SidebarBrand onBackdrop={backdropVariant !== null} />
{pillLabel ? (
<div className="ml-1 flex h-7 items-center">
<Badge data-environment-identification="pill" size="sm" variant="secondary">
{pillLabel}
</Badge>
</div>
) : null}
</div>
</div>
);
});

// Measures the brand at its titlebar inset, plus the header's right padding and the
// sidebar border, so the sidebar minimum follows font size, zoom and macOS window controls.
export function SidebarBrandWidthProbe({
onWidthChange,
}: {
onWidthChange: (width: number) => void;
}) {
const observeWidth = useCallback(
(probe: HTMLDivElement) => {
const observer = new ResizeObserver(([entry]) => {
if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth);
});
observer.observe(probe);
return () => observer.disconnect();
},
[onWidthChange],
);

return (
<div
aria-hidden="true"
className="pointer-events-none invisible fixed top-0 left-0 flex w-max border-r border-transparent pr-3"
ref={observeWidth}
>
<div className="ml-[var(--workspace-titlebar-content-left)] flex">
<SidebarBrandMark onBackdrop={false} />
</div>
</div>
);
}

function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
return (
<Link
Expand All @@ -85,22 +117,28 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
)}
to="/"
>
{/* Center the visible capitals, without the font's ascender/descender space. */}
<span className="inline-flex min-w-0 items-baseline gap-1 text-sm font-medium tracking-tight">
<T3Wordmark aria-label="T3" className="h-[1cap] w-auto shrink-0" />
<span
className={cn(
"truncate [text-box:trim-both_cap_alphabetic]",
onBackdrop ? "text-white/70" : "text-muted-foreground",
)}
>
Code
</span>
</span>
<SidebarBrandMark onBackdrop={onBackdrop} />
</Link>
);
}

function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) {
return (
// Center the visible capitals, without the font's ascender/descender space.
<span className="inline-flex min-w-0 items-baseline gap-1 text-sm font-medium tracking-tight">
<T3Wordmark aria-label="T3" className="h-[1cap] w-auto shrink-0" />
<span
className={cn(
"truncate [text-box:trim-both_cap_alphabetic]",
onBackdrop ? "text-white/70" : "text-muted-foreground",
)}
>
Code
</span>
</span>
);
}

function SidebarUtilityItem({
icon,
label,
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/components/threadSidebarWidth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vite-plus/test";
import {
clampThreadSidebarWidth,
resolveThreadSidebarMaximumWidth,
resolveThreadSidebarMinimumWidth,
THREAD_SIDEBAR_MIN_WIDTH,
} from "./threadSidebarWidth";

describe("resolveThreadSidebarMinimumWidth", () => {
it("keeps the default minimum when the brand fits", () => {
expect(resolveThreadSidebarMinimumWidth(0)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
expect(resolveThreadSidebarMinimumWidth(194)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
});

it("grows to a brand wider than the default, rounding up", () => {
expect(resolveThreadSidebarMinimumWidth(237.2)).toBe(238);
});
});

describe("resolveThreadSidebarMaximumWidth", () => {
it("never drops below a raised minimum on a narrow viewport", () => {
expect(resolveThreadSidebarMaximumWidth(800, 238)).toBe(238);
expect(resolveThreadSidebarMaximumWidth(1200, 238)).toBe(560);
});
});

describe("clampThreadSidebarWidth", () => {
it("widens a stored width below a raised minimum", () => {
expect(clampThreadSidebarWidth(208, 238, 560)).toBe(238);
});

it("keeps widths inside the range and caps wide ones", () => {
expect(clampThreadSidebarWidth(300, 238, 560)).toBe(300);
expect(clampThreadSidebarWidth(900, 238, 560)).toBe(560);
});
});
23 changes: 18 additions & 5 deletions apps/web/src/components/threadSidebarWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,24 @@ const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16;
export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;

export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number {
return Math.max(
THREAD_SIDEBAR_MIN_WIDTH,
Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH,
);
// The brand's measured width can raise the minimum so "T3 Code" never clips.
export function resolveThreadSidebarMinimumWidth(brandWidth: number): number {
return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth));
}

export function resolveThreadSidebarMaximumWidth(
viewportWidth: number,
minimumWidth = THREAD_SIDEBAR_MIN_WIDTH,
): number {
return Math.max(minimumWidth, Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH);
}

export function clampThreadSidebarWidth(
width: number,
minimumWidth: number,
maximumWidth: number,
): number {
return Math.min(maximumWidth, Math.max(minimumWidth, width));
}

export function resolveInitialThreadSidebarWidth(
Expand Down
Loading