Open History to see the last commit
1import { useAtomValue } from "@effect/atom-react";
2import * as Schema from "effect/Schema";
3import {
4 lazy,
5 Suspense,
6 useEffect,
7 useState,
8 useSyncExternalStore,
9 type CSSProperties,
10 type ReactNode,
11} from "react";
12import { useLocation, useParams, useRouter, type AnyRouter } from "@tanstack/react-router";
13import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
14
15import { moveWorkspaceFocus, workspaceAreaProps } from "../lib/workspaceFocus";
16import { isCommandPaletteOpen } from "../commandPaletteBus";
17import { isElectron } from "../env";
18import {
19 releaseThreadUndoShortcut,
20 threadUndoClaimsShortcut,
21 undoLatestThreadAction,
22} from "../hooks/showThreadUndoNotice";
23import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage";
24import {
25 isRichTextBoldShortcut,
26 resolveShortcutCommand,
27 shortcutLabelForCommand,
28} from "../keybindings";
29import { isEditableFocused } from "../lib/editableFocus";
30import { isPreviewFocused } from "../lib/previewFocus";
31import { isTerminalFocused } from "../lib/terminalFocus";
32import { isModelPickerOpen } from "../modelPickerVisibility";
33import {
34 navigateHistory,
35 useCanNavigateHistory,
36 type HistoryDirection,
37} from "../navigationHistory";
38import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
39import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
40import { cn, isMacPlatform } from "../lib/utils";
41import { primaryServerKeybindingsAtom } from "../state/server";
42import { useMediaQuery } from "../hooks/useMediaQuery";
43import {
44 useEnvironmentIdentificationMode,
45 useHideWindowControlsWhenSidebarCollapsed,
46} from "../hooks/useSettings";
47import {
48 PanelAnimationSuppressionProvider,
49 usePanelAnimationSettings,
50 usePanelNavigationSuppression,
51} from "../panelAnimations";
52import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer";
53import { HistorySwipeNavigation } from "./HistorySwipeNavigation";
54import ThreadSidebar from "./Sidebar";
55import { isAppModalPage } from "./sidebar/mainAppLocation";
56import { resolveThreadRouteTarget } from "../threadRoutes";
57import { selectFocusedTab } from "../layout/layoutModel";
58import { useLayoutStore } from "../layout/layoutStore";
59import { isLayoutDragActive } from "../layout/layoutDrag";
60import { isModalDialogOpen } from "../lib/modalDialog";
61import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
62import type { SidebarRouteReveal } from "./sidebar/routeReveal";
63import { WorkspacePlane } from "../layout/WorkspacePlane";
64import { overviewZoomTransition, useOverviewPhase } from "../layout/Overview";
65import { ChromeChannelArt, useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
66import {
67 clampThreadSidebarWidth,
68 resolveInitialThreadSidebarWidth,
69 resolveThreadSidebarMaximumWidth,
70 resolveThreadSidebarMinimumWidth,
71 THREAD_MAIN_CONTENT_MIN_WIDTH,
72 THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
73} from "./threadSidebarWidth";
74import {
75 createSidebarOpenStore,
76 Sidebar,
77 SidebarProvider,
78 SidebarRail,
79 SidebarTrigger,
80 useMobileSidebarSheetMounted,
81 useSidebar,
82 useSidebarVisibility,
83} from "./ui/sidebar";
84import { Button } from "./ui/button";
85import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
86import { appAtomRegistry } from "../rpc/atomRegistry";
87import { registerCommands, registerMenuAction } from "../shortcuts/dispatcher";
88import { resolveThreadRouteRef } from "../threadRoutes";
89import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome";
90
91const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)";
92
93function subscribeToViewportWidth(onChange: () => void): () => void {
94 window.addEventListener("resize", onChange);
95 return () => window.removeEventListener("resize", onChange);
96}
97
98function readViewportWidth(): number {
99 return window.innerWidth;
100}
101
102// Cache the synchronous fullscreen IPC read until subscription.
103let windowFullscreenSnapshot: boolean | null = null;
104
105function macosDesktopBridge() {
106 if (!isElectron || !isMacPlatform(navigator.platform)) return null;
107 return window.desktopBridge ?? null;
108}
109
110function subscribeToWindowFullscreen(onChange: () => void): () => void {
111 const bridge = macosDesktopBridge();
112 if (
113 typeof bridge?.getWindowFullscreenState !== "function" ||
114 typeof bridge.onWindowFullscreenStateChange !== "function"
115 ) {
116 return () => {};
117 }
118 const unsubscribe = bridge.onWindowFullscreenStateChange((fullscreen) => {
119 windowFullscreenSnapshot = fullscreen;
120 onChange();
121 });
122 // The window may have changed state between the first render and this subscription.
123 windowFullscreenSnapshot = bridge.getWindowFullscreenState();
124 return () => {
125 unsubscribe();
126 windowFullscreenSnapshot = null;
127 };
128}
129
130function readWindowFullscreen(): boolean {
131 if (windowFullscreenSnapshot === null) {
132 const getWindowFullscreenState = macosDesktopBridge()?.getWindowFullscreenState;
133 windowFullscreenSnapshot =
134 typeof getWindowFullscreenState === "function" ? getWindowFullscreenState() : false;
135 }
136 return windowFullscreenSnapshot;
137}
138
139function readInitialThreadSidebarWidth(): number {
140 try {
141 return resolveInitialThreadSidebarWidth(
142 getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite),
143 window.innerWidth,
144 );
145 } catch (error) {
146 console.error("Could not read persisted thread sidebar width.", error);
147 return resolveInitialThreadSidebarWidth(null, window.innerWidth);
148 }
149}
150
151function readUsagePageOpen(router: AnyRouter) {
152 return router.state.location.pathname === "/usage";
153}
154
155function isKeybindingCaptureTarget(event: KeyboardEvent) {
156 return event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]");
157}
158
159function readRoutePanelsOpen(router: AnyRouter) {
160 const routeThreadRef = resolveThreadRouteRef(router.state.matches.at(-1)?.params ?? {});
161 if (!routeThreadRef) return { terminalOpen: false, previewOpen: false };
162 return {
163 terminalOpen: selectThreadTerminalUiState(
164 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
165 routeThreadRef,
166 ).terminalOpen,
167 previewOpen:
168 selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) ===
169 "preview",
170 };
171}
172
173export function registerNavigationHistoryCommands(router: AnyRouter): () => void {
174 return registerCommands(
175 {
176 "navigation.back": (event) => {
177 if (!navigateHistory("back")) return false;
178 event.preventDefault();
179 event.stopPropagation();
180 },
181 "navigation.forward": (event) => {
182 if (!navigateHistory("forward")) return false;
183 event.preventDefault();
184 event.stopPropagation();
185 },
186 },
187 {
188 when: (event) => !isKeybindingCaptureTarget(event),
189 context: () => ({
190 ...readRoutePanelsOpen(router),
191 usagePageOpen: readUsagePageOpen(router),
192 }),
193 },
194 );
195}
196
197// Capture phase, so F6 also leaves widgets that swallow keys, such as the terminal.
198function registerWorkspaceFocusCommands(router: AnyRouter): () => void {
199 const move = (direction: 1 | -1) => (event: KeyboardEvent) => {
200 if (event.isComposing) return false;
201 // Inside a dialog the move is refused, but the key is still consumed.
202 event.preventDefault();
203 event.stopPropagation();
204 moveWorkspaceFocus(direction);
205 };
206 const disposers = [
207 registerCommands(
208 { "workspace.focusNext": move(1), "workspace.focusPrevious": move(-1) },
209 {
210 capture: true,
211 when: (event) => !isKeybindingCaptureTarget(event),
212 context: () => readRoutePanelsOpen(router),
213 },
214 ),
215 // A focused desktop browser forwards the keys; its renderer focus is stale.
216 registerMenuAction("workspace.focusNext", () => moveWorkspaceFocus(1, "panel")),
217 registerMenuAction("workspace.focusPrevious", () => moveWorkspaceFocus(-1, "panel")),
218 ];
219 return () => {
220 for (const dispose of disposers) dispose();
221 };
222}
223
224// Escape leaves the pull requests page when no scope (a selection, a dialog,
225// a drag) claimed it, so it observes after every other scope.
226function registerPullRequestsEscapeCommand(router: AnyRouter): () => void {
227 return registerCommands(
228 {},
229 {
230 observe: (event, consumed) => {
231 if (consumed || event.repeat || event.isComposing || event.key !== "Escape") return;
232 if (
233 router.state.location.pathname !== "/pull-requests" ||
234 isCommandPaletteOpen() ||
235 isModalDialogOpen() ||
236 isLayoutDragActive()
237 )
238 return;
239 if (selectFocusedTab(useLayoutStore.getState().layout)?.content.kind !== "pull-requests")
240 return;
241 event.preventDefault();
242 const activeElement = document.activeElement;
243 if (activeElement instanceof HTMLElement) activeElement.blur();
244 if (router.state.location.state.__TSR_index !== 0) {
245 window.history.back();
246 return;
247 }
248 void router.navigate({ to: "/" });
249 },
250 },
251 );
252}
253
254const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]);
255
256// A held key undoes once. Module state: undoing an archive navigates, and the
257// hold must survive whatever that re-renders.
258let heldUndoKey: string | null = null;
259
260// Undoes the thread actions in the sidebar's notice. Settings and Usage own
261// mod+z while open. Captured so mod+z reaches it before the composer's own
262// undo handler consumes the key.
263function registerThreadUndoCommands(
264 router: AnyRouter,
265 readSidebarVisible: () => boolean,
266): () => void {
267 const threadSidebarShown = () => !isAppModalPage(router.state.location.pathname);
268 const unregister = registerCommands(
269 {},
270 {
271 capture: true,
272 when: threadSidebarShown,
273 onKeyDown: (event) => {
274 // Cancel a held key's repeats so they cannot fall through to the
275 // editor's own undo, even after the last notice is gone.
276 if (event.repeat) {
277 if (event.code !== heldUndoKey) return false;
278 event.preventDefault();
279 event.stopPropagation();
280 return true;
281 }
282 // A modifier tapped mid-hold leaves the held key repeating.
283 if (!MODIFIER_KEYS.has(event.key)) heldUndoKey = null;
284 const command = resolveShortcutCommand(
285 event,
286 appAtomRegistry.get(primaryServerKeybindingsAtom),
287 {
288 context: {
289 terminalFocus: isTerminalFocused(),
290 previewFocus: isPreviewFocused(),
291 ...readRoutePanelsOpen(router),
292 // A collapsed sidebar hides the notice, so text fields keep their own undo.
293 editableFocus:
294 isEditableFocused(event.target) &&
295 !(readSidebarVisible() && threadUndoClaimsShortcut()),
296 modelPickerOpen: isModelPickerOpen(),
297 },
298 },
299 );
300 if (command !== "thread.undo") {
301 if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut();
302 return false;
303 }
304 if (isCommandPaletteOpen() || isModelPickerOpen()) return false;
305 if (!undoLatestThreadAction()) return false;
306 heldUndoKey = event.code;
307 event.preventDefault();
308 event.stopPropagation();
309 return true;
310 },
311 },
312 );
313 // Pastes from a menu, dictation and drops edit text without a keypress.
314 const onInput = () => {
315 if (threadSidebarShown()) releaseThreadUndoShortcut();
316 };
317 window.addEventListener("input", onInput, true);
318 return () => {
319 unregister();
320 window.removeEventListener("input", onInput, true);
321 };
322}
323
324/** Keyboard and menu commands that live as long as the app shell. */
325function registerAppShellCommands(router: AnyRouter, shellElement: Element): () => void {
326 // The provider mirrors the visibility it renders onto its wrapper, so the
327 // committed state is readable when a key arrives.
328 const readSidebarVisible = () =>
329 shellElement.closest('[data-slot="sidebar-wrapper"]')?.getAttribute("data-sidebar-state") ===
330 "expanded";
331 const disposers = [
332 registerThreadUndoCommands(router, readSidebarVisible),
333 registerNavigationHistoryCommands(router),
334 registerWorkspaceFocusCommands(router),
335 registerPullRequestsEscapeCommand(router),
336 registerMenuAction("open-settings", () => {
337 if (!/^\/settings(\/|$)/.test(router.state.location.pathname))
338 void router.navigate({ to: "/settings", search: { pane: undefined } });
339 }),
340 ];
341 return () => {
342 for (const dispose of disposers) dispose();
343 };
344}
345
346function SidebarControl({ showSidebar }: { readonly showSidebar: boolean }) {
347 const router = useRouter();
348 const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" });
349 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
350 const { toggleSidebar } = useSidebar();
351 const mobileSidebarSheetMounted = useMobileSidebarSheetMounted();
352 const isSidebarVisible = useSidebarVisibility();
353 const environmentIdentificationMode = useEnvironmentIdentificationMode();
354 const stageBackdropVariant = useSidebarStageBackdropVariant(environmentIdentificationMode);
355 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", {
356 context: { usagePageOpen },
357 });
358 const onStageBackdrop = isSidebarVisible && stageBackdropVariant !== null;
359 const overviewPhase = useOverviewPhase();
360 const overviewShown = overviewPhase === "opening" || overviewPhase === "open";
361
362 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window keydown listener that toggles the sidebar on its shortcut
363 useEffect(() => {
364 // A hidden sidebar would still save the toggle, so the shortcut does nothing without one.
365 if (!showSidebar) return;
366 const toggleOutsideOverview = () => {
367 if (!useLayoutStore.getState().overviewOpen) toggleSidebar();
368 };
369 const onKeyDown = (event: KeyboardEvent) => {
370 if (event.defaultPrevented) return;
371 if (
372 event.target instanceof HTMLElement &&
373 event.target.closest("[data-keybinding-capture]")
374 ) {
375 return;
376 }
377 const composer =
378 event.target instanceof HTMLElement
379 ? event.target.closest('[data-composer-rich-text="true"]')
380 : null;
381 const selection = window.getSelection();
382 if (
383 isRichTextBoldShortcut(event, selection) &&
384 composer &&
385 selection &&
386 composer.contains(selection.anchorNode)
387 ) {
388 // The rich-text composer claims Mod+B to bold text selected in it;
389 // with just a caret, and everywhere else, it toggles the sidebar.
390 return;
391 }
392 if (
393 resolveShortcutCommand(event, keybindings, { context: { usagePageOpen } }) !==
394 "sidebar.toggle"
395 )
396 return;
397
398 event.preventDefault();
399 event.stopPropagation();
400 toggleOutsideOverview();
401 };
402
403 window.addEventListener("keydown", onKeyDown, true);
404 const unsubscribe = window.desktopBridge?.onMenuAction((action) => {
405 if (action === "sidebar.toggle") toggleOutsideOverview();
406 });
407 return () => {
408 window.removeEventListener("keydown", onKeyDown, true);
409 unsubscribe?.();
410 };
411 }, [keybindings, showSidebar, toggleSidebar, usagePageOpen]);
412
413 // The control lives as long as the shell, so it carries the shell's commands. Created
414 // once so re-renders never re-register them.
415 const [appShellCommandsRef] = useState(
416 () => (node: HTMLDivElement | null) =>
417 node === null ? undefined : registerAppShellCommands(router, node),
418 );
419
420 return (
421 <div
422 ref={appShellCommandsRef}
423 className={cn(
424 "pointer-events-none fixed left-[var(--workspace-controls-left)] top-[var(--workspace-controls-top)] z-50 ml-px flex h-[var(--workspace-topbar-height)] items-center",
425 mobileSidebarSheetMounted && "hidden",
426 )}
427 // Sits on the window chrome, so it takes the black chrome tokens too.
428 data-chrome-scope=""
429 data-sidebar-control=""
430 >
431 {showSidebar ? (
432 <div
433 className="flex"
434 inert={overviewShown}
435 style={{
436 ...overviewZoomTransition(),
437 transitionProperty: "opacity",
438 ...(overviewShown && { opacity: 0 }),
439 }}
440 >
441 <Tooltip>
442 <TooltipTrigger
443 render={
444 <SidebarTrigger
445 variant={isSidebarVisible && stageBackdropVariant ? "media-navigation" : "ghost"}
446 className={cn(
447 "pointer-events-auto",
448 isSidebarVisible && stageBackdropVariant && "relative top-auto translate-y-0",
449 )}
450 aria-label="Toggle main sidebar"
451 />
452 }
453 />
454 <TooltipPopup side="bottom">
455 Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}
456 </TooltipPopup>
457 </Tooltip>
458 </div>
459 ) : null}
460 {/* Phones keep the header for the page; the system back gesture covers them. */}
461 <div className="pointer-events-auto hidden items-center [-webkit-app-region:no-drag] md:flex">
462 <NavigationHistoryButton direction="back" onStageBackdrop={onStageBackdrop} />
463 <NavigationHistoryButton direction="forward" onStageBackdrop={onStageBackdrop} />
464 </div>
465 </div>
466 );
467}
468
469const NAVIGATION_HISTORY_BUTTONS = {
470 back: { label: "Back", command: "navigation.back", Icon: ArrowLeftIcon },
471 forward: { label: "Forward", command: "navigation.forward", Icon: ArrowRightIcon },
472} as const;
473
474// Next to the sidebar toggle on the chrome row (pen fiPPG, iZJUf). On the web
475// they share the tab's history with the browser's own buttons, but stop at the
476// app's first and last entries, so they never leave the app.
477function NavigationHistoryButton({
478 direction,
479 onStageBackdrop,
480}: {
481 direction: HistoryDirection;
482 onStageBackdrop: boolean;
483}) {
484 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
485 const canNavigate = useCanNavigateHistory(direction);
486 const { label, command, Icon } = NAVIGATION_HISTORY_BUTTONS[direction];
487 const shortcutLabel = shortcutLabelForCommand(keybindings, command);
488
489 return (
490 <Tooltip>
491 <TooltipTrigger
492 render={
493 <Button
494 // The trigger beside it switches to the media variant over the stage artwork.
495 variant={onStageBackdrop ? "media-navigation" : "ghost"}
496 size="icon-sm"
497 className={cn(onStageBackdrop && "relative top-auto translate-y-0")}
498 aria-label={label}
499 disabled={!canNavigate}
500 onClick={() => navigateHistory(direction)}
501 />
502 }
503 >
504 {/* The pen strokes these 16px glyphs at 1.5px: 2.25 on lucide's 24px grid. */}
505 <Icon className="size-4 text-sidebar-toggle" strokeWidth={2.25} />
506 </TooltipTrigger>
507 <TooltipPopup side="bottom">
508 {label}
509 {shortcutLabel ? ` (${shortcutLabel})` : ""}
510 </TooltipPopup>
511 </Tooltip>
512 );
513}
514
515// Dev only, and loaded on demand so production bundles never carry it.
516const DesignSystemSidebarNav = import.meta.env.DEV
517 ? lazy(() =>
518 import("./design-system/DesignSystemSidebarNav").then((module) => ({
519 default: module.DesignSystemSidebarNav,
520 })),
521 )
522 : null;
523
524export function AppSidebarLayout({
525 children,
526 showSidebar = true,
527}: {
528 children: ReactNode;
529 showSidebar?: boolean;
530}) {
531 const sidebarArea = workspaceAreaProps("sidebar", 0, "Threads");
532 const [sidebarRouteReveal] = useState<SidebarRouteReveal>(() => ({
533 observedKey: null,
534 pendingKey: null,
535 }));
536 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
537 usePanelAnimationSettings();
538 const pathname = useLocation({ select: (location) => location.pathname });
539 const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname);
540 // Each chat suppresses its own panels (SurfaceHost). React propagates a context change past
541 // nested providers, so a flip here on a chat switch would re-render every mounted chat twice.
542 const onChatRoute = useParams({
543 strict: false,
544 select: (params) => resolveThreadRouteTarget(params) !== null,
545 });
546 const pagePanelAnimationsSuppressed = usePanelNavigationSuppression(
547 onChatRoute ? "chat" : pathname,
548 );
549 const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed;
550 const isOnDesignSystem =
551 DesignSystemSidebarNav !== null &&
552 (pathname === "/design-system" || pathname.startsWith("/design-system/"));
553 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
554 const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
555 // A clamped drag may not change width, so viewport changes need a separate subscription.
556 const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth);
557 const [brandWidth, setBrandWidth] = useState(0);
558 const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth);
559 const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth);
560 const resetSidebarWidth = () => {
561 try {
562 removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY);
563 } catch (error) {
564 console.error("Could not clear persisted thread sidebar width.", error);
565 }
566 setSidebarWidth(resolveInitialThreadSidebarWidth(null, viewportWidth));
567 };
568 const [sidebarOpenStore] = useState(() =>
569 createSidebarOpenStore(true, "chat_thread_sidebar_open"),
570 );
571 const { open: isSidebarOpen } = useSyncExternalStore(
572 sidebarOpenStore.subscribe,
573 sidebarOpenStore.getSnapshot,
574 sidebarOpenStore.getSnapshot,
575 );
576 const isMobileLayout = useMediaQuery({
577 max: "md",
578 });
579 const environmentIdentificationMode = useEnvironmentIdentificationMode();
580 const chromeArtVariant = useSidebarStageBackdropVariant(environmentIdentificationMode);
581 const overviewPhase = useOverviewPhase();
582 const overviewStaged = overviewPhase !== "closed" && !isMobileLayout;
583 const overviewShown = overviewPhase === "opening" || overviewPhase === "open";
584 const overviewTransition = overviewStaged ? overviewZoomTransition() : {};
585 const hideWindowControlsWhenSidebarCollapsed = useHideWindowControlsWhenSidebarCollapsed();
586 const isWindowFullscreen = useSyncExternalStore(
587 subscribeToWindowFullscreen,
588 readWindowFullscreen,
589 );
590 // Fullscreen owns the traffic lights, and the mobile sheet leaves the chrome row alone.
591 const windowControlsHidden =
592 isMacosDesktop &&
593 !isWindowFullscreen &&
594 !isMobileLayout &&
595 !isSidebarOpen &&
596 hideWindowControlsWhenSidebarCollapsed;
597 const windowControlsInsetStyle = (
598 isMacosDesktop && !isWindowFullscreen && !windowControlsHidden
599 ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
600 : {}
601 ) as CSSProperties;
602 // A fullscreen window has square corners, so the pane keeps its own radius.
603 const windowCornerStyle = (
604 isWindowFullscreen
605 ? { "--desktop-window-corner-radius": "initial", "--desktop-window-corner-shape": "initial" }
606 : {}
607 ) as CSSProperties;
608 const sidebarProviderStyle = {
609 "--overview-sidebar-duration": overviewTransition.transitionDuration,
610 "--overview-sidebar-easing": overviewTransition.transitionTimingFunction,
611 "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`,
612 "--panel-animation-duration": `${panelAnimationDurationMs}ms`,
613 ...windowControlsInsetStyle,
614 ...windowCornerStyle,
615 } as CSSProperties;
616
617 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps native traffic-light visibility in step with the committed shell layout and restores it on unmount
618 useEffect(() => {
619 const setWindowButtonsVisible = window.desktopBridge?.setWindowButtonsVisible;
620 if (!isMacosDesktop || typeof setWindowButtonsVisible !== "function") return;
621 void setWindowButtonsVisible(!windowControlsHidden);
622 if (!windowControlsHidden) return;
623 return () => {
624 void setWindowButtonsVisible(true);
625 };
626 }, [isMacosDesktop, windowControlsHidden]);
627
628 return (
629 <PanelAnimationSuppressionProvider value={pagePanelAnimationsSuppressed}>
630 <div
631 className={cn(
632 "contents",
633 "md:[&_[data-app-sidebar][data-overview-zoom]:not([data-overview-zoom=open])]:bg-sidebar",
634 "md:[&_[data-app-sidebar][data-overview-zoom]>[data-slot=sidebar-inner]]:transition-opacity md:[&_[data-app-sidebar][data-overview-zoom]>[data-slot=sidebar-inner]]:duration-(--overview-sidebar-duration) md:[&_[data-app-sidebar][data-overview-zoom]>[data-slot=sidebar-inner]]:ease-(--overview-sidebar-easing)",
635 "md:[&_[data-app-sidebar][data-overview-zoom=opening]>[data-slot=sidebar-inner]]:opacity-0 md:[&_[data-app-sidebar][data-overview-zoom=open]>[data-slot=sidebar-inner]]:opacity-0",
636 )}
637 >
638 <SidebarProvider
639 className="h-dvh! min-h-0!"
640 data-chrome-art={chromeArtVariant ?? undefined}
641 data-panel-animations={routePanelAnimationsActive ? "true" : "false"}
642 store={sidebarOpenStore}
643 style={sidebarProviderStyle}
644 >
645 {showSidebar ? (
646 <>
647 <SidebarBrandWidthProbe isElectron={isElectron} onWidthChange={setBrandWidth} />
648 {chromeArtVariant ? <ChromeChannelArt variant={chromeArtVariant} /> : null}
649 <Sidebar
650 side="left"
651 collapsible="offcanvas"
652 variant="inset"
653 data-app-sidebar=""
654 {...sidebarArea}
655 role="navigation"
656 aria-label={isOnDesignSystem ? "Design system" : "Threads"}
657 // Light, black chrome paints the sidebar with the dark tokens (index.css).
658 data-chrome-scope=""
659 resizable={{
660 maxWidth: sidebarMaximumWidth,
661 minWidth: sidebarMinimumWidth,
662 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
663 nextWidth <= currentWidth ||
664 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
665 storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
666 onResize: setSidebarWidth,
667 }}
668 className={cn(overviewStaged && "z-35")}
669 data-overview-zoom={overviewStaged ? overviewPhase : undefined}
670 inert={overviewStaged && overviewShown}
671 style={
672 overviewStaged
673 ? {
674 ...windowControlsInsetStyle,
675 ...overviewTransition,
676 }
677 : windowControlsInsetStyle
678 }
679 >
680 {isOnDesignSystem && DesignSystemSidebarNav ? (
681 <>
682 <SidebarChromeHeader isElectron={isElectron} />
683 <Suspense fallback={null}>
684 <DesignSystemSidebarNav />
685 </Suspense>
686 </>
687 ) : (
688 <ThreadSidebar routeReveal={sidebarRouteReveal} />
689 )}
690 <SidebarRail onDoubleClick={resetSidebarWidth} />
691 </Sidebar>
692 </>
693 ) : null}
694 <WorkspacePlane>{children}</WorkspacePlane>
695 <BackgroundThreadAnnouncer />
696 {isElectron ? (
697 // The chrome strip above the inset pane moves the window like the
698 // header rows on either side of it.
699 <div
700 aria-hidden
701 className="drag-region fixed inset-x-0 top-0 hidden h-(--workspace-pane-inset-top) md:block"
702 />
703 ) : null}
704 <SidebarControl showSidebar={showSidebar} />
705 {isElectron ? <HistorySwipeNavigation /> : null}
706 </SidebarProvider>
707 </div>
708 </PanelAnimationSuppressionProvider>
709 );
710}