1import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
3import {
4 Outlet,
5 Link,
6 redirect,
7 createRootRoute,
8 type ErrorComponentProps,
9 useLocation,
10 useNavigate,
11 useRouter,
12} from "@tanstack/react-router";
13import { CheckIcon, CopyIcon } from "lucide-react";
14import { useEffect, useEffectEvent, useRef, useState } from "react";
15
16import { APP_DISPLAY_NAME, APP_VERSION } from "../branding";
17import { AppSidebarLayout } from "../components/AppSidebarLayout";
18import { CommandPalette } from "../components/CommandPalette";
19import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog";
20import { GuestShareDialogHost } from "../components/chat/GuestShareControls";
21import { UseInAnotherAppDialogHost } from "../components/mcp/UseInAnotherAppDialog";
22import { AddProjectToMachineDialogHost } from "../components/AddProjectToMachineDialog";
23import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
24import { DevReplayHost } from "../components/DevReplayHost";
25import { FirstRunGate } from "../components/onboarding/FirstRunGate";
26import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
27import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
28import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
29import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";
30import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
31import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator";
32import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";
33import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut";
34import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
35import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator";
36import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
37import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
38import { Button } from "../components/ui/button";
39import { StandalonePage, StandalonePageHeader } from "../components/ui/standalone-page";
40import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
41import { isElectron } from "../env";
42import { useClientSettings } from "../hooks/useSettings";
43import {
44 deriveLogicalProjectKeyFromSettings,
45 derivePhysicalProjectKeyFromPath,
46 selectProjectGroupingSettings,
47} from "@t3tools/client-runtime/state/project-grouping";
48import { useUiStateStore } from "../uiStateStore";
49import { configureClientTracing } from "../observability/clientTracing";
50import { resolveInitialServerAuthGateState } from "../environments/primary";
51import { GUEST_PAGE_PATH } from "../guestPage";
52import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
53import { useHostedPromptLinkLanding } from "../components/promptLinks/HostedPromptLinkLanding";
54import { isLocalEnvironmentDisabled } from "../localEnvironment";
55import { useAtomValue } from "@effect/atom-react";
56import { primaryServerConfigAtom, primaryServerWelcomeAtom } from "../state/server";
57import { readProject } from "../state/entities";
58
59import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot";
60import { isChunkLoadError } from "../lib/chunkReloadGuard";
61import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume";
62
63export const Route = createRootRoute({
64 beforeLoad: async ({ location }) => {
65 // Guest links open on the owner's tunnel host in a browser with no T3 session, and must
66 // not trigger pairing or an environment connection.
67 if (location.pathname === GUEST_PAGE_PATH) {
68 return {
69 authGateState: {
70 status: "guest",
71 } as const,
72 };
73 }
74
75 if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) {
76 return {
77 authGateState: {
78 status: "hosted-pairing",
79 } as const,
80 };
81 }
82
83 if (isLocalEnvironmentDisabled() || isHostedStaticApp(new URL(window.location.href))) {
84 return {
85 authGateState: {
86 status: "hosted-static",
87 } as const,
88 };
89 }
90
91 const authGateState = await resolveInitialServerAuthGateState();
92 if (authGateState.status === "authenticated") {
93 // Idempotent, so every navigation can ask for it.
94 void configureClientTracing();
95 }
96 if (
97 authGateState.status === "authenticated" &&
98 getDesktopSnapShotBridge() &&
99 shouldResumeSnapShotSetupOnStartup() &&
100 location.pathname !== "/settings/snap-shot"
101 ) {
102 throw redirect({ to: "/settings/snap-shot", replace: true });
103 }
104 return {
105 authGateState,
106 };
107 },
108 component: RootRouteView,
109 errorComponent: RootRouteErrorView,
110 notFoundComponent: RootRouteNotFoundView,
111 head: () => ({
112 meta: [{ name: "title", content: APP_DISPLAY_NAME }],
113 }),
114});
115
116function RootRouteNotFoundView() {
117 return (
118 <main className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-6">
119 <div className="flex max-w-sm flex-col items-center gap-4 text-center">
120 <h1 className="text-lg font-medium text-foreground">Page not found</h1>
121 <p className="text-sm text-muted-foreground">
122 This link doesn't point to a page in {APP_DISPLAY_NAME}. Go home to choose a project or
123 start a thread.
124 </p>
125 <Button render={<Link to="/" replace />}>Go home</Button>
126 </div>
127 </main>
128 );
129}
130
131function RootRouteView() {
132 // Above every branch: app services raise toasts for the whole page, so the surface they write to
133 // must not unmount when the root switches branches (for example /welcome to the app shell).
134 return (
135 <ToastProvider>
136 <AnchoredToastProvider>
137 <RootRouteBranch />
138 </AnchoredToastProvider>
139 </ToastProvider>
140 );
141}
142
143function RootRouteBranch() {
144 const pathname = useLocation({ select: (location) => location.pathname });
145 const { authGateState } = Route.useRouteContext();
146 const primaryEnvironmentAuthenticated = authGateState.status === "authenticated";
147 // A sync IPC read; the setting only changes across a relaunch.
148 const [localEnvironmentDisabled] = useState(isLocalEnvironmentDisabled);
149 // Once true, stays true: a visit to /welcome means the bootstrap already chose a route.
150 const [returningFromWelcome, setReturningFromWelcome] = useState(pathname === "/welcome");
151 if (pathname === "/welcome" && !returningFromWelcome) setReturningFromWelcome(true);
152 const hostedPromptLinkLanding = useHostedPromptLinkLanding(
153 authGateState.status === "hosted-static",
154 );
155
156 if (pathname === GUEST_PAGE_PATH) return <Outlet />;
157
158 if (
159 pathname === "/pair" ||
160 pathname === "/connect" ||
161 pathname === "/connect-agent" ||
162 pathname === "/approve-agent"
163 ) {
164 return <Outlet />;
165 }
166
167 // Handle links before onboarding redirects lose them.
168 if (hostedPromptLinkLanding !== null) {
169 return hostedPromptLinkLanding;
170 }
171
172 const isWelcome = pathname === "/welcome";
173 if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") {
174 return <Outlet />;
175 }
176
177 // Settings can reopen /welcome mid-session, so it mounts everything the app does except the
178 // first-run gate and the event router, whose first navigation would leave the page.
179 const appHosts = (
180 <>
181 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}
182 {primaryEnvironmentAuthenticated || localEnvironmentDisabled ? (
183 <DesktopWebLinkCoordinator />
184 ) : null}
185 {isElectron ? <RunningThreadKeepAlive /> : null}
186 <RelayClientInstallDialog />
187 <ConnectOnboardingDialog />
188 <SshPasswordPromptDialog />
189 <SnapShotCoordinator />
190 <ReopenClosedViewShortcut />
191 <ConfirmDialogHost />
192 <CustomSnoozeDialogHost />
193 <GuestShareDialogHost />
194 <ProjectCloneToastCoordinator />
195 <AddProjectToMachineDialogHost />
196 <UseInAnotherAppDialogHost />
197 {import.meta.env.DEV && primaryEnvironmentAuthenticated ? <DevReplayHost /> : null}
198 </>
199 );
200 const appShell = (
201 <CommandPalette>
202 {/* Setup starts with an empty workspace, so the Welcome page has no thread list. */}
203 <AppSidebarLayout showSidebar={!isWelcome}>
204 <Outlet />
205 </AppSidebarLayout>
206 </CommandPalette>
207 );
208
209 return (
210 <>
211 <ChatGptWelcomeCoordinator />
212 {isWelcome ? (
213 <>
214 {appHosts}
215 {appShell}
216 </>
217 ) : (
218 <FirstRunGate
219 enabled={primaryEnvironmentAuthenticated}
220 hostedStatic={authGateState.status === "hosted-static"}
221 >
222 {appHosts}
223 {primaryEnvironmentAuthenticated ? (
224 <EventRouter skipInitialBootstrapNavigation={returningFromWelcome} />
225 ) : null}
226 {appShell}
227 </FirstRunGate>
228 )}
229 {/* Above both shells and the router, so drafts survive navigation, including to /welcome. */}
230 <ThemeEditorHost />
231 </>
232 );
233}
234
235function RootRouteErrorView({ error }: ErrorComponentProps) {
236 const router = useRouter();
237 const message = errorMessage(error);
238 // Router pathname rather than window.location: desktop uses hash history, where the window path is always "/".
239 const pathname = useLocation({ select: (location) => location.pathname });
240 const report = errorReport(error, pathname);
241
242 return (
243 <StandalonePage>
244 <StandalonePageHeader title="Something went wrong." description={message} />
245
246 <div className="mt-5 flex flex-wrap gap-2">
247 <Button
248 size="sm"
249 onClick={() =>
250 isChunkLoadError(error) ? window.location.reload() : void router.invalidate()
251 }
252 >
253 Try again
254 </Button>
255 <Button size="sm" variant="outline" onClick={() => window.location.reload()}>
256 Reload app
257 </Button>
258 <CopyErrorButton report={report} />
259 </div>
260
261 <div className="mt-5 overflow-hidden rounded-lg border border-border/70 bg-background/55">
262 <p className="px-3 py-1.5 text-xs font-medium text-muted-foreground">Error report</p>
263 <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">
264 {report}
265 </pre>
266 </div>
267 </StandalonePage>
268 );
269}
270
271function CopyErrorButton({ report }: { report: string }) {
272 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-report" });
273
274 return (
275 <Button size="sm" variant="outline" onClick={() => copyToClipboard(report)}>
276 {isCopied ? <CheckIcon className="text-success" /> : <CopyIcon />}
277 {isCopied ? "Copied" : "Copy error"}
278 </Button>
279 );
280}
281
282function errorMessage(error: unknown): string {
283 if (error instanceof Error && error.message.trim().length > 0) {
284 return error.message;
285 }
286
287 if (typeof error === "string" && error.trim().length > 0) {
288 return error;
289 }
290
291 return "An unexpected router error occurred.";
292}
293
294function errorDetails(error: unknown): string {
295 if (error instanceof Error) {
296 return error.stack ?? error.message;
297 }
298
299 if (typeof error === "string") {
300 return error;
301 }
302
303 try {
304 return JSON.stringify(error, null, 2);
305 } catch {
306 return "No additional error details are available.";
307 }
308}
309
310const MAX_ERROR_CAUSE_DEPTH = 5;
311
312// Use only pathname to keep query tokens off the clipboard.
313function errorReport(error: unknown, pathname: string): string {
314 const lines = [
315 `${APP_DISPLAY_NAME} ${APP_VERSION}`,
316 `Path: ${pathname}`,
317 `Time: ${new Date().toISOString()}`,
318 "",
319 errorDetails(error),
320 ];
321 let cause = error instanceof Error ? error.cause : undefined;
322 for (let depth = 0; cause !== undefined && depth < MAX_ERROR_CAUSE_DEPTH; depth += 1) {
323 lines.push("", "Caused by:", errorDetails(cause));
324 cause = cause instanceof Error ? cause.cause : undefined;
325 }
326 return lines.join("\n");
327}
328
329function EventRouter({
330 skipInitialBootstrapNavigation,
331}: {
332 readonly skipInitialBootstrapNavigation: boolean;
333}) {
334 const navigate = useNavigate();
335 const pathname = useLocation({ select: (loc) => loc.pathname });
336 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
337 const serverConfig = useAtomValue(primaryServerConfigAtom);
338 const serverWelcome = useAtomValue(primaryServerWelcomeAtom);
339 const readPathname = useEffectEvent(() => pathname);
340 const handledBootstrapThreadIdRef = useRef<string | null>(null);
341 const skipInitialBootstrapNavigationRef = useRef(skipInitialBootstrapNavigation);
342
343 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {
344 if (!payload) return;
345
346 void (async () => {
347 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
348 return;
349 }
350 const bootstrapProject = readProject(
351 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
352 );
353 const bootstrapProjectKey =
354 (bootstrapProject
355 ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings)
356 : null) ??
357 (serverConfig?.cwd
358 ? derivePhysicalProjectKeyFromPath(payload.environment.environmentId, serverConfig.cwd)
359 : null) ??
360 scopedProjectKey(
361 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
362 );
363 useUiStateStore.getState().setProjectExpanded(bootstrapProjectKey, true);
364
365 if (readPathname() !== "/") {
366 return;
367 }
368 if (skipInitialBootstrapNavigationRef.current) {
369 skipInitialBootstrapNavigationRef.current = false;
370 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
371 return;
372 }
373 if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
374 return;
375 }
376 await navigate({
377 to: "/$environmentId/$threadId",
378 params: {
379 environmentId: payload.environment.environmentId,
380 threadId: payload.bootstrapThreadId,
381 },
382 replace: true,
383 });
384 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
385 })().catch(() => undefined);
386 });
387
388 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands the bootstrap project in the UI store and navigates the router on a server welcome
389 useEffect(() => {
390 handleWelcome(serverWelcome);
391 }, [serverWelcome]);
392
393 return null;
394}