| 1 | import { HostedServerAutoConnect } from "../components/settings/SelfHostedServer"; |
| 2 | import { usePersonalUiSettingsSync } from "../state/uiSettingsSync"; |
| 3 | import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice"; |
| 4 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; |
| 5 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 6 | import { |
| 7 | Outlet, |
| 8 | Link, |
| 9 | createRootRoute, |
| 10 | type ErrorComponentProps, |
| 11 | useLocation, |
| 12 | useNavigate, |
| 13 | useRouter, |
| 14 | } from "@tanstack/react-router"; |
| 15 | import { Check, Copy } from "lucide"; |
| 16 | import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 17 | |
| 18 | import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 19 | import { resolveServerBackedAppDisplayName } from "../branding.logic"; |
| 20 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 21 | import { MediaPlaybackProvider } from "../components/media/MediaPlaybackHost"; |
| 22 | import { CommandPalette } from "../components/CommandPalette"; |
| 23 | import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; |
| 24 | import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; |
| 25 | import { KeybindingsConfigWarning } from "../components/KeybindingsConfigWarning"; |
| 26 | import { FirstRunGate } from "../components/onboarding/FirstRunGate"; |
| 27 | import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; |
| 28 | import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; |
| 29 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| 30 | import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; |
| 31 | import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; |
| 32 | import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator"; |
| 33 | import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; |
| 34 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 35 | import { DesktopUpdateLaunchNotice } from "../components/sidebar/updateEverythingRun"; |
| 36 | import { AutomaticUiRefresh } from "../components/AutomaticUiRefresh"; |
| 37 | import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; |
| 38 | import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; |
| 39 | import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; |
| 40 | import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut"; |
| 41 | import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; |
| 42 | import { PullRequestGuideRunCoordinator } from "../components/pullRequest/guide/PullRequestGuideRunCoordinator"; |
| 43 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 44 | import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator"; |
| 45 | import { ProviderAuthCallbackCoordinator } from "../components/settings/ProviderAuthCallbackCoordinator"; |
| 46 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; |
| 47 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 48 | import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme"; |
| 49 | import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme"; |
| 50 | import { Button } from "../components/ui/button"; |
| 51 | import { MorphIcon } from "~/components/MorphIcon"; |
| 52 | import { StandalonePage, StandalonePageHeader } from "../components/ui/standalone-page"; |
| 53 | import { |
| 54 | AnchoredToastProvider, |
| 55 | stackedThreadToast, |
| 56 | ToastProvider, |
| 57 | toastManager, |
| 58 | } from "../components/ui/toast"; |
| 59 | import { isElectron } from "../env"; |
| 60 | import { cn } from "../lib/utils"; |
| 61 | import { applyAppearanceFontVariables } from "~/appearanceFonts"; |
| 62 | import { applyAppearanceContrast } from "~/appearanceContrast"; |
| 63 | import { useClientSettings } from "../hooks/useSettings"; |
| 64 | import { |
| 65 | deriveLogicalProjectKeyFromSettings, |
| 66 | derivePhysicalProjectKeyFromPath, |
| 67 | selectProjectGroupingSettings, |
| 68 | } from "../logicalProject"; |
| 69 | import { useUiStateStore } from "../uiStateStore"; |
| 70 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; |
| 71 | import { configureClientTracing } from "../observability/clientTracing"; |
| 72 | import { resolveInitialServerAuthGateState } from "../environments/primary"; |
| 73 | import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; |
| 74 | import { useHostedPromptLinkLanding } from "../components/promptLinks/HostedPromptLinkLanding"; |
| 75 | import { isLocalEnvironmentDisabled } from "../localEnvironment"; |
| 76 | import { useAtomValue } from "@effect/atom-react"; |
| 77 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; |
| 78 | import { |
| 79 | primaryServerConfigAtom, |
| 80 | primaryServerConfigEventAtom, |
| 81 | primaryServerWelcomeAtom, |
| 82 | } from "../state/server"; |
| 83 | import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; |
| 84 | import { |
| 85 | createKeybindingsUpdateToastController, |
| 86 | type KeybindingsUpdateToastController, |
| 87 | } from "../components/KeybindingsUpdateToast.logic"; |
| 88 | |
| 89 | import { installDesktopPasteAsText } from "../lib/desktopPasteAsText"; |
| 90 | |
| 91 | export 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 | |
| 122 | function 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 | |
| 137 | function 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. */ |
| 281 | function 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 | |
| 289 | function 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 | |
| 309 | function 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 | |
| 325 | function 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 | |
| 357 | function 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 | |
| 374 | function 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 | |
| 402 | function 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. */ |
| 438 | function 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 | |
| 449 | function 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 | |
| 461 | function 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 | |
| 477 | const 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 | */ |
| 484 | function 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 | |
| 500 | function AuthenticatedTracingBootstrap() { |
| 501 | useEffect(() => { |
| 502 | void configureClientTracing(); |
| 503 | }, []); |
| 504 | |
| 505 | return null; |
| 506 | } |
| 507 | |
| 508 | function 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 | } |