1import {
2 type LinkedPrompt,
3 parseNewThreadLink,
4 parseThreadLinkPrompt,
5 promptLinkAppPath,
6 promptLinkDeepLink,
7} from "@t3tools/shared/promptLinks";
8import { useLocation } from "@tanstack/react-router";
9import * as Schema from "effect/Schema";
10import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
11
12import { APP_DISPLAY_NAME } from "~/branding";
13import { useLocalStorage } from "~/hooks/useLocalStorage";
14import { isHostedStaticApp } from "~/hostedPairing";
15import { useEnvironments } from "~/state/environments";
16
17import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
18import { Button } from "../ui/button";
19import { Checkbox } from "../ui/checkbox";
20import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page";
21
22const OPENER_STORAGE_KEY = "t3code:prompt-link-opener";
23const Opener = Schema.NullOr(Schema.Literals(["app", "browser"]));
24const DOWNLOAD_URL = "https://t3.codes/download";
25
26interface LinkSummary {
27 readonly prompt: LinkedPrompt | null;
28 readonly details: ReadonlyArray<string>;
29}
30
31function summarize(
32 appPath: string,
33): { ok: true; value: LinkSummary } | { ok: false; error: string } {
34 const url = new URL(appPath, window.location.origin);
35 if (url.pathname !== "/new") {
36 const prompt = parseThreadLinkPrompt(url.search);
37 return prompt.ok
38 ? { ok: true, value: { prompt: prompt.value, details: ["Opens one of your threads."] } }
39 : prompt;
40 }
41 const link = parseNewThreadLink(url.search);
42 if (!link.ok) return link;
43 if (link.value.kind === "pull-request") {
44 const { owner, name, number } = link.value.pullRequest;
45 return {
46 ok: true,
47 value: { prompt: link.value.prompt, details: [`Pull request ${owner}/${name}#${number}`] },
48 };
49 }
50 const { repository, provider, model } = link.value;
51 return {
52 ok: true,
53 value: {
54 prompt: link.value.prompt,
55 details: [
56 repository ? `Repository ${repository.owner}/${repository.name}` : "No project",
57 ...(provider ? [`Model ${model ? `${provider} ${model}` : provider}`] : []),
58 ],
59 },
60 };
61}
62
63/**
64 * On app.t3.codes a prompt link first asks where to open: the installed desktop or mobile app
65 * (through `t3code://`), this browser when it is connected to a machine, or the download page.
66 * Returns that page, or null when the link should open in this browser as usual.
67 */
68export function useHostedPromptLinkLanding(enabled: boolean): ReactNode | null {
69 const href = useLocation({ select: (location) => location.href });
70 const { environments, isReady } = useEnvironments();
71 const [opener, setOpener] = useLocalStorage(OPENER_STORAGE_KEY, null, Opener);
72 const [continuedHref, setContinuedHref] = useState<string | null>(null);
73 const appPath = useMemo(
74 () =>
75 enabled && isHostedStaticApp()
76 ? promptLinkAppPath(new URL(href, window.location.origin).href, window.location.origin)
77 : null,
78 [enabled, href],
79 );
80 if (appPath === null || continuedHref === href) return null;
81 const canContinue = isReady && environments.length > 0;
82 if (opener === "browser") {
83 if (canContinue) return null;
84 // Blank until the machines load, so a remembered choice does not flash this page.
85 if (!isReady) return <></>;
86 }
87 return (
88 <HostedPromptLinkLanding
89 appPath={appPath}
90 canContinue={canContinue}
91 openInAppRemembered={opener === "app"}
92 onChoose={(choice, remember) => {
93 if (remember) setOpener(choice);
94 if (choice === "browser") setContinuedHref(href);
95 }}
96 />
97 );
98}
99
100function HostedPromptLinkLanding({
101 appPath,
102 canContinue,
103 openInAppRemembered,
104 onChoose,
105}: {
106 readonly appPath: string;
107 readonly canContinue: boolean;
108 readonly openInAppRemembered: boolean;
109 readonly onChoose: (choice: "app" | "browser", remember: boolean) => void;
110}) {
111 const summary = useMemo(() => summarize(appPath), [appPath]);
112 const [remember, setRemember] = useState(false);
113 const deepLink = promptLinkDeepLink(appPath);
114 const onPhone = /android|iphone|ipad/i.test(navigator.userAgent);
115 const autoOpenedRef = useRef(false);
116
117 // After "remember", later links hand straight to the app. The page stays as the way back
118 // when the app is not installed on this device.
119 useEffect(() => {
120 if (!openInAppRemembered || !summary.ok || autoOpenedRef.current) return;
121 autoOpenedRef.current = true;
122 window.location.href = deepLink;
123 }, [deepLink, openInAppRemembered, summary.ok]);
124
125 if (!summary.ok) {
126 return (
127 <StandalonePage tone="error">
128 <StandalonePageHeader
129 eyebrow={APP_DISPLAY_NAME}
130 title="Could not open the link"
131 description={summary.error}
132 />
133 </StandalonePage>
134 );
135 }
136
137 const { prompt, details } = summary.value;
138 return (
139 <StandalonePage tone="brand">
140 <StandalonePageHeader
141 eyebrow={APP_DISPLAY_NAME}
142 title="Open this prompt"
143 description="It opens as a draft. Nothing runs until you press Send."
144 />
145 {prompt !== null && prompt.hiddenCharacters > 0 ? (
146 <Alert className="mt-5" variant="error">
147 <AlertTitle>This prompt had hidden characters, it may be malicious</AlertTitle>
148 <AlertDescription>
149 The Knife removed {prompt.hiddenCharacters} hidden character
150 {prompt.hiddenCharacters === 1 ? "" : "s"}. Read the prompt before you send it.
151 </AlertDescription>
152 </Alert>
153 ) : null}
154 {prompt !== null ? (
155 <pre className="mt-5 max-h-72 overflow-auto rounded-lg border border-border bg-background p-3 text-sm whitespace-pre-wrap">
156 {prompt.text}
157 </pre>
158 ) : null}
159 <p className="mt-3 text-xs text-muted-foreground">{details.join(" · ")}</p>
160 <div className="mt-6 flex flex-wrap gap-2">
161 <Button render={<a href={deepLink} />} onClick={() => onChoose("app", remember)}>
162 {onPhone ? "Open in app" : "Open in desktop app"}
163 </Button>
164 {canContinue ? (
165 <Button variant="outline" onClick={() => onChoose("browser", remember)}>
166 Continue here
167 </Button>
168 ) : null}
169 <Button variant="ghost" render={<a href={DOWNLOAD_URL} />}>
170 Get T3 Code
171 </Button>
172 </div>
173 <label className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
174 <Checkbox checked={remember} onCheckedChange={(checked) => setRemember(checked === true)} />
175 Remember my choice
176 </label>
177 </StandalonePage>
178 );
179}