| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import * as Schema from "effect/Schema"; |
| 3 | import { |
| 4 | useEffect, |
| 5 | useState, |
| 6 | useSyncExternalStore, |
| 7 | type CSSProperties, |
| 8 | type ReactNode, |
| 9 | } from "react"; |
| 10 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 11 | |
| 12 | import { isElectron } from "../env"; |
| 13 | import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; |
| 14 | import { |
| 15 | isRichTextBoldShortcut, |
| 16 | resolveShortcutCommand, |
| 17 | shortcutLabelForCommand, |
| 18 | } from "../keybindings"; |
| 19 | import { isEditableFocused } from "../lib/editableFocus"; |
| 20 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 21 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 22 | import { isModelPickerOpen } from "../modelPickerVisibility"; |
| 23 | import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; |
| 24 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 25 | import { resolveThreadRouteRef } from "../threadRoutes"; |
| 26 | import { cn, isMacPlatform } from "../lib/utils"; |
| 27 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 28 | import { useLegacySidebarEnabled } from "../hooks/useSettings"; |
| 29 | import { |
| 30 | PanelAnimationSuppressionProvider, |
| 31 | usePanelAnimationSettings, |
| 32 | usePanelNavigationSuppression, |
| 33 | } from "../panelAnimations"; |
| 34 | import LegacyThreadSidebar from "./LegacySidebar"; |
| 35 | import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; |
| 36 | import ThreadSidebar from "./Sidebar"; |
| 37 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| 38 | import { AppMenu } from "./sidebar/AppMenu"; |
| 39 | import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 40 | import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; |
| 41 | import { useProjects } from "../state/entities"; |
| 42 | import { |
| 43 | clampThreadSidebarWidth, |
| 44 | resolveInitialThreadSidebarWidth, |
| 45 | resolveThreadSidebarMaximumWidth, |
| 46 | THREAD_SIDEBAR_MIN_WIDTH, |
| 47 | THREAD_MAIN_CONTENT_MIN_WIDTH, |
| 48 | THREAD_SIDEBAR_WIDTH_STORAGE_KEY, |
| 49 | } from "./threadSidebarWidth"; |
| 50 | import { |
| 51 | Sidebar, |
| 52 | SidebarProvider, |
| 53 | SidebarRail, |
| 54 | SidebarTrigger, |
| 55 | useSidebar, |
| 56 | } from "./ui/sidebar"; |
| 57 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 58 | |
| 59 | const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; |
| 60 | |
| 61 | function subscribeToViewportWidth(onChange: () => void): () => void { |
| 62 | window.addEventListener("resize", onChange); |
| 63 | return () => window.removeEventListener("resize", onChange); |
| 64 | } |
| 65 | |
| 66 | function readViewportWidth(): number { |
| 67 | return window.innerWidth; |
| 68 | } |
| 69 | |
| 70 | function readInitialThreadSidebarWidth(): number { |
| 71 | try { |
| 72 | return resolveInitialThreadSidebarWidth( |
| 73 | getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite), |
| 74 | window.innerWidth, |
| 75 | ); |
| 76 | } catch (error) { |
| 77 | console.error("Could not read persisted thread sidebar width.", error); |
| 78 | return resolveInitialThreadSidebarWidth(null, window.innerWidth); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | function SidebarControl() { |
| 83 | const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); |
| 84 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 85 | const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar(); |
| 86 | const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { |
| 87 | context: { usagePageOpen }, |
| 88 | }); |
| 89 | |
| 90 | useEffect(() => { |
| 91 | const onKeyDown = (event: KeyboardEvent) => { |
| 92 | if (event.defaultPrevented) return; |
| 93 | if ( |
| 94 | event.target instanceof HTMLElement && |
| 95 | event.target.closest("[data-keybinding-capture]") |
| 96 | ) { |
| 97 | return; |
| 98 | } |
| 99 | if ( |
| 100 | isRichTextBoldShortcut(event) && |
| 101 | event.target instanceof HTMLElement && |
| 102 | event.target.closest('[data-composer-rich-text="true"]') |
| 103 | ) { |
| 104 | // The rich-text composer claims Mod+B for bold; the toggle stays |
| 105 | // available everywhere else, including the plain-text composer. |
| 106 | return; |
| 107 | } |
| 108 | if ( |
| 109 | resolveShortcutCommand(event, keybindings, { context: { usagePageOpen } }) !== |
| 110 | "sidebar.toggle" |
| 111 | ) |
| 112 | return; |
| 113 | |
| 114 | event.preventDefault(); |
| 115 | event.stopPropagation(); |
| 116 | toggleSidebar(); |
| 117 | }; |
| 118 | |
| 119 | // Capture before focused editors consume commands such as Mod+B for rich-text formatting. |
| 120 | window.addEventListener("keydown", onKeyDown, true); |
| 121 | // A focused desktop browser page forwards the chord as a menu action. |
| 122 | const unsubscribe = window.desktopBridge?.onMenuAction((action) => { |
| 123 | if (action === "sidebar.toggle") toggleSidebar(); |
| 124 | }); |
| 125 | return () => { |
| 126 | window.removeEventListener("keydown", onKeyDown, true); |
| 127 | unsubscribe?.(); |
| 128 | }; |
| 129 | }, [keybindings, toggleSidebar, usagePageOpen]); |
| 130 | |
| 131 | return ( |
| 132 | // The right-side layout controls carry mr-px (border compensation inside |
| 133 | // the panel), so the trigger mirrors it: both clusters sit one extra pixel |
| 134 | // off their edge and the titlebar reads symmetric. |
| 135 | // The sheet owns its trigger until its closing transition unmounts it. |
| 136 | <div |
| 137 | className={cn( |
| 138 | "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", |
| 139 | isMobile && mobileSheetMounted && "hidden", |
| 140 | )} |
| 141 | data-sidebar-control="" |
| 142 | > |
| 143 | <div className="pointer-events-auto flex items-center [-webkit-app-region:no-drag]"> |
| 144 | <AppMenu /> |
| 145 | </div> |
| 146 | <Tooltip> |
| 147 | <TooltipTrigger |
| 148 | render={ |
| 149 | <SidebarTrigger className="pointer-events-auto" aria-label="Toggle main sidebar" /> |
| 150 | } |
| 151 | /> |
| 152 | <TooltipPopup side="bottom"> |
| 153 | Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} |
| 154 | </TooltipPopup> |
| 155 | </Tooltip> |
| 156 | </div> |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | // Moves through the app's route history like a browser's back/forward buttons. |
| 161 | function NavigationHistoryShortcuts() { |
| 162 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 163 | const routeThreadRef = useParams({ |
| 164 | strict: false, |
| 165 | select: (params) => resolveThreadRouteRef(params), |
| 166 | }); |
| 167 | |
| 168 | useEffect(() => { |
| 169 | const onKeyDown = (event: KeyboardEvent) => { |
| 170 | if (event.defaultPrevented) return; |
| 171 | if ( |
| 172 | event.target instanceof HTMLElement && |
| 173 | event.target.closest("[data-keybinding-capture]") |
| 174 | ) { |
| 175 | return; |
| 176 | } |
| 177 | const command = resolveShortcutCommand(event, keybindings, { |
| 178 | context: { |
| 179 | terminalFocus: isTerminalFocused(), |
| 180 | terminalOpen: routeThreadRef |
| 181 | ? selectThreadTerminalUiState( |
| 182 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 183 | routeThreadRef, |
| 184 | ).terminalOpen |
| 185 | : false, |
| 186 | previewFocus: isPreviewFocused(), |
| 187 | previewOpen: routeThreadRef |
| 188 | ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === |
| 189 | "preview" |
| 190 | : false, |
| 191 | editableFocus: isEditableFocused(event.target), |
| 192 | modelPickerOpen: isModelPickerOpen(), |
| 193 | }, |
| 194 | }); |
| 195 | if (command !== "navigation.back" && command !== "navigation.forward") return; |
| 196 | |
| 197 | event.preventDefault(); |
| 198 | event.stopPropagation(); |
| 199 | if (command === "navigation.back") window.history.back(); |
| 200 | else window.history.forward(); |
| 201 | }; |
| 202 | |
| 203 | window.addEventListener("keydown", onKeyDown); |
| 204 | return () => window.removeEventListener("keydown", onKeyDown); |
| 205 | }, [keybindings, routeThreadRef]); |
| 206 | |
| 207 | return null; |
| 208 | } |
| 209 | |
| 210 | // Settings swaps the thread sidebar out of the tree. Keep the lightweight |
| 211 | // project projection subscribed so returning to a draft never renders the |
| 212 | // zero-project state while the environment snapshot reconnects. |
| 213 | function ProjectProjectionRetention() { |
| 214 | useProjects(); |
| 215 | return null; |
| 216 | } |
| 217 | |
| 218 | export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 219 | const navigate = useNavigate(); |
| 220 | const legacySidebarEnabled = useLegacySidebarEnabled(); |
| 221 | const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = |
| 222 | usePanelAnimationSettings(); |
| 223 | // Settings routes show the settings nav in place of whichever thread |
| 224 | // sidebar is active. |
| 225 | // Seeds server-side visited tracking from this browser's localStorage the |
| 226 | useThreadVisitedMigration(); |
| 227 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 228 | const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); |
| 229 | const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; |
| 230 | const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); |
| 231 | const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); |
| 232 | const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); |
| 233 | // Subscribed rather than read once: the clamp must track live window size, |
| 234 | // and a clamped drag ends with an unchanged width, which skips the re-render |
| 235 | // that would otherwise refresh a render-time snapshot. |
| 236 | const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); |
| 237 | const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); |
| 238 | const resetSidebarWidth = () => { |
| 239 | try { |
| 240 | removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); |
| 241 | } catch (error) { |
| 242 | console.error("Could not clear persisted thread sidebar width.", error); |
| 243 | } |
| 244 | setSidebarWidth(resolveInitialThreadSidebarWidth(null, viewportWidth)); |
| 245 | }; |
| 246 | const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { |
| 247 | const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; |
| 248 | return isMacosDesktop && typeof getWindowFullscreenState === "function" |
| 249 | ? getWindowFullscreenState() |
| 250 | : false; |
| 251 | }); |
| 252 | const windowControlsInsetStyle = ( |
| 253 | isMacosDesktop && !isWindowFullscreen |
| 254 | ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } |
| 255 | : {} |
| 256 | ) as CSSProperties; |
| 257 | const sidebarProviderStyle = { |
| 258 | "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMaximumWidth)}px`, |
| 259 | "--panel-animation-duration": `${panelAnimationDurationMs}ms`, |
| 260 | "--workspace-titlebar-content-left": |
| 261 | "calc(var(--workspace-controls-left) + 2 * var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap))", |
| 262 | ...windowControlsInsetStyle, |
| 263 | } as CSSProperties; |
| 264 | |
| 265 | useEffect(() => { |
| 266 | if (!isMacosDesktop) return; |
| 267 | const bridge = window.desktopBridge; |
| 268 | if (!bridge) return; |
| 269 | const { getWindowFullscreenState, onWindowFullscreenStateChange } = bridge; |
| 270 | if ( |
| 271 | typeof getWindowFullscreenState !== "function" || |
| 272 | typeof onWindowFullscreenStateChange !== "function" |
| 273 | ) { |
| 274 | return; |
| 275 | } |
| 276 | |
| 277 | const unsubscribe = onWindowFullscreenStateChange(setIsWindowFullscreen); |
| 278 | setIsWindowFullscreen(getWindowFullscreenState()); |
| 279 | return unsubscribe; |
| 280 | }, [isMacosDesktop]); |
| 281 | |
| 282 | useEffect(() => { |
| 283 | const onMenuAction = window.desktopBridge?.onMenuAction; |
| 284 | if (typeof onMenuAction !== "function") { |
| 285 | return; |
| 286 | } |
| 287 | |
| 288 | const unsubscribe = onMenuAction((action) => { |
| 289 | if (action === "open-settings") { |
| 290 | const isSettingsRoute = /^\/settings(\/|$)/.test(pathname); |
| 291 | if (!isSettingsRoute) { |
| 292 | void navigate({ to: "/settings" }); |
| 293 | } |
| 294 | } |
| 295 | }); |
| 296 | |
| 297 | return () => { |
| 298 | unsubscribe?.(); |
| 299 | }; |
| 300 | }, [navigate, pathname]); |
| 301 | |
| 302 | return ( |
| 303 | <PanelAnimationSuppressionProvider value={panelAnimationsSuppressed}> |
| 304 | <SidebarProvider |
| 305 | className="h-dvh! min-h-0!" |
| 306 | data-panel-animations={routePanelAnimationsActive ? "true" : "false"} |
| 307 | defaultOpen |
| 308 | style={sidebarProviderStyle} |
| 309 | > |
| 310 | <ProjectProjectionRetention /> |
| 311 | <Sidebar |
| 312 | side="left" |
| 313 | collapsible="offcanvas" |
| 314 | data-app-sidebar="" |
| 315 | role="navigation" |
| 316 | aria-label={isOnSettings ? "Settings" : "Threads"} |
| 317 | resizable={{ |
| 318 | maxWidth: sidebarMaximumWidth, |
| 319 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, |
| 320 | shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => |
| 321 | nextWidth <= currentWidth || |
| 322 | wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, |
| 323 | storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, |
| 324 | onResize: setSidebarWidth, |
| 325 | }} |
| 326 | // The narrow-window sidebar is a sheet, portaled outside the provider's styles. |
| 327 | style={windowControlsInsetStyle} |
| 328 | > |
| 329 | {isOnSettings ? ( |
| 330 | <> |
| 331 | <SidebarChromeHeader isElectron={isElectron} /> |
| 332 | <SettingsSidebarNav pathname={pathname} /> |
| 333 | </> |
| 334 | ) : legacySidebarEnabled ? ( |
| 335 | <LegacyThreadSidebar /> |
| 336 | ) : ( |
| 337 | <ThreadSidebar /> |
| 338 | )} |
| 339 | <SidebarRail aria-valuenow={sidebarWidth} onDoubleClick={resetSidebarWidth} /> |
| 340 | </Sidebar> |
| 341 | {children} |
| 342 | <SidebarControl /> |
| 343 | <NavigationHistoryShortcuts /> |
| 344 | <MainAppLocationTracker /> |
| 345 | </SidebarProvider> |
| 346 | </PanelAnimationSuppressionProvider> |
| 347 | ); |
| 348 | } |