1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
4import { promptLinkAppPath } from "@t3tools/shared/promptLinks";
5import { useRouter } from "@tanstack/react-router";
6import { useEffect, useEffectEvent, useRef } from "react";
7
8import { openFileInPreview, openUrlInPreview } from "../../browser/openFileInPreview";
9import { isPreviewAvailableFor } from "../../browser/previewRuntime";
10import { useComposerDraftStore } from "../../composerDraftStore";
11import { useNewThreadHandler } from "../../hooks/useHandleNewThread";
12import { useScratchProject } from "../../hooks/useScratchProject";
13import { selectActiveRightPanelSurface, useRightPanelStore } from "../../rightPanelStore";
14import { resolveThreadRouteTarget } from "../../threadRoutes";
15import { assetEnvironment } from "../../state/assets";
16import { useEnvironmentHttpBaseUrl, usePrimaryEnvironment } from "../../state/environments";
17import { previewEnvironment } from "../../state/preview";
18import { useAtomCommand } from "../../state/use-atom-command";
19import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
20import { stackedThreadToast, toastManager } from "../ui/toast";
21
22/**
23 * The thread on screen when its browser panel is showing, so a link opens as
24 * another tab there, the way a browser window takes new links. HTML files are
25 * served by this machine's environment, so only its threads can take them.
26 */
27function threadShowingBrowser(
28 routeParams: Record<string, string | undefined>,
29 fileEnvironmentId: EnvironmentId | null,
30): ScopedThreadRef | null {
31 const target = resolveThreadRouteTarget(routeParams);
32 if (target === null) return null;
33 let threadRef = target.kind === "server" ? target.threadRef : null;
34 if (target.kind === "draft") {
35 const session = useComposerDraftStore.getState().getDraftSession(target.draftId);
36 if (!session || session.promotedTo) return null;
37 threadRef = scopeThreadRef(session.environmentId, session.threadId);
38 }
39 if (threadRef === null || !isPreviewAvailableFor(threadRef.environmentId)) return null;
40 if (fileEnvironmentId !== null && threadRef.environmentId !== fileEnvironmentId) return null;
41 const surface = selectActiveRightPanelSurface(
42 useRightPanelStore.getState().byThreadKey,
43 threadRef,
44 );
45 return surface?.kind === "preview" ? threadRef : null;
46}
47
48/**
49 * Opens each link the OS hands T3 Code. Prompt and thread links, by `t3code://`
50 * or as hosted-app pages, open in the app. Other web links and HTML files arrive
51 * because T3 Code is the default browser (macOS): with a browser panel on screen
52 * the page opens as a new tab there; otherwise it starts a thread without a
53 * project, its browser panel maximized.
54 */
55export function DesktopWebLinkCoordinator() {
56 const router = useRouter();
57 const primaryEnvironment = usePrimaryEnvironment();
58 const { scratchEnvironmentId, openScratchProject } = useScratchProject();
59 const openThread = useNewThreadHandler();
60 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
61 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
62 reportFailure: false,
63 refresh: true,
64 });
65 const httpBaseUrl = useEnvironmentHttpBaseUrl(primaryEnvironment?.environmentId ?? null);
66 const queueRef = useRef(Promise.resolve());
67 const webLinks = window.desktopBridge?.webLinks;
68 const ready =
69 webLinks !== undefined &&
70 primaryEnvironment?.connection.phase === "connected" &&
71 primaryEnvironment.serverConfig !== null;
72
73 const reportFailure = (url: string, description = url) =>
74 toastManager.add(
75 stackedThreadToast({ type: "error", title: "Could not open the link", description }),
76 );
77
78 // The OS hands each link over once, so a thread that could not start says so.
79 const startThread = async (url: string): Promise<ScopedThreadRef | null> => {
80 const environmentId = scratchEnvironmentId(primaryEnvironment?.environmentId ?? null);
81 if (environmentId === null) {
82 reportFailure(url);
83 return null;
84 }
85 // Reports its own failure.
86 const project = await openScratchProject(environmentId, "Could not open the link");
87 if (!project) return null;
88 const opened = await openThread(scopeProjectRef(project.environmentId, project.id));
89 if (!opened) {
90 reportFailure(url);
91 return null;
92 }
93 const threadRef = scopeThreadRef(project.environmentId, opened.threadId);
94 useRightPanelStore.getState().requestMaximize(threadRef);
95 return threadRef;
96 };
97
98 const openLink = useEffectEvent(async (url: string) => {
99 const appPath = promptLinkAppPath(url);
100 if (appPath !== null) {
101 await router.navigate({ href: appPath });
102 return;
103 }
104 const isFile = url.startsWith("file:");
105 const routeParams = router.state.matches.at(-1)?.params ?? {};
106 const threadRef =
107 threadShowingBrowser(
108 routeParams,
109 isFile ? (primaryEnvironment?.environmentId ?? null) : null,
110 ) ?? (await startThread(url));
111 if (threadRef === null) return;
112 // An HTML file is served from the environment, which runs on this machine.
113 const result = isFile
114 ? httpBaseUrl === null
115 ? null
116 : await openFileInPreview({
117 threadRef,
118 filePath: decodeURIComponent(new URL(url).pathname),
119 workspaceRoot: undefined,
120 httpBaseUrl,
121 createAssetUrl,
122 openPreview,
123 })
124 : await openUrlInPreview({ threadRef, url, openPreview });
125 if (result === null) {
126 reportFailure(url);
127 return;
128 }
129 if (result._tag === "Failure") {
130 const error = squashAtomCommandFailure(result);
131 reportFailure(url, error instanceof Error ? error.message : url);
132 }
133 });
134
135 useEffect(() => {
136 if (!ready || webLinks === undefined) return;
137 let subscribed = true;
138 // Links open one at a time, in the order they came.
139 const unsubscribe = webLinks.onOpen((url) => {
140 queueRef.current = queueRef.current.then(() => openLink(url)).catch(() => undefined);
141 });
142 // Skip readiness if React runs cleanup before this subscription can receive links.
143 queueMicrotask(() => {
144 if (subscribed) void webLinks.setReady(true).catch(() => undefined);
145 });
146 return () => {
147 subscribed = false;
148 void webLinks.setReady(false).catch(() => undefined);
149 unsubscribe();
150 };
151 }, [ready, webLinks]);
152
153 return null;
154}