Open History to see the last commit
1import { useAtomValue } from "@effect/atom-react";
2import { ANOTHER_ADDRESS, linkRequestFailureMessage } from "@t3tools/client-runtime/link-request";
3import {
4 AuthAccessWriteScope,
5 type AuthMcpClientAccess,
6 type EnvironmentId,
7 type PeerLinkSummary,
8} from "@t3tools/contracts";
9import {
10 isAtomCommandInterrupted,
11 squashAtomCommandFailure,
12} from "@t3tools/client-runtime/state/runtime";
13import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks";
14import * as DateTime from "effect/DateTime";
15import * as Option from "effect/Option";
16import * as Result from "effect/Result";
17import { PlusIcon } from "lucide-react";
18import { useId, useRef, useState } from "react";
19
20import { requestConfirmDialog } from "~/confirmDialog";
21import { mintPairingCodeOn } from "~/state/peerPairingCode";
22import { peerLinkEnvironment, useLinkableMachines } from "~/state/peerLinks";
23import { useEnvironmentQuery } from "~/state/query";
24import { useEnvironmentScope, usePreparedConnection } from "~/state/session";
25import { useAtomCommand } from "~/state/use-atom-command";
26import { accessConfig } from "../auth/ConnectAgentSurface";
27import { ConnectionStatusDot } from "../ConnectionStatusDot";
28import { Button } from "../ui/button";
29import {
30 Dialog,
31 DialogClose,
32 DialogDescription,
33 DialogFooter,
34 DialogHeader,
35 DialogPanel,
36 DialogPopup,
37 DialogTitle,
38 DialogTrigger,
39} from "../ui/dialog";
40import { Input } from "../ui/input";
41import { stackedThreadToast, toastManager } from "../ui/toast";
42import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
43import { LinkAccessPicker } from "./LinkAccessPicker";
44import { LinkAddressWarning, LinkMachineSelect } from "./LinkMachineSelect";
45import { SettingsRow, SettingsSection } from "./settingsLayout";
46import { useLiveRefresh } from "../../hooks/useLiveRefresh";
47import { useNowMinute } from "../../hooks/useNowMinute";
48import { searchableSetting } from "./settingsSearch";
49
50const failureToast = (title: string, description: string) =>
51 toastManager.add(stackedThreadToast({ type: "error", title, description }));
52
53export function LinkedEnvironmentsSettings({
54 environmentId,
55 environmentLabel,
56 isSearchTarget,
57}: {
58 readonly environmentId: EnvironmentId;
59 readonly environmentLabel: string | null;
60 readonly isSearchTarget: boolean;
61}) {
62 const links = useEnvironmentQuery(peerLinkEnvironment.list({ environmentId, input: {} }));
63 // Links made outside this client bump nothing the shared list reads, so showing the panel
64 // asks again. Listing probes every linked environment, too costly for a timer nobody asked for.
65 // A list still loading counts as read now, so the next return to the window asks again.
66 useLiveRefresh(
67 () => {
68 if (!links.isPending) links.refresh();
69 },
70 {
71 key: `linked-environments:${environmentId}`,
72 intervalMs: null,
73 refreshOnShow: true,
74 },
75 );
76 const canLink = useAtomValue(peerLinkEnvironment.link.permissionAtom(environmentId));
77 const search = searchableSetting("linked-environments");
78 return (
79 <SettingsSection
80 {...(isSearchTarget ? { id: search.id } : {})}
81 title={environmentLabel === null ? search.title : `${search.title} · ${environmentLabel}`}
82 headerAction={canLink ? <LinkEnvironmentAction environmentId={environmentId} /> : null}
83 >
84 {links.error ? (
85 <SettingsRow title="Could not load linked environments" description={links.error} />
86 ) : !links.data ? (
87 <SettingsRow title="Loading linked environments…" role="status" />
88 ) : links.data.links.length === 0 ? (
89 <SettingsRow
90 title="No linked environments"
91 description="Link another machine running T3 Code, such as a VPS, so agents here can launch and drive threads there."
92 />
93 ) : (
94 links.data.links.map((link) => (
95 <LinkedEnvironmentRow
96 key={link.environmentId}
97 environmentId={environmentId}
98 link={link}
99 />
100 ))
101 )}
102 </SettingsSection>
103 );
104}
105
106function LinkedEnvironmentRow({
107 environmentId,
108 link,
109}: {
110 readonly environmentId: EnvironmentId;
111 readonly link: PeerLinkSummary;
112}) {
113 const nowMinute = useNowMinute();
114 const health = peerLinkHealth(link, DateTime.makeUnsafe(nowMinute));
115 const unlink = useAtomCommand(peerLinkEnvironment.unlink, { label: "unlink environment" });
116 const canUnlink = useAtomValue(peerLinkEnvironment.unlink.permissionAtom(environmentId));
117 const [busy, setBusy] = useState(false);
118 const forget = async () => {
119 const confirmed = await (requestConfirmDialog(
120 `Forget ${link.label}? Agents here stop working there. Revoke "T3 Code · …" in ${link.label}'s Sharing & access to end the session there too.`,
121 { variant: "destructive" },
122 ) ?? Promise.resolve(false));
123 if (!confirmed) return;
124 setBusy(true);
125 const result = await unlink({ environmentId, input: { environmentId: link.environmentId } });
126 setBusy(false);
127 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
128 failureToast("Could not forget the link", String(squashAtomCommandFailure(result)));
129 }
130 };
131 const status =
132 health.kind === "expired"
133 ? "Expired. Link it again with a new pairing code."
134 : health.kind === "unreachable"
135 ? (health.detail ?? "Not answering right now.")
136 : health.kind === "failing"
137 ? health.detail
138 : health.expiresInDays !== null
139 ? `Expires in ${health.expiresInDays} ${health.expiresInDays === 1 ? "day" : "days"}. Link it again to renew.`
140 : "Reachable";
141 return (
142 <div className={ITEM_ROW_CLASSNAME}>
143 <div className={ITEM_ROW_INNER_CLASSNAME}>
144 <div className="min-w-0 flex-1 space-y-1">
145 <div className="flex min-h-5 items-center gap-1.5">
146 <ConnectionStatusDot
147 tooltipText={status}
148 dotClassName={
149 health.kind === "reachable"
150 ? health.expiresInDays === null
151 ? "bg-success"
152 : "bg-warning"
153 : health.kind === "expired" || health.kind === "failing"
154 ? "bg-destructive"
155 : "bg-muted-foreground/30"
156 }
157 pingClassName={null}
158 />
159 <h3 className="text-sm font-medium text-foreground">{link.label}</h3>
160 </div>
161 <p className="text-xs text-muted-foreground">
162 {accessConfig[link.access].label} · {status}
163 </p>
164 <p className="truncate text-xs text-muted-foreground/80">{link.urls.join(", ")}</p>
165 </div>
166 {canUnlink ? (
167 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
168 <Button
169 size="xs"
170 variant="destructive-outline"
171 disabled={busy}
172 onClick={() => void forget()}
173 >
174 {busy ? "Forgetting…" : "Forget"}
175 </Button>
176 </div>
177 ) : null}
178 </div>
179 </div>
180 );
181}
182
183function LinkEnvironmentAction({ environmentId }: { readonly environmentId: EnvironmentId }) {
184 const [open, setOpen] = useState(false);
185 const machines = useLinkableMachines(environmentId, open);
186 // Null until the user picks; with no other machines known, only an address is left.
187 const [choice, setChoice] = useState<string | null>(null);
188 const [address, setAddress] = useState("");
189 const [pairingCode, setPairingCode] = useState("");
190 const [access, setAccess] = useState<AuthMcpClientAccess>("approval-required");
191 const [busy, setBusy] = useState(false);
192 // Machines whose minting already failed, so the dialog asks for a code instead.
193 const [mintFailed, setMintFailed] = useState<ReadonlySet<EnvironmentId>>(new Set());
194 // Enter then a click can both run before a re-render; this guard is synchronous.
195 const inFlight = useRef(false);
196 const formId = useId();
197 const link = useAtomCommand(peerLinkEnvironment.link, { label: "link environment" });
198 const selected = choice ?? (machines.length === 0 ? ANOTHER_ADDRESS : null);
199 const machine = machines.find((candidate) => candidate.environmentId === selected) ?? null;
200 const targetId = machine?.environmentId ?? null;
201 const connected = usePreparedConnection(targetId);
202 const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope);
203 // A picked machine that left the list has no address here either.
204 const needsAddress = selected !== null && (machine === null || machine.urls.length === 0);
205 // This client mints a code there when it is connected with a session that may
206 // create pairing links, and only for its own addresses: anything can answer
207 // as that machine at one typed in, so that one gets a code the user fetched.
208 const canMint =
209 targetId !== null &&
210 !needsAddress &&
211 Option.isSome(connected) &&
212 mayMint &&
213 !mintFailed.has(targetId);
214 const ready =
215 selected !== null &&
216 (!needsAddress || address.trim().length > 0) &&
217 (canMint || pairingCode.trim().length > 0);
218 const handleOpenChange = (next: boolean) => {
219 setOpen(next);
220 if (!next) {
221 setChoice(null);
222 setAddress("");
223 setPairingCode("");
224 setAccess("approval-required");
225 setMintFailed(new Set());
226 }
227 };
228 // Settles to the code to link with, or null after reporting why there is none.
229 const resolvePairingCode = async () => {
230 if (!canMint || targetId === null) return pairingCode.trim();
231 const minted = await mintPairingCodeOn(targetId, access);
232 if (Result.isSuccess(minted)) return minted.success;
233 setMintFailed((failed) => new Set(failed).add(targetId));
234 failureToast("Could not link the environment", linkRequestFailureMessage(minted.failure));
235 return null;
236 };
237 const submit = async () => {
238 const [url, ...alternateUrls] =
239 machine === null || needsAddress ? [address.trim()] : machine.urls;
240 if (inFlight.current || !ready || url === undefined) return;
241 inFlight.current = true;
242 setBusy(true);
243 const code = await resolvePairingCode();
244 const result =
245 code === null
246 ? null
247 : await link({
248 environmentId,
249 input: {
250 url,
251 ...(alternateUrls.length > 0 ? { alternateUrls } : {}),
252 // A typed address links alone: only this app's own addresses name a machine.
253 ...(machine === null || needsAddress
254 ? {}
255 : { expectedEnvironmentId: machine.environmentId, expectedLabel: machine.label }),
256 pairingCode: code,
257 access,
258 },
259 });
260 inFlight.current = false;
261 setBusy(false);
262 if (result === null) return;
263 if (result._tag === "Success") {
264 toastManager.add({ type: "success", title: `Linked ${result.value.label}` });
265 handleOpenChange(false);
266 } else if (!isAtomCommandInterrupted(result)) {
267 failureToast("Could not link the environment", String(squashAtomCommandFailure(result)));
268 }
269 };
270 return (
271 <Dialog
272 open={open}
273 onOpenChange={(next) => {
274 if (!next && busy) return;
275 handleOpenChange(next);
276 }}
277 >
278 <DialogTrigger
279 render={
280 <Button size="xs" variant="default">
281 <PlusIcon className="size-3" />
282 Link
283 </Button>
284 }
285 />
286 <DialogPopup className="max-w-md">
287 <DialogHeader>
288 <DialogTitle>Link an environment</DialogTitle>
289 <DialogDescription>
290 Agents here can then launch, message and wait on threads there. It appears under
291 Authorized clients in that environment's Sharing & access, where it can be revoked.
292 </DialogDescription>
293 </DialogHeader>
294 <DialogPanel>
295 <form
296 id={formId}
297 className="space-y-4"
298 autoComplete="off"
299 onSubmit={(event) => {
300 event.preventDefault();
301 void submit();
302 }}
303 >
304 {machines.length > 0 ? (
305 <LinkMachineSelect
306 machines={machines}
307 value={selected}
308 disabled={busy}
309 onChange={setChoice}
310 />
311 ) : null}
312 {machine?.linked ? (
313 <p className="text-xs text-muted-foreground">
314 Already linked. Linking again renews it with the access you pick.
315 </p>
316 ) : null}
317 {needsAddress ? (
318 <label className="block space-y-1.5">
319 <span className="block text-xs font-medium text-foreground">Address</span>
320 <Input
321 value={address}
322 onChange={(event) => {
323 setAddress(event.target.value);
324 // Machines found after typing began must not hide the address being typed.
325 if (choice === null) setChoice(ANOTHER_ADDRESS);
326 }}
327 placeholder="https://box.example.ts.net"
328 disabled={busy}
329 autoFocus={machines.length === 0}
330 nativeInput
331 spellCheck={false}
332 />
333 {machine !== null ? (
334 <span className="block text-xs text-muted-foreground">
335 This app has no address for {machine.label} that another machine can reach.
336 Enter its https or Tailscale address.
337 </span>
338 ) : null}
339 </label>
340 ) : null}
341 {needsAddress && address.trim() ? <LinkAddressWarning address={address} /> : null}
342 {selected === null || canMint ? null : (
343 <label className="block space-y-1.5">
344 <span className="block text-xs font-medium text-foreground">Pairing code</span>
345 <Input
346 value={pairingCode}
347 onChange={(event) => setPairingCode(event.target.value)}
348 placeholder={
349 machine === null ? "From Sharing & access there, or t3 pair" : "Paste the code"
350 }
351 disabled={busy}
352 autoComplete="one-time-code"
353 nativeInput
354 spellCheck={false}
355 />
356 {machine !== null ? (
357 <span className="block text-xs text-muted-foreground">
358 Create a link on {machine.label}: Settings → Sharing & access → Authorized
359 clients → Create link, then paste its code here.
360 </span>
361 ) : null}
362 </label>
363 )}
364 <LinkAccessPicker value={access} onChange={setAccess} disabled={busy} />
365 </form>
366 </DialogPanel>
367 <DialogFooter variant="bare">
368 <DialogClose disabled={busy} render={<Button variant="outline" type="button" />}>
369 Cancel
370 </DialogClose>
371 <Button form={formId} type="submit" disabled={busy || !ready}>
372 {busy ? "Linking…" : "Link"}
373 </Button>
374 </DialogFooter>
375 </DialogPopup>
376 </Dialog>
377 );
378}