import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useEffect, useState, useSyncExternalStore, type CSSProperties, type ReactNode, } from "react"; import { useLocation, useRouter, type AnyRouter } from "@tanstack/react-router"; import { moveWorkspaceFocus, workspaceAreaProps } from "../lib/workspaceFocus"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { isElectron } from "../env"; import { releaseThreadUndoShortcut, threadUndoClaimsShortcut, undoLatestThreadAction, } from "../hooks/showThreadUndoNotice"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; import { isRichTextBoldShortcut, resolveShortcutCommand, shortcutLabelForCommand, } from "../keybindings"; import { isEditableFocused } from "../lib/editableFocus"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useClientSettings, useEnvironmentIdentificationMode } from "../hooks/useSettings"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, usePanelNavigationSuppression, } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; import ThreadSidebar from "./Sidebar"; import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome"; import { isAppModalPage } from "./sidebar/mainAppLocation"; import type { SidebarRouteReveal } from "./sidebar/routeReveal"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { clampThreadSidebarWidth, resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, resolveThreadSidebarMinimumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, THREAD_SIDEBAR_WIDTH_STORAGE_KEY, } from "./threadSidebarWidth"; import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar, useSidebarVisibility, } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { registerCommands, registerMenuAction } from "../shortcuts/dispatcher"; import { resolveThreadRouteRef } from "../threadRoutes"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; function subscribeToViewportWidth(onChange: () => void): () => void { window.addEventListener("resize", onChange); return () => window.removeEventListener("resize", onChange); } function readViewportWidth(): number { return window.innerWidth; } // Cache the synchronous fullscreen IPC read until subscription. let windowFullscreenSnapshot: boolean | null = null; function macosDesktopBridge() { if (!isElectron || !isMacPlatform(navigator.platform)) return null; return window.desktopBridge ?? null; } function subscribeToWindowFullscreen(onChange: () => void): () => void { const bridge = macosDesktopBridge(); if ( typeof bridge?.getWindowFullscreenState !== "function" || typeof bridge.onWindowFullscreenStateChange !== "function" ) { return () => {}; } const unsubscribe = bridge.onWindowFullscreenStateChange((fullscreen) => { windowFullscreenSnapshot = fullscreen; onChange(); }); // The window may have changed state between the first render and this subscription. windowFullscreenSnapshot = bridge.getWindowFullscreenState(); return () => { unsubscribe(); windowFullscreenSnapshot = null; }; } function readWindowFullscreen(): boolean { if (windowFullscreenSnapshot === null) { const getWindowFullscreenState = macosDesktopBridge()?.getWindowFullscreenState; windowFullscreenSnapshot = typeof getWindowFullscreenState === "function" ? getWindowFullscreenState() : false; } return windowFullscreenSnapshot; } function readInitialThreadSidebarWidth(): number { try { return resolveInitialThreadSidebarWidth( getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite), window.innerWidth, ); } catch (error) { console.error("Could not read persisted thread sidebar width.", error); return resolveInitialThreadSidebarWidth(null, window.innerWidth); } } function readUsagePageOpen(router: AnyRouter) { return router.state.location.pathname === "/usage"; } function isKeybindingCaptureTarget(event: KeyboardEvent) { return event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]"); } function readRoutePanelsOpen(router: AnyRouter) { const routeThreadRef = resolveThreadRouteRef(router.state.matches.at(-1)?.params ?? {}); if (!routeThreadRef) return { terminalOpen: false, previewOpen: false }; return { terminalOpen: selectThreadTerminalUiState( useTerminalUiStateStore.getState().terminalUiStateByThreadKey, routeThreadRef, ).terminalOpen, previewOpen: selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === "preview", }; } export function registerNavigationHistoryCommands(router: AnyRouter): () => void { return registerCommands( { "navigation.back": (event) => { event.preventDefault(); event.stopPropagation(); window.history.back(); }, "navigation.forward": (event) => { event.preventDefault(); event.stopPropagation(); window.history.forward(); }, }, { when: (event) => !isKeybindingCaptureTarget(event), context: () => ({ ...readRoutePanelsOpen(router), usagePageOpen: readUsagePageOpen(router), }), }, ); } // Capture phase, so F6 also leaves widgets that swallow keys, such as the terminal. function registerWorkspaceFocusCommands(router: AnyRouter): () => void { const move = (direction: 1 | -1) => (event: KeyboardEvent) => { if (event.isComposing) return false; // Inside a dialog the move is refused, but the key is still consumed. event.preventDefault(); event.stopPropagation(); moveWorkspaceFocus(direction); }; const disposers = [ registerCommands( { "workspace.focusNext": move(1), "workspace.focusPrevious": move(-1) }, { capture: true, when: (event) => !isKeybindingCaptureTarget(event), context: () => readRoutePanelsOpen(router), }, ), // A focused desktop browser forwards the keys; its renderer focus is stale. registerMenuAction("workspace.focusNext", () => moveWorkspaceFocus(1, "panel")), registerMenuAction("workspace.focusPrevious", () => moveWorkspaceFocus(-1, "panel")), ]; return () => { for (const dispose of disposers) dispose(); }; } // Escape leaves the pull requests page when no scope (a selection, a dialog, // a drag) claimed it, so it observes after every other scope. function registerPullRequestsEscapeCommand(router: AnyRouter): () => void { return registerCommands( {}, { observe: (event, consumed) => { if (consumed || event.repeat || event.isComposing || event.key !== "Escape") return; if (router.state.location.pathname !== "/pull-requests") return; event.preventDefault(); const activeElement = document.activeElement; if (activeElement instanceof HTMLElement) activeElement.blur(); if (router.state.location.state.__TSR_index !== 0) { window.history.back(); return; } void router.navigate({ to: "/" }); }, }, ); } const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); // A held key undoes once. Module state: undoing an archive navigates, and the // hold must survive whatever that re-renders. let heldUndoKey: string | null = null; // Undoes the thread actions in the sidebar's notice. Settings and Usage own // mod+z while open. Captured so mod+z reaches it before the composer's own // undo handler consumes the key. function registerThreadUndoCommands( router: AnyRouter, readSidebarVisible: () => boolean, ): () => void { const threadSidebarShown = () => !isAppModalPage(router.state.location.pathname); const unregister = registerCommands( {}, { capture: true, when: threadSidebarShown, onKeyDown: (event) => { // Cancel a held key's repeats so they cannot fall through to the // editor's own undo, even after the last notice is gone. if (event.repeat) { if (event.code !== heldUndoKey) return false; event.preventDefault(); event.stopPropagation(); return true; } // A modifier tapped mid-hold leaves the held key repeating. if (!MODIFIER_KEYS.has(event.key)) heldUndoKey = null; const command = resolveShortcutCommand( event, appAtomRegistry.get(primaryServerKeybindingsAtom), { context: { terminalFocus: isTerminalFocused(), previewFocus: isPreviewFocused(), ...readRoutePanelsOpen(router), // A collapsed sidebar hides the notice, so text fields keep their own undo. editableFocus: isEditableFocused(event.target) && !(readSidebarVisible() && threadUndoClaimsShortcut()), modelPickerOpen: isModelPickerOpen(), }, }, ); if (command !== "thread.undo") { if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut(); return false; } if (isCommandPaletteOpen() || isModelPickerOpen()) return false; if (!undoLatestThreadAction()) return false; heldUndoKey = event.code; event.preventDefault(); event.stopPropagation(); return true; }, }, ); // Pastes from a menu, dictation and drops edit text without a keypress. const onInput = () => { if (threadSidebarShown()) releaseThreadUndoShortcut(); }; window.addEventListener("input", onInput, true); return () => { unregister(); window.removeEventListener("input", onInput, true); }; } /** Keyboard and menu commands that live as long as the app shell. */ function registerAppShellCommands(router: AnyRouter, shellElement: Element): () => void { // The provider mirrors the visibility it renders onto its wrapper, so the // committed state is readable when a key arrives. const readSidebarVisible = () => shellElement.closest('[data-slot="sidebar-wrapper"]')?.getAttribute("data-sidebar-state") === "expanded"; const disposers = [ registerThreadUndoCommands(router, readSidebarVisible), registerNavigationHistoryCommands(router), registerWorkspaceFocusCommands(router), registerPullRequestsEscapeCommand(router), registerMenuAction("open-settings", () => { if (!/^\/settings(\/|$)/.test(router.state.location.pathname)) void router.navigate({ to: "/settings" }); }), ]; return () => { for (const dispose of disposers) dispose(); }; } function SidebarControl() { const router = useRouter(); const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); const stageBackdropVariant = useSidebarStageBackdropVariant(environmentIdentificationMode); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { context: { usagePageOpen }, }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window keydown listener that toggles the sidebar on its shortcut useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; if ( event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]") ) { return; } if ( isRichTextBoldShortcut(event, window.getSelection()) && event.target instanceof HTMLElement && event.target.closest('[data-composer-rich-text="true"]') ) { // The rich-text composer claims Mod+B only to bold selected text; // the toggle stays available everywhere else. return; } if ( resolveShortcutCommand(event, keybindings, { context: { usagePageOpen } }) !== "sidebar.toggle" ) return; event.preventDefault(); event.stopPropagation(); toggleSidebar(); }; window.addEventListener("keydown", onKeyDown, true); const unsubscribe = window.desktopBridge?.onMenuAction((action) => { if (action === "sidebar.toggle") toggleSidebar(); }); return () => { window.removeEventListener("keydown", onKeyDown, true); unsubscribe?.(); }; }, [keybindings, toggleSidebar, usagePageOpen]); // The control lives as long as the shell, so it carries the shell's commands. Created // once so re-renders never re-register them. const [appShellCommandsRef] = useState( () => (node: HTMLDivElement | null) => node === null ? undefined : registerAppShellCommands(router, node), ); return (