1import { pipe } from "effect/Function";
2import { useAtomValue } from "@effect/atom-react";
3import {
4 ANOTHER_ADDRESS,
5 type LinkableMachine,
6 linkDestination,
7 linkRequestAnswerInput,
8 linkRequestDefaultAccess,
9 linkRequestDisplay,
10 linkRequestFailureMessage,
11 linkRequestInitialMachine,
12 type LinkRequestItem,
13 linkRequestTarget,
14 linkRequestTargetName,
15 rankMachinesByHint,
16} from "@t3tools/client-runtime/link-request";
17import {
18 isAtomCommandInterrupted,
19 squashAtomCommandFailure,
20} from "@t3tools/client-runtime/state/runtime";
21import {
22 AuthAccessWriteScope,
23 type AuthMcpClientAccess,
24 type EnvironmentId,
25 type OrchestrationV2ProjectedTurnItem,
26} from "@t3tools/contracts";
27import * as Option from "effect/Option";
28import * as Result from "effect/Result";
29import { useMemo, useRef, useState } from "react";
30import { Pressable, ScrollView, View, type ColorValue } from "react-native";
31import { useSafeAreaInsets } from "react-native-safe-area-context";
32
33import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol";
34import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText";
35import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
36import { peerLinkEnvironment, useLinkableMachines } from "../../state/peerLinks";
37import { mintPairingCodeOn } from "../../state/peerPairingCode";
38import { useEnvironmentScope, usePreparedConnection } from "../../state/session";
39import { useAtomCommand } from "../../state/use-atom-command";
40import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow";
41import { SettingsSection } from "../settings/components/SettingsSection";
42import { Button } from "../../components/ui/Button";
43import { Sheet } from "../../components/ui/Sheet";
44import { RUNTIME_MODE_CHOICES } from "./thread-settings-options";
45
46/** The same five levels as web's access picker: read-only, then the runtime modes. */
47const ACCESS_CHOICES: ReadonlyArray<{
48 readonly access: AuthMcpClientAccess;
49 readonly label: string;
50 readonly description: string;
51}> = [
52 {
53 access: "read-only",
54 label: "Read only",
55 description: "Read projects and threads. Cannot start, message or change anything.",
56 },
57 ...RUNTIME_MODE_CHOICES.map((choice) => ({ ...choice, access: choice.mode })),
58];
59const accessLabel = (access: AuthMcpClientAccess) =>
60 ACCESS_CHOICES.find((choice) => choice.access === access)?.label ?? access;
61
62const LINK_SYMBOL: AppSymbolName = { ios: "link", android: "desktop_windows" };
63
64/** Whether the agent left the machine for the user to pick. */
65const picksMachine = (item: LinkRequestItem) =>
66 item.environmentId === undefined && item.url === undefined;
67
68/**
69 * Feed card for a link an agent asked the user to make. The user picks the
70 * access in a sheet; the pairing code lives only in that sheet's state and
71 * the RPC payload, and is never logged, alerted, or persisted.
72 */
73export function LinkRequestCard(props: {
74 readonly environmentId: EnvironmentId;
75 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
76 readonly iconColor: ColorValue;
77}) {
78 const { item, visibility } = props.projectedItem;
79 if (item.type !== "link_request") return null;
80 const display = linkRequestDisplay(item, visibility, accessLabel);
81 if (display.kind === "pending") {
82 return <PendingLinkRequest environmentId={props.environmentId} item={item} />;
83 }
84 const icon: AppSymbolName =
85 display.kind === "pending-elsewhere"
86 ? LINK_SYMBOL
87 : display.outcome === "linked"
88 ? "checkmark"
89 : display.outcome === "failed"
90 ? "xmark"
91 : "minus";
92 return (
93 <View className="mb-3 min-h-9 flex-row items-center gap-2 px-1">
94 <SymbolView name={icon} size={13} tintColor={props.iconColor} type="monochrome" />
95 <Text className="flex-1 font-sans text-sm text-foreground-muted" numberOfLines={2}>
96 {display.label}
97 </Text>
98 </View>
99 );
100}
101
102function PendingLinkRequest(props: {
103 readonly environmentId: EnvironmentId;
104 readonly item: LinkRequestItem;
105}) {
106 const { item } = props;
107 const [open, setOpen] = useState(false);
108 const [declining, setDeclining] = useState(false);
109 const [error, setError] = useState<string | null>(null);
110 const answer = useAtomCommand(peerLinkEnvironment.answerRequest, {
111 label: "decline link request",
112 reportFailure: false,
113 reportDefect: false,
114 });
115 const canAnswer = useAtomValue(
116 peerLinkEnvironment.answerRequest.permissionAtom(props.environmentId),
117 );
118 const target = linkRequestTargetName(item);
119
120 const decline = async () => {
121 const input = linkRequestAnswerInput(item, { type: "decline" });
122 if (input === null) return;
123 setDeclining(true);
124 setError(null);
125 const result = await answer({ environmentId: props.environmentId, input }).finally(() =>
126 setDeclining(false),
127 );
128 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
129 pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError);
130 }
131 };
132
133 return (
134 <View className="mb-3 gap-3 rounded-[20px] border border-border bg-card-alt p-4">
135 <View className="gap-1">
136 <Text className="font-t3-bold text-base text-foreground">
137 {picksMachine(item)
138 ? "Link a machine to this environment"
139 : `Link ${target} to this environment`}
140 </Text>
141 {item.reason.trim() ? (
142 <Text className="font-sans text-sm leading-5 text-foreground-muted">{item.reason}</Text>
143 ) : null}
144 </View>
145 {error !== null ? (
146 <Text
147 accessibilityRole="alert"
148 accessibilityLiveRegion="polite"
149 className="font-sans text-sm text-danger-foreground"
150 >
151 {error}
152 </Text>
153 ) : null}
154 {canAnswer ? (
155 <>
156 <Button
157 label={picksMachine(item) ? "Choose machine…" : "Review link…"}
158 disabled={declining}
159 onPress={() => setOpen(true)}
160 tone="primary"
161 fullWidth
162 />
163 <View className="flex-row justify-end">
164 <Pressable
165 accessibilityRole="button"
166 accessibilityState={{ disabled: declining }}
167 disabled={declining}
168 hitSlop={8}
169 className="px-1 py-1 active:opacity-60 disabled:opacity-50"
170 onPress={() => void decline()}
171 >
172 <Text className="font-sans text-xs text-foreground-muted">Decline</Text>
173 </Pressable>
174 </View>
175 </>
176 ) : (
177 <Text className="font-sans text-xs text-foreground-muted">
178 Only a session that manages access here can answer this.
179 </Text>
180 )}
181 {open ? (
182 <LinkRequestSheet
183 environmentId={props.environmentId}
184 item={item}
185 onClose={() => setOpen(false)}
186 />
187 ) : null}
188 </View>
189 );
190}
191
192function LinkRequestSheet(props: {
193 readonly environmentId: EnvironmentId;
194 readonly item: LinkRequestItem;
195 readonly onClose: () => void;
196}) {
197 const { item } = props;
198 const insets = useSafeAreaInsets();
199 const pickMachine = picksMachine(item);
200 const listed = useLinkableMachines(props.environmentId, true);
201 const machines = useMemo(() => rankMachinesByHint(listed, item.hint), [listed, item.hint]);
202 const [choice, setChoice] = useState<string | null>(null);
203 const [address, setAddress] = useState(item.url ?? "");
204 const [access, setAccess] = useState<AuthMcpClientAccess>(() => linkRequestDefaultAccess(item));
205 const [pairingCode, setPairingCode] = useState("");
206 const [busy, setBusy] = useState(false);
207 const [error, setError] = useState<string | null>(null);
208 const [mintFailed, setMintFailed] = useState<ReadonlySet<EnvironmentId>>(new Set());
209 // Submit then a tap can both run before a re-render; this guard is synchronous.
210 const inFlight = useRef(false);
211 const answer = useAtomCommand(peerLinkEnvironment.answerRequest, {
212 label: "answer link request",
213 // The failure cause holds the request, and with it the code; keep it out of the console.
214 reportFailure: false,
215 reportDefect: false,
216 });
217 const selected = choice ?? linkRequestInitialMachine(item, machines);
218 const target = linkRequestTarget(item, machines, selected);
219 const targetName = target.kind === "machine" ? target.label : linkRequestTargetName(item);
220 const targetId = target.kind === "machine" ? target.environmentId : null;
221 const connected = usePreparedConnection(targetId);
222 const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope);
223 const needsAddress =
224 target.kind === "address" || (target.kind === "machine" && target.urls.length === 0);
225 // This phone mints a code there when it is connected with a session that may
226 // create pairing links, and only for its own addresses: anything can answer
227 // as that machine at one typed in, so that one gets a code the user fetched.
228 const canMint =
229 targetId !== null &&
230 !needsAddress &&
231 Option.isSome(connected) &&
232 mayMint &&
233 !mintFailed.has(targetId);
234 const useExisting = target.kind === "machine" && target.linked;
235 const ready =
236 target.kind !== "none" &&
237 (useExisting ||
238 ((!needsAddress || address.trim().length > 0) && (canMint || pairingCode.trim().length > 0)));
239
240 const prepareAnswer = async () => {
241 if (target.kind === "none") return null;
242 if (target.kind === "machine" && target.linked) {
243 return linkRequestAnswerInput(item, {
244 type: "use-existing",
245 environmentId: target.environmentId,
246 });
247 }
248 let code = pairingCode;
249 if (canMint && targetId !== null) {
250 const minted = await mintPairingCodeOn(targetId, access);
251 if (Result.isFailure(minted)) {
252 setMintFailed((failed) => new Set(failed).add(targetId));
253 setError(linkRequestFailureMessage(minted.failure));
254 return null;
255 }
256 code = minted.success;
257 }
258 return linkRequestAnswerInput(item, {
259 type: "link",
260 target: target.kind === "machine" && target.urls.length > 0 ? target : { url: address },
261 access,
262 pairingCode: code,
263 });
264 };
265
266 const submit = async () => {
267 if (inFlight.current || target.kind === "none") return;
268 inFlight.current = true;
269 setBusy(true);
270 setError(null);
271 const input = await prepareAnswer();
272 const result =
273 input === null ? null : await answer({ environmentId: props.environmentId, input });
274 inFlight.current = false;
275 setBusy(false);
276 if (result === null) return;
277 if (result._tag === "Success") {
278 setPairingCode("");
279 props.onClose();
280 } else if (!isAtomCommandInterrupted(result)) {
281 pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError);
282 }
283 };
284
285 return (
286 <Sheet
287 title={pickMachine ? "Link a machine" : `Link ${targetName}`}
288 onClose={props.onClose}
289 // An answer that lands after a close would still link, and would close a reopened sheet.
290 dismissDisabled={busy}
291 >
292 <ScrollView
293 nestedScrollEnabled
294 className="flex-1 bg-sheet-solid"
295 contentInsetAdjustmentBehavior="automatic"
296 contentContainerClassName="gap-5 p-5"
297 contentContainerStyle={{ paddingBottom: insets.bottom + 20 }}
298 keyboardShouldPersistTaps="handled"
299 >
300 <View className="gap-1">
301 <Text className="font-sans text-sm leading-5 text-foreground-muted">
302 Agents here can then launch, message and wait on threads there. It appears under
303 Authorized clients in that machine's Sharing & access, where it can be revoked.
304 </Text>
305 {item.url !== undefined && target.kind === "machine" ? (
306 <Text className="font-sans text-xs text-foreground-muted" numberOfLines={1}>
307 {item.url}
308 </Text>
309 ) : null}
310 </View>
311 {needsAddress && !useExisting && address.trim() ? (
312 <View
313 accessibilityRole="alert"
314 className="gap-1 rounded-xl border border-warning-border bg-warning p-3"
315 >
316 <Text className="font-t3-bold text-sm text-warning-foreground">Check this address</Text>
317 <Text className="font-sans text-xs leading-snug text-warning-foreground">
318 This app cannot confirm it is one of your machines. The pairing code you paste is sent
319 there.
320 </Text>
321 <Text selectable className="font-sans text-xs leading-snug text-warning-foreground">
322 {linkDestination(address)}
323 </Text>
324 </View>
325 ) : null}
326 {pickMachine ? (
327 <SettingsSection title="Machine">
328 {machines.map((machine, index) => (
329 <MachineRow
330 key={machine.environmentId}
331 machine={machine}
332 selected={selected === machine.environmentId}
333 separated={index > 0}
334 disabled={busy}
335 onPress={() => {
336 setChoice(machine.environmentId);
337 setError(null);
338 }}
339 />
340 ))}
341 <SettingsChoiceRow
342 label="Another address…"
343 description="Link a machine this app does not know, by its https or Tailscale address."
344 selected={selected === ANOTHER_ADDRESS}
345 separated={machines.length > 0}
346 disabled={busy}
347 onPress={() => {
348 setChoice(ANOTHER_ADDRESS);
349 setError(null);
350 }}
351 />
352 </SettingsSection>
353 ) : null}
354 {needsAddress && item.url === undefined ? (
355 <View className="gap-2">
356 <TextInput
357 accessibilityLabel="Address"
358 placeholder="https://machine.tailnet.ts.net"
359 value={address}
360 onChangeText={setAddress}
361 editable={!busy}
362 autoCorrect={false}
363 autoCapitalize="none"
364 keyboardType="url"
365 spellCheck={false}
366 />
367 {target.kind === "machine" ? (
368 <Text className="font-sans text-xs text-foreground-muted">
369 This app has no address for {target.label} that another machine can reach. Enter its
370 https or Tailscale address.
371 </Text>
372 ) : null}
373 </View>
374 ) : null}
375 {useExisting ? (
376 <Text className="font-sans text-sm leading-5 text-foreground-muted">
377 This environment is already linked to {targetName}. The agent can use that link as it
378 is; change its access in Settings → Environments.
379 </Text>
380 ) : target.kind === "none" ? null : (
381 <>
382 {canMint ? null : (
383 <View className="gap-2">
384 <TextInput
385 accessibilityLabel="Pairing code"
386 placeholder="Paste the code"
387 value={pairingCode}
388 onChangeText={setPairingCode}
389 editable={!busy}
390 autoCorrect={false}
391 autoCapitalize="none"
392 autoComplete="one-time-code"
393 textContentType="oneTimeCode"
394 spellCheck={false}
395 />
396 <Text className="font-sans text-xs text-foreground-muted">
397 Create a link on {targetName}: Settings → Sharing & access → Authorized clients →
398 Create link, then paste its code here.
399 </Text>
400 </View>
401 )}
402 <SettingsSection title="What agents here may do there">
403 {ACCESS_CHOICES.map((choice, index) => (
404 <SettingsChoiceRow
405 key={choice.access}
406 label={choice.label}
407 description={choice.description}
408 selected={choice.access === access}
409 separated={index > 0}
410 disabled={busy}
411 onPress={() => setAccess(choice.access)}
412 />
413 ))}
414 </SettingsSection>
415 <Text className="font-sans text-xs text-foreground-muted">
416 An agent here never gets more there than its own mode here either. Threads it starts
417 there cannot change that environment's own threads, projects or settings.
418 </Text>
419 </>
420 )}
421 {error !== null ? (
422 <Text
423 accessibilityRole="alert"
424 accessibilityLiveRegion="polite"
425 className="font-sans text-sm text-danger-foreground"
426 >
427 {error}
428 </Text>
429 ) : null}
430 <Button
431 label={busy ? "Linking…" : useExisting ? "Use this link" : "Link"}
432 disabled={busy || !ready}
433 onPress={() => void submit()}
434 tone="primary"
435 fullWidth
436 />
437 <Button label="Cancel" tone="secondary" disabled={busy} onPress={props.onClose} fullWidth />
438 </ScrollView>
439 </Sheet>
440 );
441}
442
443/** One machine in the sheet's list: its glyph, name, and whether it is linked or reachable. */
444function MachineRow(props: {
445 readonly machine: LinkableMachine;
446 readonly selected: boolean;
447 readonly separated: boolean;
448 readonly disabled: boolean;
449 readonly onPress: () => void;
450}) {
451 const { machine } = props;
452 const status = machine.linked
453 ? "Linked"
454 : machine.connected
455 ? null
456 : machine.connectOnly
457 ? "T3 Connect"
458 : "Not connected";
459 return (
460 <Pressable
461 accessibilityRole="radio"
462 accessibilityState={{ checked: props.selected, disabled: props.disabled }}
463 className={
464 props.separated
465 ? "flex-row items-center gap-3 border-t border-border-subtle p-4 active:opacity-70"
466 : "flex-row items-center gap-3 p-4 active:opacity-70"
467 }
468 disabled={props.disabled}
469 onPress={props.onPress}
470 >
471 <EnvironmentMachineSymbol
472 kind={machine.machine}
473 size={18}
474 tintColorClassName="accent-foreground-muted"
475 />
476 <View className="min-w-0 flex-1 gap-0.5">
477 <Text className="text-lg text-foreground android:text-base" numberOfLines={1}>
478 {machine.label}
479 </Text>
480 {status === null ? null : <Text className="text-sm text-foreground-muted">{status}</Text>}
481 </View>
482 {props.selected ? (
483 <SymbolView
484 name="checkmark"
485 size={18}
486 tintColorClassName="accent-icon"
487 type="monochrome"
488 weight="semibold"
489 />
490 ) : null}
491 </Pressable>
492 );
493}