1import { HostedServerAutoConnect } from "../components/settings/SelfHostedServer";
2import { usePersonalUiSettingsSync } from "../state/uiSettingsSync";
3import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice";
4import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
5import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
6import {
7 Outlet,
8 Link,
9 createRootRoute,
10 type ErrorComponentProps,
11 useLocation,
12 useNavigate,
13 useRouter,
14} from "@tanstack/react-router";
15import { Check, Copy } from "lucide";
16import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
17
18import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding";
19import { resolveServerBackedAppDisplayName } from "../branding.logic";
20import { AppSidebarLayout } from "../components/AppSidebarLayout";
21import { MediaPlaybackProvider } from "../components/media/MediaPlaybackHost";
22import { CommandPalette } from "../components/CommandPalette";
23import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog";
24import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
25import { KeybindingsConfigWarning } from "../components/KeybindingsConfigWarning";
26import { FirstRunGate } from "../components/onboarding/FirstRunGate";
27import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
28import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
29import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
30import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";
31import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
32import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator";
33import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";
34import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
35import { DesktopUpdateLaunchNotice } from "../components/sidebar/updateEverythingRun";
36import { AutomaticUiRefresh } from "../components/AutomaticUiRefresh";
37import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
38import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";
39import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
40import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut";
41import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
42import { PullRequestGuideRunCoordinator } from "../components/pullRequest/guide/PullRequestGuideRunCoordinator";
43import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
44import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator";
45import { ProviderAuthCallbackCoordinator } from "../components/settings/ProviderAuthCallbackCoordinator";
46import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
47import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
48import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme";
49import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme";
50import { Button } from "../components/ui/button";
51import { MorphIcon } from "~/components/MorphIcon";
52import { StandalonePage, StandalonePageHeader } from "../components/ui/standalone-page";
53import {
54 AnchoredToastProvider,
55 stackedThreadToast,
56 ToastProvider,
57 toastManager,
58} from "../components/ui/toast";
59import { isElectron } from "../env";
60import { cn } from "../lib/utils";
61import { applyAppearanceFontVariables } from "~/appearanceFonts";
62import { applyAppearanceContrast } from "~/appearanceContrast";
63import { useClientSettings } from "../hooks/useSettings";
64import {
65 deriveLogicalProjectKeyFromSettings,
66 derivePhysicalProjectKeyFromPath,
67 selectProjectGroupingSettings,
68} from "../logicalProject";
69import { useUiStateStore } from "../uiStateStore";
70import { syncBrowserChromeTheme } from "../hooks/useTheme";
71import { configureClientTracing } from "../observability/clientTracing";
72import { resolveInitialServerAuthGateState } from "../environments/primary";
73import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
74import { useHostedPromptLinkLanding } from "../components/promptLinks/HostedPromptLinkLanding";
75import { isLocalEnvironmentDisabled } from "../localEnvironment";
76import { useAtomValue } from "@effect/atom-react";
77import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
78import {
79 primaryServerConfigAtom,
80 primaryServerConfigEventAtom,
81 primaryServerWelcomeAtom,
82} from "../state/server";
83import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities";
84import {
85 createKeybindingsUpdateToastController,
86 type KeybindingsUpdateToastController,
87} from "../components/KeybindingsUpdateToast.logic";
88
89import { installDesktopPasteAsText } from "../lib/desktopPasteAsText";
90
91export const Route = createRootRoute({
92 beforeLoad: async ({ location }) => {
93 if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) {
94 return {
95 authGateState: {
96 status: "hosted-pairing",
97 } as const,
98 };
99 }
100
101 if (isLocalEnvironmentDisabled() || isHostedStaticApp(new URL(window.location.href))) {
102 return {
103 authGateState: {
104 status: "hosted-static",
105 } as const,
106 };
107 }
108
109 const authGateState = await resolveInitialServerAuthGateState();
110 return {
111 authGateState,
112 };
113 },
114 component: RootRouteView,
115 errorComponent: RootRouteErrorView,
116 notFoundComponent: RootRouteNotFoundView,
117 head: () => ({
118 meta: [{ name: "title", content: APP_DISPLAY_NAME }],
119 }),
120});
121
122function RootRouteNotFoundView() {
123 return (
124 <main className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-6">
125 <div className="flex max-w-sm flex-col items-center gap-4 text-center">
126 <h1 className="text-lg font-medium text-foreground">Page not found</h1>
127 <p className="text-sm text-muted-foreground">
128 This link doesn't point to a page in {APP_DISPLAY_NAME}. Go home to choose a project or
129 start a thread.
130 </p>
131 <Button render={<Link to="/" replace />}>Go home</Button>
132 </div>
133 </main>
134 );
135}
136
137function RootRouteView() {
138 usePersonalUiSettingsSync();
139 useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []);
140 const pathname = useLocation({ select: (location) => location.pathname });
141 const { authGateState } = Route.useRouteContext();
142 const primaryEnvironmentAuthenticated = authGateState.status === "authenticated";
143 const returningFromWelcomeRef = useRef(pathname === "/welcome");
144 const hostedPromptLinkLanding = useHostedPromptLinkLanding(
145 authGateState.status === "hosted-static",
146 );
147
148 useEffect(() => {
149 if (pathname === "/welcome") {
150 returningFromWelcomeRef.current = true;
151 }
152 }, [pathname]);
153
154 useEffect(() => {
155 const frame = window.requestAnimationFrame(() => {
156 syncBrowserChromeTheme();
157 });
158 return () => {
159 window.cancelAnimationFrame(frame);
160 };
161 }, [pathname]);
162
163 if (pathname === "/pair" || pathname === "/connect" || pathname === "/connect-agent") {
164 return (
165 <>
166 <DocumentTitleSync />
167 <Outlet />
168 </>
169 );
170 }
171
172 // Ahead of first-run onboarding, which would otherwise send a new visitor to the welcome
173 // wizard and lose the link.
174 if (hostedPromptLinkLanding !== null) {
175 return (
176 <>
177 <DocumentTitleSync />
178 {hostedPromptLinkLanding}
179 </>
180 );
181 }
182
183 // Show onboarding over the workspace, keeping automatic thread navigation
184 // and other startup dialogs suspended until setup finishes.
185 if (pathname === "/welcome") {
186 return (
187 <ToastProvider>
188 <AnchoredToastProvider>
189 <DocumentTitleSync />
190 <ContrastAppearanceSync />
191 <EnvironmentThemeSync />
192 <GlassAppearanceSync />
193 <FontAppearanceSync />
194 <ProviderAuthCallbackCoordinator />
195 <CustomSnoozeDialogHost />
196 <CommandPalette>
197 <AppSidebarLayout>
198 <Outlet />
199 </AppSidebarLayout>
200 </CommandPalette>
201 </AnchoredToastProvider>
202 </ToastProvider>
203 );
204 }
205
206 if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") {
207 return (
208 <>
209 <DocumentTitleSync />
210 <Outlet />
211 </>
212 );
213 }
214
215 const appShell = (
216 <MediaPlaybackProvider>
217 <CommandPalette>
218 <AppSidebarLayout>
219 <Outlet />
220 </AppSidebarLayout>
221 </CommandPalette>
222 </MediaPlaybackProvider>
223 );
224
225 // FirstRunGate holds back everything below it — including EventRouter,
226 // whose welcome payload navigates into a thread — until the first-run
227 // decision is known, so a fresh install renders nothing (not the shell,
228 // not a flash of threads) before landing on the welcome wizard.
229 return (
230 <ToastProvider>
231 <AnchoredToastProvider>
232 <DocumentTitleSync />
233 <ContrastAppearanceSync />
234 <EnvironmentThemeSync />
235 <GlassAppearanceSync />
236 <FontAppearanceSync />
237 <ProviderAuthCallbackCoordinator />
238 <ChatGptWelcomeCoordinator />
239 <HostedServerAutoConnect />
240 <FirstRunGate
241 enabled={primaryEnvironmentAuthenticated}
242 hostedStatic={authGateState.status === "hosted-static"}
243 >
244 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
245 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}
246 {primaryEnvironmentAuthenticated ? <DesktopWebLinkCoordinator /> : null}
247 {isElectron ? <RunningThreadKeepAlive /> : null}
248 <RelayClientInstallDialog />
249 <ConnectOnboardingDialog />
250 <SshPasswordPromptDialog />
251 <SnapShotCoordinator />
252 <ThreadNotificationCoordinator />
253 <ReopenClosedViewShortcut />
254 <ConfirmDialogHost />
255 <CustomSnoozeDialogHost />
256 <SlowRpcRequestToastCoordinator />
257 <PermissionUpdateNotice />
258 {primaryEnvironmentAuthenticated ? <LegacyThreadMigrationToast /> : null}
259 <ProjectCloneToastCoordinator />
260 <PullRequestGuideRunCoordinator />
261 <HostedStaticEnvironmentBootstrap />
262 {primaryEnvironmentAuthenticated ? (
263 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />
264 ) : null}
265 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
266 {isElectron ? <DesktopUpdateLaunchNotice /> : null}
267 {isElectron ? <AutomaticUiRefresh /> : null}
268 {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */}
269 <NightlyMobileBetaNotice />
270 {appShell}
271 {/* Above the router: a theme draft is judged by walking the app, so the
272 editor has to survive navigation away from settings. */}
273 <ThemeEditorHost />
274 </FirstRunGate>
275 </AnchoredToastProvider>
276 </ToastProvider>
277 );
278}
279
280/** Follows the palette the primary environment's machine publishes, if any. */
281function EnvironmentThemeSync() {
282 useEnvironmentThemeSync();
283 // Ordered after the palette sync so a first-run client adopting the
284 // environment's own theme finds it already in the library.
285 useDefaultThemeAdoption();
286 return null;
287}
288
289function ContrastAppearanceSync() {
290 const appearanceContrast = useClientSettings((settings) => settings.appearanceContrast);
291 const diffColorScheme = useClientSettings((settings) => settings.diffColorScheme);
292
293 useEffect(() => {
294 document.documentElement.dataset.diffColorScheme = diffColorScheme;
295 }, [diffColorScheme]);
296
297 const chatWidth = useClientSettings((settings) => settings.chatWidth);
298 useEffect(() => {
299 document.documentElement.dataset.chatWidth = chatWidth;
300 }, [chatWidth]);
301
302 useEffect(() => {
303 applyAppearanceContrast(document.documentElement, appearanceContrast);
304 }, [appearanceContrast]);
305
306 return null;
307}
308
309function GlassAppearanceSync() {
310 const glassOpacity = useClientSettings((settings) => settings.glassOpacity);
311
312 useEffect(() => {
313 const style = document.documentElement.style;
314 style.setProperty("--glass-opacity", `${glassOpacity}%`);
315 if (glassOpacity === 100) {
316 style.setProperty("--glass-blur", "0px");
317 } else {
318 style.removeProperty("--glass-blur");
319 }
320 }, [glassOpacity]);
321
322 return null;
323}
324
325function FontAppearanceSync() {
326 const fontFamilySans = useClientSettings((settings) => settings.fontFamilySans);
327 const fontFamilyCode = useClientSettings((settings) => settings.fontFamilyCode);
328 const fontFamilyComposer = useClientSettings((settings) => settings.fontFamilyComposer);
329 const fontSizeInterface = useClientSettings((settings) => settings.fontSizeInterface);
330 const fontSizePrompt = useClientSettings((settings) => settings.fontSizePrompt);
331 const fontSizeCode = useClientSettings((settings) => settings.fontSizeCode);
332 const fontSmoothing = useClientSettings((settings) => settings.fontSmoothing);
333
334 useEffect(() => {
335 applyAppearanceFontVariables(document.documentElement, {
336 sans: fontFamilySans,
337 code: fontFamilyCode,
338 composer: fontFamilyComposer,
339 sizeInterface: fontSizeInterface,
340 sizePrompt: fontSizePrompt,
341 sizeCode: fontSizeCode,
342 smoothing: fontSmoothing,
343 });
344 }, [
345 fontFamilyCode,
346 fontFamilyComposer,
347 fontFamilySans,
348 fontSizeCode,
349 fontSizeInterface,
350 fontSizePrompt,
351 fontSmoothing,
352 ]);
353
354 return null;
355}
356
357function DocumentTitleSync() {
358 const primaryServerVersion =
359 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
360 const title = resolveServerBackedAppDisplayName({
361 baseName: APP_BASE_NAME,
362 fallbackDisplayName: APP_DISPLAY_NAME,
363 fallbackStageLabel: APP_STAGE_LABEL,
364 primaryServerVersion,
365 });
366
367 useEffect(() => {
368 document.title = title;
369 }, [title]);
370
371 return null;
372}
373
374function HostedStaticEnvironmentBootstrap() {
375 const { environments } = useEnvironments();
376 const activeEnvironmentId = useActiveEnvironmentId();
377
378 useEffect(() => {
379 if (
380 environments.some(
381 (environment) => environment.entry.target._tag === "PrimaryConnectionTarget",
382 )
383 ) {
384 return;
385 }
386
387 if (activeEnvironmentId) {
388 return;
389 }
390
391 const firstSavedEnvironment = environments[0];
392 if (!firstSavedEnvironment) {
393 return;
394 }
395
396 setActiveEnvironmentId(firstSavedEnvironment.environmentId);
397 }, [activeEnvironmentId, environments]);
398
399 return null;
400}
401
402function RootRouteErrorView({ error }: ErrorComponentProps) {
403 const router = useRouter();
404 const message = errorMessage(error);
405 // Router pathname rather than window.location: desktop uses hash history, where the window path is always "/".
406 const pathname = useLocation({ select: (location) => location.pathname });
407 const report = useMemo(() => errorReport(error, pathname), [error, pathname]);
408
409 return (
410 <StandalonePage tone="error">
411 <StandalonePageHeader
412 eyebrow={APP_DISPLAY_NAME}
413 title="Something went wrong."
414 description={message}
415 />
416
417 <div className="mt-5 flex flex-wrap gap-2">
418 <Button size="sm" onClick={() => void router.invalidate()}>
419 Try again
420 </Button>
421 <Button size="sm" variant="outline" onClick={() => window.location.reload()}>
422 Reload app
423 </Button>
424 <CopyErrorButton report={report} />
425 </div>
426
427 <div className="mt-5 overflow-hidden rounded-lg border border-border/70 bg-background/55">
428 <p className="px-3 py-1.5 text-xs font-medium text-muted-foreground">Error report</p>
429 <pre className="max-h-64 overflow-auto border-t border-border/70 bg-background/80 px-3 py-2 text-xs whitespace-pre-wrap text-foreground/85">
430 {report}
431 </pre>
432 </div>
433 </StandalonePage>
434 );
435}
436
437/** Copies the full error report and swaps to a check mark for a moment as confirmation. */
438function CopyErrorButton({ report }: { report: string }) {
439 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-report" });
440
441 return (
442 <Button size="sm" variant="outline" onClick={() => copyToClipboard(report)}>
443 <MorphIcon className={cn(isCopied && "text-success")} icon={isCopied ? Check : Copy} />
444 {isCopied ? "Copied" : "Copy error"}
445 </Button>
446 );
447}
448
449function errorMessage(error: unknown): string {
450 if (error instanceof Error && error.message.trim().length > 0) {
451 return error.message;
452 }
453
454 if (typeof error === "string" && error.trim().length > 0) {
455 return error;
456 }
457
458 return "An unexpected router error occurred.";
459}
460
461function errorDetails(error: unknown): string {
462 if (error instanceof Error) {
463 return error.stack ?? error.message;
464 }
465
466 if (typeof error === "string") {
467 return error;
468 }
469
470 try {
471 return JSON.stringify(error, null, 2);
472 } catch {
473 return "No additional error details are available.";
474 }
475}
476
477const MAX_ERROR_CAUSE_DEPTH = 5;
478
479/**
480 * Full error text for bug reports: app build, page path, time, then the stack
481 * and any cause chain. Takes the pathname only so tokens in the query never
482 * land on the clipboard.
483 */
484function errorReport(error: unknown, pathname: string): string {
485 const lines = [
486 `${APP_DISPLAY_NAME} ${APP_VERSION}`,
487 `Path: ${pathname}`,
488 `Time: ${new Date().toISOString()}`,
489 "",
490 errorDetails(error),
491 ];
492 let cause = error instanceof Error ? error.cause : undefined;
493 for (let depth = 0; cause !== undefined && depth < MAX_ERROR_CAUSE_DEPTH; depth += 1) {
494 lines.push("", "Caused by:", errorDetails(cause));
495 cause = cause instanceof Error ? cause.cause : undefined;
496 }
497 return lines.join("\n");
498}
499
500function AuthenticatedTracingBootstrap() {
501 useEffect(() => {
502 void configureClientTracing();
503 }, []);
504
505 return null;
506}
507
508function EventRouter({
509 skipInitialBootstrapNavigation,
510}: {
511 readonly skipInitialBootstrapNavigation: boolean;
512}) {
513 const navigate = useNavigate();
514 const pathname = useLocation({ select: (loc) => loc.pathname });
515 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
516 const primaryEnvironment = usePrimaryEnvironment();
517 const serverConfig = useAtomValue(primaryServerConfigAtom);
518 const serverConfigEvent = useAtomValue(primaryServerConfigEventAtom);
519 const serverWelcome = useAtomValue(primaryServerWelcomeAtom);
520 const readPathname = useEffectEvent(() => pathname);
521 const handledBootstrapThreadIdRef = useRef<string | null>(null);
522 const skipInitialBootstrapNavigationRef = useRef(skipInitialBootstrapNavigation);
523 const handledConfigEventRef = useRef(serverConfigEvent);
524 const [keybindingsToastController] = useState<KeybindingsUpdateToastController>(() =>
525 createKeybindingsUpdateToastController({}),
526 );
527
528 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {
529 if (!payload) return;
530
531 setActiveEnvironmentId(payload.environment.environmentId);
532 void (async () => {
533 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
534 return;
535 }
536 const bootstrapProject = readProject(
537 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
538 );
539 const bootstrapProjectKey =
540 (bootstrapProject
541 ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings)
542 : null) ??
543 (serverConfig?.cwd
544 ? derivePhysicalProjectKeyFromPath(payload.environment.environmentId, serverConfig.cwd)
545 : null) ??
546 scopedProjectKey(
547 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
548 );
549 useUiStateStore.getState().setProjectExpanded(bootstrapProjectKey, true);
550
551 if (readPathname() !== "/") {
552 return;
553 }
554 if (skipInitialBootstrapNavigationRef.current) {
555 skipInitialBootstrapNavigationRef.current = false;
556 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
557 return;
558 }
559 if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
560 return;
561 }
562 await navigate({
563 to: "/$environmentId/$threadId",
564 params: {
565 environmentId: payload.environment.environmentId,
566 threadId: payload.bootstrapThreadId,
567 },
568 replace: true,
569 });
570 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
571 })().catch(() => undefined);
572 });
573
574 const handleServerConfigUpdated = useEffectEvent(() => {
575 const decision = keybindingsToastController.handle(serverConfigEvent);
576 if (!decision) {
577 return;
578 }
579
580 if (decision._tag === "Success") {
581 toastManager.add({
582 type: "success",
583 title: "Keybindings updated",
584 description: "Keybindings configuration reloaded successfully.",
585 });
586 return;
587 }
588
589 toastManager.add(
590 stackedThreadToast({
591 type: "warning",
592 title: "Invalid keybindings configuration",
593 description: (
594 <KeybindingsConfigWarning
595 message={decision.message}
596 environmentId={primaryEnvironment?.environmentId ?? null}
597 configPath={serverConfig?.keybindingsConfigPath ?? null}
598 availableEditors={serverConfig?.availableEditors ?? []}
599 />
600 ),
601 }),
602 );
603 });
604
605 useEffect(() => {
606 if (!serverConfig) {
607 return;
608 }
609
610 setActiveEnvironmentId(serverConfig.environment.environmentId);
611 }, [serverConfig]);
612
613 useEffect(() => {
614 handleWelcome(serverWelcome);
615 }, [serverWelcome]);
616
617 useEffect(() => {
618 if (serverConfigEvent === null || handledConfigEventRef.current === serverConfigEvent) {
619 return;
620 }
621 handledConfigEventRef.current = serverConfigEvent;
622 handleServerConfigUpdated();
623 }, [serverConfigEvent]);
624
625 return null;
626}