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, useNavigate, useParams } from "@tanstack/react-router";
11
12import { isElectron } from "../env";
13import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage";
14import {
15 isRichTextBoldShortcut,
16 resolveShortcutCommand,
17 shortcutLabelForCommand,
18} from "../keybindings";
19import { isEditableFocused } from "../lib/editableFocus";
20import { isPreviewFocused } from "../lib/previewFocus";
21import { isTerminalFocused } from "../lib/terminalFocus";
22import { isModelPickerOpen } from "../modelPickerVisibility";
23import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
24import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
25import { resolveThreadRouteRef } from "../threadRoutes";
26import { cn, isMacPlatform } from "../lib/utils";
27import { primaryServerKeybindingsAtom } from "../state/server";
28import { useLegacySidebarEnabled } from "../hooks/useSettings";
29import {
30 PanelAnimationSuppressionProvider,
31 usePanelAnimationSettings,
32 usePanelNavigationSuppression,
33} from "../panelAnimations";
34import LegacyThreadSidebar from "./LegacySidebar";
35import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
36import ThreadSidebar from "./Sidebar";
37import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
38import { AppMenu } from "./sidebar/AppMenu";
39import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
40import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
41import { useProjects } from "../state/entities";
42import {
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";
50import {
51 Sidebar,
52 SidebarProvider,
53 SidebarRail,
54 SidebarTrigger,
55 useSidebar,
56} from "./ui/sidebar";
57import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
58
59const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)";
60
61function subscribeToViewportWidth(onChange: () => void): () => void {
62 window.addEventListener("resize", onChange);
63 return () => window.removeEventListener("resize", onChange);
64}
65
66function readViewportWidth(): number {
67 return window.innerWidth;
68}
69
70function 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
82function 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.
161function 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.
213function ProjectProjectionRetention() {
214 useProjects();
215 return null;
216}
217
218export 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}