Open History to see the last commit
1import { pipe } from "effect/Function";
2import { useAtomValue } from "@effect/atom-react";
3import {
4 ANOTHER_ADDRESS,
5 type LinkableMachine,
6 linkRequestAnswerInput,
7 linkRequestDefaultAccess,
8 linkRequestDisplay,
9 linkRequestFailureMessage,
10 linkRequestInitialMachine,
11 type LinkRequestItem,
12 linkRequestTarget,
13 linkRequestTargetName,
14 rankMachinesByHint,
15} from "@t3tools/client-runtime/link-request";
16import {
17 isAtomCommandInterrupted,
18 squashAtomCommandFailure,
19} from "@t3tools/client-runtime/state/runtime";
20import {
21 AuthAccessWriteScope,
22 type AuthMcpClientAccess,
23 type EnvironmentId,
24 type OrchestrationV2ProjectedTurnItem,
25} from "@t3tools/contracts";
26import * as Option from "effect/Option";
27import * as Result from "effect/Result";
28import { CheckIcon, LinkIcon, MinusIcon, XIcon } from "lucide-react";
29import { useId, useRef, useState } from "react";
30
31import { useEnvironmentScope, usePreparedConnection } from "../../state/session";
32import { mintPairingCodeOn } from "../../state/peerPairingCode";
33import { peerLinkEnvironment, useLinkableMachines } from "../../state/peerLinks";
34import { useAtomCommand } from "../../state/use-atom-command";
35import { accessConfig } from "../auth/ConnectAgentSurface";
36import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
37import { LinkAccessPicker } from "../settings/LinkAccessPicker";
38import { LinkAddressWarning, LinkMachineBadge } from "../settings/LinkMachineSelect";
39import { Button } from "../ui/button";
40import { Dialog } from "../ui/dialog";
41import { Input } from "../ui/input";
42import { Radio, RadioGroup } from "../ui/radio-group";
43import { WizardFooter, WizardHeader, WizardPanel, WizardPopup, WizardSteps } from "../ui/wizard";
44import { WorkLogRow } from "./WorkLog";
45
46const accessLabel = (access: AuthMcpClientAccess) => accessConfig[access].label;
47
48/** Whether the agent left the machine for the user to pick. */
49const picksMachine = (item: LinkRequestItem) =>
50 item.environmentId === undefined && item.url === undefined;
51
52/**
53 * Inline card for a link an agent asked the user to make. The user picks the
54 * access in a dialog; the pairing code lives only in that dialog's state and
55 * the RPC payload, and is never logged, toasted, or persisted.
56 */
57export function LinkRequestCard(props: {
58 readonly environmentId: EnvironmentId;
59 readonly item: LinkRequestItem;
60 readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"];
61}) {
62 const { item } = props;
63 const display = linkRequestDisplay(item, props.visibility, accessLabel);
64 if (display.kind === "answered" || display.kind === "pending-elsewhere") {
65 const Icon =
66 display.kind === "pending-elsewhere"
67 ? LinkIcon
68 : display.outcome === "linked"
69 ? CheckIcon
70 : display.outcome === "failed"
71 ? XIcon
72 : MinusIcon;
73 return (
74 <WorkLogRow
75 data-v2-item-type={item.type}
76 icon={<Icon className="size-3.5 text-icon-muted" aria-hidden />}
77 label={display.label}
78 />
79 );
80 }
81 return <PendingLinkRequest environmentId={props.environmentId} item={item} />;
82}
83
84function PendingLinkRequest(props: {
85 readonly environmentId: EnvironmentId;
86 readonly item: LinkRequestItem;
87}) {
88 const { item } = props;
89 const [open, setOpen] = useState(false);
90 const [declining, setDeclining] = useState(false);
91 const [error, setError] = useState<string | null>(null);
92 const answer = useAtomCommand(peerLinkEnvironment.answerRequest, {
93 label: "decline link request",
94 reportFailure: false,
95 reportDefect: false,
96 });
97 const canAnswer = useAtomValue(
98 peerLinkEnvironment.answerRequest.permissionAtom(props.environmentId),
99 );
100 const target = linkRequestTargetName(item);
101
102 const decline = async () => {
103 const input = linkRequestAnswerInput(item, { type: "decline" });
104 if (input === null) return;
105 setDeclining(true);
106 setError(null);
107 const result = await answer({ environmentId: props.environmentId, input }).finally(() =>
108 setDeclining(false),
109 );
110 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
111 pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError);
112 }
113 };
114
115 return (
116 <div
117 data-v2-item-type={item.type}
118 className="flex min-w-0 flex-col gap-3 rounded-xl border border-border/60 bg-card p-4"
119 >
120 <div className="flex min-w-0 flex-col gap-1">
121 <p className="text-sm font-medium text-foreground">
122 {picksMachine(item)
123 ? "Link a machine to this environment"
124 : `Link ${target} to this environment`}
125 </p>
126 {item.reason.trim() ? <p className="text-sm text-muted-foreground">{item.reason}</p> : null}
127 </div>
128 {error !== null ? (
129 <p role="alert" className="text-xs text-destructive">
130 {error}
131 </p>
132 ) : null}
133 {canAnswer ? (
134 <div className="flex min-w-0 items-center justify-end gap-2">
135 <Button
136 type="button"
137 size="xs"
138 variant="ghost-muted"
139 disabled={declining}
140 onClick={() => void decline()}
141 >
142 Decline
143 </Button>
144 <Button type="button" size="xs" disabled={declining} onClick={() => setOpen(true)}>
145 {picksMachine(item) ? "Choose machine…" : "Review link…"}
146 </Button>
147 </div>
148 ) : (
149 <p className="text-xs text-muted-foreground">
150 Only a session that manages access here can answer this.
151 </p>
152 )}
153 <LinkRequestDialog
154 environmentId={props.environmentId}
155 item={item}
156 open={open}
157 onOpenChange={setOpen}
158 />
159 </div>
160 );
161}
162
163/** The dialog's steps: where to link, then what agents may do there. */
164type LinkStep = "machine" | "access";
165
166function LinkRequestDialog(props: {
167 readonly environmentId: EnvironmentId;
168 readonly item: LinkRequestItem;
169 readonly open: boolean;
170 readonly onOpenChange: (open: boolean) => void;
171}) {
172 const { item } = props;
173 const formId = useId();
174 const pickMachine = picksMachine(item);
175 const listed = useLinkableMachines(props.environmentId, props.open);
176 const machines = rankMachinesByHint(listed, item.hint);
177 // Null until the user picks; until then the hint's best match is picked.
178 const [choice, setChoice] = useState<string | null>(null);
179 const [address, setAddress] = useState(item.url ?? "");
180 const [access, setAccess] = useState<AuthMcpClientAccess>(() => linkRequestDefaultAccess(item));
181 const [pairingCode, setPairingCode] = useState("");
182 const [step, setStep] = useState<LinkStep>("machine");
183 const [busy, setBusy] = useState(false);
184 const [error, setError] = useState<string | null>(null);
185 // Machines whose minting already failed, so the dialog asks for a code instead.
186 const [mintFailed, setMintFailed] = useState<ReadonlySet<EnvironmentId>>(new Set());
187 // Enter then a click can both run before a re-render; this guard is synchronous.
188 const inFlight = useRef(false);
189 const answer = useAtomCommand(peerLinkEnvironment.answerRequest, {
190 label: "answer link request",
191 // The failure cause holds the request, and with it the code; keep it out of the console.
192 reportFailure: false,
193 reportDefect: false,
194 });
195 const selected = choice ?? linkRequestInitialMachine(item, machines);
196 const target = linkRequestTarget(item, machines, selected);
197 const targetName = target.kind === "machine" ? target.label : linkRequestTargetName(item);
198 const targetId = target.kind === "machine" ? target.environmentId : null;
199 const connected = usePreparedConnection(targetId);
200 const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope);
201 const needsAddress =
202 target.kind === "address" || (target.kind === "machine" && target.urls.length === 0);
203 // This client mints a code there when it is connected with a session that may
204 // create pairing links, and only for its own addresses: anything can answer
205 // as that machine at one typed in, so that one gets a code the user fetched.
206 const canMint =
207 targetId !== null &&
208 !needsAddress &&
209 Option.isSome(connected) &&
210 mayMint &&
211 !mintFailed.has(targetId);
212 // An address the agent gave is already filled in; only one the user must type is asked for.
213 const asksAddress = needsAddress && item.url === undefined;
214 const useExisting = target.kind === "machine" && target.linked;
215 // A named machine with an address it answers at has nothing to choose before access.
216 const steps: ReadonlyArray<LinkStep> =
217 pickMachine || asksAddress ? ["machine", "access"] : ["access"];
218 const current: LinkStep = steps.includes(step) ? step : "access";
219 const machineReady =
220 target.kind !== "none" && (useExisting || !needsAddress || address.trim().length > 0);
221 const ready = machineReady && (useExisting || canMint || pairingCode.trim().length > 0);
222
223 const reset = () => {
224 setChoice(null);
225 setAddress(item.url ?? "");
226 setAccess(linkRequestDefaultAccess(item));
227 setPairingCode("");
228 setStep("machine");
229 setError(null);
230 };
231
232 // Every close resets, so a reopened dialog never shows the last answer or its pairing code.
233 const close = () => {
234 props.onOpenChange(false);
235 reset();
236 };
237
238 // Settles to the answer to send, an error to show, or nothing to do.
239 const prepareAnswer = async () => {
240 if (target.kind === "none") return null;
241 if (target.kind === "machine" && target.linked) {
242 return linkRequestAnswerInput(item, {
243 type: "use-existing",
244 environmentId: target.environmentId,
245 });
246 }
247 let code = pairingCode;
248 if (canMint && targetId !== null) {
249 const minted = await mintPairingCodeOn(targetId, access);
250 if (Result.isFailure(minted)) {
251 setMintFailed((failed) => new Set(failed).add(targetId));
252 setError(linkRequestFailureMessage(minted.failure));
253 return null;
254 }
255 code = minted.success;
256 }
257 return linkRequestAnswerInput(item, {
258 type: "link",
259 target: target.kind === "machine" && target.urls.length > 0 ? target : { url: address },
260 access,
261 pairingCode: code,
262 });
263 };
264
265 const submit = async () => {
266 if (inFlight.current || target.kind === "none") return;
267 inFlight.current = true;
268 setBusy(true);
269 setError(null);
270 const input = await prepareAnswer();
271 const result =
272 input === null ? null : await answer({ environmentId: props.environmentId, input });
273 inFlight.current = false;
274 setBusy(false);
275 if (result === null) return;
276 if (result._tag === "Success") {
277 // The card switches to its answered row once the item updates.
278 close();
279 } else if (!isAtomCommandInterrupted(result)) {
280 pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError);
281 }
282 };
283
284 // The machine step ends the dialog when the machine is linked here already.
285 const finishesHere = current === "access" || useExisting;
286
287 return (
288 <Dialog
289 open={props.open}
290 onOpenChange={(next) => {
291 // An answer that lands after a close would still link, and would close a reopened dialog.
292 if (!next && busy) return;
293 if (next) props.onOpenChange(true);
294 else close();
295 }}
296 >
297 <WizardPopup>
298 <WizardHeader
299 title={pickMachine ? "Link a machine" : `Link ${targetName}`}
300 description="Agents here can then launch, message and wait on threads there. Revoke it in that machine's Connections."
301 >
302 {steps.length > 1 && !useExisting ? (
303 <WizardSteps
304 steps={["Machine", "Access"]}
305 currentStep={steps.indexOf(current)}
306 summaries={[target.kind === "none" ? null : targetName, null]}
307 showSummaries
308 isStepDisabled={(index) => busy || (index === 1 && !machineReady)}
309 onStepChange={(index) => {
310 const next = steps[index];
311 if (next !== undefined) setStep(next);
312 }}
313 />
314 ) : null}
315 </WizardHeader>
316 <WizardPanel holdHeight={busy}>
317 <form
318 id={formId}
319 className="space-y-4"
320 autoComplete="off"
321 onSubmit={(event) => {
322 event.preventDefault();
323 if (finishesHere) void submit();
324 else if (machineReady) setStep("access");
325 }}
326 >
327 {current === "machine" ? (
328 <>
329 {pickMachine ? (
330 <MachinePicker
331 machines={machines}
332 value={selected}
333 disabled={busy}
334 onChange={(next) => {
335 setChoice(next);
336 setError(null);
337 }}
338 />
339 ) : null}
340 {asksAddress ? (
341 <label className="block space-y-1.5">
342 <span className="block text-xs font-medium text-foreground">Address</span>
343 <Input
344 value={address}
345 onChange={(event) => setAddress(event.target.value)}
346 placeholder="https://machine.tailnet.ts.net"
347 disabled={busy}
348 nativeInput
349 spellCheck={false}
350 />
351 {target.kind === "machine" ? (
352 <span className="block text-xs text-muted-foreground">
353 This app has no address for {target.label} that another machine can reach.
354 Enter its https or Tailscale address.
355 </span>
356 ) : null}
357 </label>
358 ) : null}
359 </>
360 ) : (
361 <>
362 {item.url !== undefined && target.kind === "machine" ? (
363 <p className="truncate text-xs text-muted-foreground">{item.url}</p>
364 ) : null}
365 {useExisting ? null : (
366 <>
367 {needsAddress ? <LinkAddressWarning address={address} /> : null}
368 {canMint ? null : (
369 <label className="block space-y-1.5">
370 <span className="block text-xs font-medium text-foreground">
371 Pairing code
372 </span>
373 <Input
374 value={pairingCode}
375 onChange={(event) => setPairingCode(event.target.value)}
376 placeholder="Paste the code"
377 disabled={busy}
378 autoComplete="one-time-code"
379 nativeInput
380 spellCheck={false}
381 />
382 <span className="block text-xs text-muted-foreground">
383 Create a link on {targetName}: Settings → Connections → Create link, then
384 paste its code here.
385 </span>
386 </label>
387 )}
388 <LinkAccessPicker value={access} onChange={setAccess} disabled={busy} />
389 </>
390 )}
391 </>
392 )}
393 {useExisting ? (
394 <p className="text-xs text-muted-foreground">
395 This environment is already linked to {targetName}. The agent can use that link as
396 it is; change its access in Settings → Connections.
397 </p>
398 ) : null}
399 {error !== null ? (
400 <p role="alert" className="text-xs text-destructive">
401 {error}
402 </p>
403 ) : null}
404 </form>
405 </WizardPanel>
406 <WizardFooter>
407 {current === "access" && steps.length > 1 ? (
408 <Button
409 variant="outline"
410 type="button"
411 disabled={busy}
412 onClick={() => {
413 setStep("machine");
414 setError(null);
415 }}
416 >
417 Back
418 </Button>
419 ) : (
420 <Button variant="outline" type="button" disabled={busy} onClick={close}>
421 Cancel
422 </Button>
423 )}
424 <Button
425 form={formId}
426 type="submit"
427 disabled={busy || (finishesHere ? !ready : !machineReady)}
428 >
429 {busy ? "Linking…" : useExisting ? "Use this link" : finishesHere ? "Link" : "Continue"}
430 </Button>
431 </WizardFooter>
432 </WizardPopup>
433 </Dialog>
434 );
435}
436
437/** The user's other machines, best match for the agent's hint first, then an address. */
438function MachinePicker(props: {
439 readonly machines: ReadonlyArray<LinkableMachine>;
440 readonly value: string | null;
441 readonly disabled: boolean;
442 readonly onChange: (value: string) => void;
443}) {
444 const labelId = useId();
445 return (
446 <div className="space-y-2">
447 <span id={labelId} className="block text-xs font-medium">
448 Machine
449 </span>
450 <RadioGroup
451 aria-labelledby={labelId}
452 value={props.value}
453 disabled={props.disabled}
454 onValueChange={(next) => props.onChange(next as string)}
455 >
456 {props.machines.map((machine) => (
457 <label
458 key={machine.environmentId}
459 className="flex cursor-pointer items-center gap-2.5 rounded-lg border px-3 py-2 text-sm"
460 >
461 <Radio value={machine.environmentId} />
462 <EnvironmentMachineIcon
463 kind={machine.machine}
464 className="size-4 shrink-0 text-muted-foreground"
465 />
466 <span className="min-w-0 flex-1 truncate font-medium">{machine.label}</span>
467 <LinkMachineBadge machine={machine} />
468 </label>
469 ))}
470 <label className="flex cursor-pointer items-center gap-2.5 rounded-lg border px-3 py-2 text-sm">
471 <Radio value={ANOTHER_ADDRESS} />
472 <span className="min-w-0 flex-1 font-medium">Another address…</span>
473 </label>
474 </RadioGroup>
475 </div>
476 );
477}