Skip to content
Source
Sign inSign up

AppSidebarLayout.tsx

498 lines18.2 KB

1import { useAtomValue } from "@effect/atom-react";
2import * as Schema from "effect/Schema";
3import {
4 useEffect,
5 useState,
6 useSyncExternalStore,
7 type CSSProperties,
8 type ReactNode,
9} from "react";
10import { useLocation, useRouter, type AnyRouter } from "@tanstack/react-router";
11​
12import { moveWorkspaceFocus, workspaceAreaProps } from "../lib/workspaceFocus";
13import { isCommandPaletteOpen } from "../commandPaletteBus";
14import { isElectron } from "../env";
15import {
16 releaseThreadUndoShortcut,
17 threadUndoClaimsShortcut,
18 undoLatestThreadAction,
19} from "../hooks/showThreadUndoNotice";
20import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage";
21import {
22 isRichTextBoldShortcut,
23 resolveShortcutCommand,
24 shortcutLabelForCommand,
25} from "../keybindings";
26import { isEditableFocused } from "../lib/editableFocus";
27import { isPreviewFocused } from "../lib/previewFocus";
28import { isTerminalFocused } from "../lib/terminalFocus";
29import { isModelPickerOpen } from "../modelPickerVisibility";
30import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
31import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
32import { cn, isMacPlatform } from "../lib/utils";
33import { primaryServerKeybindingsAtom } from "../state/server";
34import { useEnvironmentIdentificationMode } from "../hooks/useSettings";
35import {
36 PanelAnimationSuppressionProvider,
37 usePanelAnimationSettings,
38 usePanelNavigationSuppression,
39} from "../panelAnimations";
40import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer";
41import { HistorySwipeNavigation } from "./HistorySwipeNavigation";
42import ThreadSidebar from "./Sidebar";
43import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome";
44import { isAppModalPage } from "./sidebar/mainAppLocation";
45import type { SidebarRouteReveal } from "./sidebar/routeReveal";
46import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
47import {
48 clampThreadSidebarWidth,
49 resolveInitialThreadSidebarWidth,
50 resolveThreadSidebarMaximumWidth,
51 resolveThreadSidebarMinimumWidth,
52 THREAD_MAIN_CONTENT_MIN_WIDTH,
53 THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
54} from "./threadSidebarWidth";
55import {
56 Sidebar,
57 SidebarProvider,
58 SidebarRail,
59 SidebarTrigger,
60 useMobileSidebarSheetMounted,
61 useSidebar,
62 useSidebarVisibility,
63} from "./ui/sidebar";
64import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
65import { appAtomRegistry } from "../rpc/atomRegistry";
66import { registerCommands, registerMenuAction } from "../shortcuts/dispatcher";
67import { resolveThreadRouteRef } from "../threadRoutes";
68​
69const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)";
70​
71function subscribeToViewportWidth(onChange: () => void): () => void {
72 window.addEventListener("resize", onChange);
73 return () => window.removeEventListener("resize", onChange);
75​
76function readViewportWidth(): number {
77 return window.innerWidth;
79​
80// Cache the synchronous fullscreen IPC read until subscription.
81let windowFullscreenSnapshot: boolean | null = null;
82​
83function macosDesktopBridge() {
84 if (!isElectron || !isMacPlatform(navigator.platform)) return null;
85 return window.desktopBridge ?? null;
87​
88function subscribeToWindowFullscreen(onChange: () => void): () => void {
89 const bridge = macosDesktopBridge();
90 if (
91 typeof bridge?.getWindowFullscreenState !== "function" ||
92 typeof bridge.onWindowFullscreenStateChange !== "function"
93 ) {
94 return () => {};
95 }
96 const unsubscribe = bridge.onWindowFullscreenStateChange((fullscreen) => {
97 windowFullscreenSnapshot = fullscreen;
98 onChange();
99 });
100 // The window may have changed state between the first render and this subscription.
101 windowFullscreenSnapshot = bridge.getWindowFullscreenState();
102 return () => {
103 unsubscribe();
104 windowFullscreenSnapshot = null;
105 };
107​
108function readWindowFullscreen(): boolean {
109 if (windowFullscreenSnapshot === null) {
110 const getWindowFullscreenState = macosDesktopBridge()?.getWindowFullscreenState;
111 windowFullscreenSnapshot =
112 typeof getWindowFullscreenState === "function" ? getWindowFullscreenState() : false;
113 }
114 return windowFullscreenSnapshot;
116​
117function readInitialThreadSidebarWidth(): number {
118 try {
119 return resolveInitialThreadSidebarWidth(
120 getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite),
121 window.innerWidth,
122 );
123 } catch (error) {
124 console.error("Could not read persisted thread sidebar width.", error);
125 return resolveInitialThreadSidebarWidth(null, window.innerWidth);
126 }
128​
129function readUsagePageOpen(router: AnyRouter) {
130 return router.state.location.pathname === "/usage";
132​
133function isKeybindingCaptureTarget(event: KeyboardEvent) {
134 return event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]");
136​
137function readRoutePanelsOpen(router: AnyRouter) {
138 const routeThreadRef = resolveThreadRouteRef(router.state.matches.at(-1)?.params ?? {});
139 if (!routeThreadRef) return { terminalOpen: false, previewOpen: false };
140 return {
141 terminalOpen: selectThreadTerminalUiState(
142 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
143 routeThreadRef,
144 ).terminalOpen,
145 previewOpen:
146 selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) ===
147 "preview",
148 };
150​
151export function registerNavigationHistoryCommands(router: AnyRouter): () => void {
152 return registerCommands(
153 {
154 "navigation.back": (event) => {
155 event.preventDefault();
156 event.stopPropagation();
157 window.history.back();
158 },
159 "navigation.forward": (event) => {
160 event.preventDefault();
161 event.stopPropagation();
162 window.history.forward();
163 },
164 },
165 {
166 when: (event) => !isKeybindingCaptureTarget(event),
167 context: () => ({
168 ...readRoutePanelsOpen(router),
169 usagePageOpen: readUsagePageOpen(router),
170 }),
171 },
172 );
174​
175// Capture phase, so F6 also leaves widgets that swallow keys, such as the terminal.
176function registerWorkspaceFocusCommands(router: AnyRouter): () => void {
177 const move = (direction: 1 | -1) => (event: KeyboardEvent) => {
178 if (event.isComposing) return false;
179 // Inside a dialog the move is refused, but the key is still consumed.
180 event.preventDefault();
181 event.stopPropagation();
182 moveWorkspaceFocus(direction);
183 };
184 const disposers = [
185 registerCommands(
186 { "workspace.focusNext": move(1), "workspace.focusPrevious": move(-1) },
187 {
188 capture: true,
189 when: (event) => !isKeybindingCaptureTarget(event),
190 context: () => readRoutePanelsOpen(router),
191 },
192 ),
193 // A focused desktop browser forwards the keys; its renderer focus is stale.
194 registerMenuAction("workspace.focusNext", () => moveWorkspaceFocus(1, "panel")),
195 registerMenuAction("workspace.focusPrevious", () => moveWorkspaceFocus(-1, "panel")),
196 ];
197 return () => {
198 for (const dispose of disposers) dispose();
199 };
201​
202// Escape leaves the pull requests page when no scope (a selection, a dialog,
203// a drag) claimed it, so it observes after every other scope.
204function registerPullRequestsEscapeCommand(router: AnyRouter): () => void {
205 return registerCommands(
206 {},
207 {
208 observe: (event, consumed) => {
209 if (consumed || event.repeat || event.isComposing || event.key !== "Escape") return;
210 if (router.state.location.pathname !== "/pull-requests") return;
211 event.preventDefault();
212 const activeElement = document.activeElement;
213 if (activeElement instanceof HTMLElement) activeElement.blur();
214 if (router.state.location.state.__TSR_index !== 0) {
215 window.history.back();
216 return;
217 }
218 void router.navigate({ to: "/" });
219 },
220 },
221 );
223​
224const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]);
225​
226// A held key undoes once. Module state: undoing an archive navigates, and the
227// hold must survive whatever that re-renders.
228let heldUndoKey: string | null = null;
229​
230// Undoes the thread actions in the sidebar's notice. Settings and Usage own
231// mod+z while open. Captured so mod+z reaches it before the composer's own
232// undo handler consumes the key.
233function registerThreadUndoCommands(
234 router: AnyRouter,
235 readSidebarVisible: () => boolean,
236): () => void {
237 const threadSidebarShown = () => !isAppModalPage(router.state.location.pathname);
238 const unregister = registerCommands(
239 {},
240 {
241 capture: true,
242 when: threadSidebarShown,
243 onKeyDown: (event) => {
244 // Cancel a held key's repeats so they cannot fall through to the
245 // editor's own undo, even after the last notice is gone.
246 if (event.repeat) {
247 if (event.code !== heldUndoKey) return false;
248 event.preventDefault();
249 event.stopPropagation();
250 return true;
251 }
252 // A modifier tapped mid-hold leaves the held key repeating.
253 if (!MODIFIER_KEYS.has(event.key)) heldUndoKey = null;
254 const command = resolveShortcutCommand(
255 event,
256 appAtomRegistry.get(primaryServerKeybindingsAtom),
257 {
258 context: {
259 terminalFocus: isTerminalFocused(),
260 previewFocus: isPreviewFocused(),
261 ...readRoutePanelsOpen(router),
262 // A collapsed sidebar hides the notice, so text fields keep their own undo.
263 editableFocus:
264 isEditableFocused(event.target) &&
265 !(readSidebarVisible() && threadUndoClaimsShortcut()),
266 modelPickerOpen: isModelPickerOpen(),
267 },
268 },
269 );
270 if (command !== "thread.undo") {
271 if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut();
272 return false;
273 }
274 if (isCommandPaletteOpen() || isModelPickerOpen()) return false;
275 if (!undoLatestThreadAction()) return false;
276 heldUndoKey = event.code;
277 event.preventDefault();
278 event.stopPropagation();
279 return true;
280 },
281 },
282 );
283 // Pastes from a menu, dictation and drops edit text without a keypress.
284 const onInput = () => {
285 if (threadSidebarShown()) releaseThreadUndoShortcut();
286 };
287 window.addEventListener("input", onInput, true);
288 return () => {
289 unregister();
290 window.removeEventListener("input", onInput, true);
291 };
293​
294/** Keyboard and menu commands that live as long as the app shell. */
295function registerAppShellCommands(router: AnyRouter, shellElement: Element): () => void {
296 // The provider mirrors the visibility it renders onto its wrapper, so the
297 // committed state is readable when a key arrives.
298 const readSidebarVisible = () =>
299 shellElement.closest('[data-slot="sidebar-wrapper"]')?.getAttribute("data-sidebar-state") ===
300 "expanded";
301 const disposers = [
302 registerThreadUndoCommands(router, readSidebarVisible),
303 registerNavigationHistoryCommands(router),
304 registerWorkspaceFocusCommands(router),
305 registerPullRequestsEscapeCommand(router),
306 registerMenuAction("open-settings", () => {
307 if (!/^\/settings(\/|$)/.test(router.state.location.pathname))
308 void router.navigate({ to: "/settings" });
309 }),
310 ];
311 return () => {
312 for (const dispose of disposers) dispose();
313 };
315​
316function SidebarControl({ showSidebar }: { readonly showSidebar: boolean }) {
317 const router = useRouter();
318 const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" });
319 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
320 const { toggleSidebar } = useSidebar();
321 const mobileSidebarSheetMounted = useMobileSidebarSheetMounted();
322 const isSidebarVisible = useSidebarVisibility();
323 const environmentIdentificationMode = useEnvironmentIdentificationMode();
324 const stageBackdropVariant = useSidebarStageBackdropVariant(environmentIdentificationMode);
325 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", {
326 context: { usagePageOpen },
327 });
328​
329 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window keydown listener that toggles the sidebar on its shortcut
330 useEffect(() => {
331 // A hidden sidebar would still save the toggle, so the shortcut does nothing without one.
332 if (!showSidebar) return;
333 const onKeyDown = (event: KeyboardEvent) => {
334 if (event.defaultPrevented) return;
335 if (
336 event.target instanceof HTMLElement &&
337 event.target.closest("[data-keybinding-capture]")
338 ) {
339 return;
340 }
341 if (
342 isRichTextBoldShortcut(event, window.getSelection()) &&
343 event.target instanceof HTMLElement &&
344 event.target.closest('[data-composer-rich-text="true"]')
345 ) {
346 // The rich-text composer claims Mod+B only to bold selected text;
347 // the toggle stays available everywhere else.
348 return;
349 }
350 if (
351 resolveShortcutCommand(event, keybindings, { context: { usagePageOpen } }) !==
352 "sidebar.toggle"
353 )
354 return;
355​
356 event.preventDefault();
357 event.stopPropagation();
358 toggleSidebar();
359 };
360​
361 window.addEventListener("keydown", onKeyDown, true);
362 const unsubscribe = window.desktopBridge?.onMenuAction((action) => {
363 if (action === "sidebar.toggle") toggleSidebar();
364 });
365 return () => {
366 window.removeEventListener("keydown", onKeyDown, true);
367 unsubscribe?.();
368 };
369 }, [keybindings, showSidebar, toggleSidebar, usagePageOpen]);
370​
371 // The control lives as long as the shell, so it carries the shell's commands. Created
372 // once so re-renders never re-register them.
373 const [appShellCommandsRef] = useState(
374 () => (node: HTMLDivElement | null) =>
375 node === null ? undefined : registerAppShellCommands(router, node),
376 );
377​
378 return (
379 <div
380 ref={appShellCommandsRef}
381 className={cn(
382 "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",
383 mobileSidebarSheetMounted && "hidden",
384 )}
385 data-sidebar-control=""
386 >
387 {showSidebar ? (
388 <Tooltip>
389 <TooltipTrigger
390 render={
391 <SidebarTrigger
392 variant={isSidebarVisible && stageBackdropVariant ? "media-navigation" : "ghost"}
393 className={cn(
394 "pointer-events-auto",
395 isSidebarVisible && stageBackdropVariant && "relative top-auto translate-y-0",
396 )}
397 aria-label="Toggle main sidebar"
398 />
399 }
400 />
401 <TooltipPopup side="bottom">
402 Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}
403 </TooltipPopup>
404 </Tooltip>
405 ) : null}
406 </div>
407 );
409​
410export function AppSidebarLayout({
411 children,
412 showSidebar = true,
413}: {
414 children: ReactNode;
415 showSidebar?: boolean;
416}) {
417 const sidebarArea = workspaceAreaProps("sidebar", 0, "Threads");
418 const [sidebarRouteReveal] = useState<SidebarRouteReveal>(() => ({
419 observedKey: null,
420 pendingKey: null,
421 }));
422 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
423 usePanelAnimationSettings();
424 const pathname = useLocation({ select: (location) => location.pathname });
425 const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname);
426 const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed;
427 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
428 const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
429 // A clamped drag may not change width, so viewport changes need a separate subscription.
430 const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth);
431 const [brandWidth, setBrandWidth] = useState(0);
432 const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth);
433 const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth);
434 const resetSidebarWidth = () => {
435 try {
436 removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY);
437 } catch (error) {
438 console.error("Could not clear persisted thread sidebar width.", error);
439 }
440 setSidebarWidth(resolveInitialThreadSidebarWidth(null, viewportWidth));
441 };
442 const isWindowFullscreen = useSyncExternalStore(
443 subscribeToWindowFullscreen,
444 readWindowFullscreen,
445 );
446 const windowControlsInsetStyle = (
447 isMacosDesktop && !isWindowFullscreen
448 ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
449 : {}
450 ) as CSSProperties;
451 const sidebarProviderStyle = {
452 "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`,
453 "--panel-animation-duration": `${panelAnimationDurationMs}ms`,
454 ...windowControlsInsetStyle,
455 } as CSSProperties;
456​
457 return (
458 <PanelAnimationSuppressionProvider value={panelAnimationsSuppressed}>
459 <SidebarProvider
460 className="h-dvh! min-h-0!"
461 data-panel-animations={routePanelAnimationsActive ? "true" : "false"}
462 storageKey="chat_thread_sidebar_open"
463 style={sidebarProviderStyle}
464 >
465 {showSidebar ? (
466 <>
467 <SidebarBrandWidthProbe isElectron={isElectron} onWidthChange={setBrandWidth} />
468 <Sidebar
469 side="left"
470 collapsible="offcanvas"
471 data-app-sidebar=""
472 {...sidebarArea}
473 role="navigation"
474 aria-label="Threads"
475 resizable={{
476 maxWidth: sidebarMaximumWidth,
477 minWidth: sidebarMinimumWidth,
478 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
479 nextWidth <= currentWidth ||
480 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
481 storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
482 onResize: setSidebarWidth,
483 }}
484 style={windowControlsInsetStyle}
485 >
486 <ThreadSidebar routeReveal={sidebarRouteReveal} />
487 <SidebarRail onDoubleClick={resetSidebarWidth} />
488 </Sidebar>
489 </>
490 ) : null}
491 {children}
492 <BackgroundThreadAnnouncer />
493 <SidebarControl showSidebar={showSidebar} />
494 {isElectron ? <HistorySwipeNavigation /> : null}
495 </SidebarProvider>
496 </PanelAnimationSuppressionProvider>
497 );