1import { RemoteConnectionFlow, SelfHostedServerSettings } from "./SelfHostedServer";
2import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary";
3import { activateCloudAuthentication } from "../../cloud/activation";
4import { SessionPermissions } from "./SessionPermissions";
5import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
6import {
7 ChevronRightIcon,
8 ChevronsLeftRightEllipsisIcon,
9 EllipsisIcon,
10 PlusIcon,
11 QrCodeIcon,
12 RouteIcon,
13 TerminalIcon,
14} from "lucide-react";
15import { useAtomValue } from "@effect/atom-react";
16import { Atom } from "effect/reactivity";
17import {
18 type KeyboardEvent,
19 type ReactNode,
20 memo,
21 useCallback,
22 useEffect,
23 useId,
24 useMemo,
25 useRef,
26 useState,
27} from "react";
28import {
29 AuthAccessReadScope,
30 AuthAccessWriteScope,
31 AuthEnvironmentMaintainScope,
32 AuthDiagnosticsReadScope,
33 AuthOrchestrationReadScope,
34 AuthRelayReadScope,
35 AuthRelayWriteScope,
36 AuthFilesystemReadScope,
37 AuthStandardClientScopes,
38 AuthTerminalReadScope,
39 type AuthClientSession,
40 type AuthEnvironmentScope,
41 type AuthGrantScope,
42 type AuthPairingLink,
43 type AuthPairingCredentialResult,
44 type AdvertisedEndpoint,
45 type DesktopDiscoveredSshHost,
46 type DesktopSshEnvironmentTarget,
47 type DesktopServerExposureState,
48 type DesktopWslState,
49 type EnvironmentId,
50 resolveEnvironmentMachineKind,
51} from "@t3tools/contracts";
52import {
53 RelayConnectionRegistration,
54 RelayConnectionTarget,
55 connectionRoutes,
56 connectionStatusText,
57 environmentMcpUrl,
58} from "@t3tools/client-runtime/connection";
59import {
60 isAtomCommandInterrupted,
61 squashAtomCommandFailure,
62} from "@t3tools/client-runtime/state/runtime";
63import * as DateTime from "effect/DateTime";
64import * as Option from "effect/Option";
65
66import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
67import { cn } from "../../lib/utils";
68import { isLocalEnvironmentDisabled } from "../../localEnvironment";
69import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
70import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
71import {
72 applyWslEnableSelection,
73 canRevokeOtherClients,
74 isQrShareableEndpoint,
75 isWslSettingsRowVisible,
76 selectQrEndpointOption,
77 togglePairingScopeSelection,
78} from "./ConnectionsSettings.logic";
79import {
80 SettingsPageContainer,
81 SettingsRow,
82 SettingsSection,
83 useRelativeTimeTick,
84} from "./settingsLayout";
85import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting";
86import { searchableSetting } from "./settingsSearch";
87import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
88import { EnvironmentRoutesList } from "./EnvironmentRoutesList";
89import { usePreparedConnection } from "~/state/session";
90import {
91 EnvironmentRow,
92 environmentTransportLabel,
93 formatDesktopSshTarget,
94} from "./EnvironmentRow";
95import { FoldedSettingsSection } from "./FoldedSettingsSection";
96import { LoadBalancingSettings } from "./LoadBalancingSettings";
97import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
98import { Input } from "../ui/input";
99import { CommandShortcut } from "../ui/command";
100import {
101 Autocomplete,
102 AutocompleteEmpty,
103 AutocompleteInput,
104 AutocompleteItem,
105 AutocompleteList,
106 AutocompletePopup,
107} from "../ui/autocomplete";
108import { Checkbox } from "../ui/checkbox";
109import {
110 Dialog,
111 DialogClose,
112 DialogFooter,
113 DialogDescription,
114 DialogHeader,
115 DialogPanel,
116 DialogPopup,
117 DialogTitle,
118 DialogTrigger,
119} from "../ui/dialog";
120import { ScrollArea } from "../ui/scroll-area";
121import {
122 AlertDialog,
123 AlertDialogClose,
124 AlertDialogDescription,
125 AlertDialogFooter,
126 AlertDialogHeader,
127 AlertDialogPopup,
128 AlertDialogTitle,
129} from "../ui/alert-dialog";
130import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
131import { QRCodeSvg } from "../ui/qr-code";
132import { Spinner } from "../ui/spinner";
133import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
134import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
135import { Switch } from "../ui/switch";
136import { stackedThreadToast, toastManager } from "../ui/toast";
137import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
138import { Alert, AlertDescription } from "../ui/alert";
139import { Button } from "../ui/button";
140import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
141import { AnimatedHeight } from "../AnimatedHeight";
142import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
143import { Textarea } from "../ui/textarea";
144import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
145import { readHostedPairingRequest } from "../../hostedPairing";
146import {
147 createServerPairingCredential,
148 revokeOtherServerClientSessions,
149 revokeServerClientSession,
150 revokeServerPairingLink,
151 isLoopbackHostname,
152 usePrimarySessionState,
153 type ServerClientSessionRecord,
154 type ServerPairingLinkRecord,
155} from "~/environments/primary";
156import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
157import { useUiStateStore } from "~/uiStateStore";
158import {
159 resolveServerConfigVersionMismatch,
160 resolveServerSelfUpdateCapability,
161 supportsDesktopAppUpdate,
162 supportsServerUpdateThreadContinuation,
163} from "~/versionSkew";
164import { hasCloudPublicConfig } from "~/cloud/publicConfig";
165import { RemoveT3ConnectEnvironmentDialog } from "../clerk/RemoveT3ConnectEnvironmentDialog";
166import { useCloudLinkController } from "~/cloud/useCloudLinkController";
167import { authEnvironment } from "~/state/auth";
168import { environmentCatalog } from "~/connection/catalog";
169import {
170 connectPairing as connectPairingAtom,
171 connectSshEnvironment as connectSshEnvironmentAtom,
172} from "~/connection/onboarding";
173import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
174import { useEnvironmentQuery } from "~/state/query";
175import {
176 desktopNetworkAccessStateAtom,
177 refreshDesktopNetworkAccessState,
178} from "~/state/desktopNetworkAccess";
179import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts";
180import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState";
181import {
182 type EnvironmentPresentation,
183 useEnvironments,
184 usePrimaryEnvironment,
185 useRelayEnvironmentDiscovery,
186 usePrimaryEnvironmentId,
187} from "~/state/environments";
188import { APP_VERSION } from "~/branding";
189import { requestConfirmDialog } from "~/confirmDialog";
190import { useAtomCommand } from "../../state/use-atom-command";
191import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
192import { ConnectionStatusDot } from "../ConnectionStatusDot";
193import {
194 OutdatedServerUpdateAction,
195 ServerUpdateAction,
196 ServerUpdateProgress,
197 ServerUpdatesAction,
198 type ServerUpdateTarget,
199} from "../ServerUpdateAction";
200import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
201import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
202import {
203 resolveShortcutCommand,
204 shortcutLabelForCommand,
205 threadJumpCommandForIndex,
206 threadJumpIndexFromCommand,
207} from "../../keybindings";
208
209const DEFAULT_TAILSCALE_SERVE_PORT = 443;
210const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];
211const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray<DesktopDiscoveredSshHost> = [];
212
213// Sentinels for the consolidated WSL backend picker. The colon is
214// rejected by DISTRO_NAME_PATTERN (validated on the desktop side) so
215// neither can collide with a real distro name.
216const BACKEND_VALUE_DEFAULT_WSL = "backend:default-wsl";
217const BACKEND_VALUE_WSL_OFF = "backend:wsl-off";
218
219const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {
220 dateStyle: "medium",
221 timeStyle: "short",
222});
223
224function formatAccessTimestamp(value: string): string {
225 const parsed = new Date(value);
226 if (Number.isNaN(parsed.getTime())) {
227 return value;
228 }
229 return accessTimestampFormatter.format(parsed);
230}
231
232function AccessScopeSummary({
233 scopes,
234 label,
235}: {
236 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
237 readonly label: string;
238}) {
239 const scopeCountLabel = `${scopes.length} ${scopes.length === 1 ? "scope" : "scopes"}`;
240
241 return (
242 <Popover>
243 <PopoverTrigger
244 openOnHover
245 delay={250}
246 closeDelay={100}
247 render={
248 <button
249 type="button"
250 aria-label={`${label}: show ${scopeCountLabel}`}
251 className="cursor-help underline decoration-border underline-offset-2 outline-hidden hover:text-foreground focus-visible:text-foreground"
252 />
253 }
254 >
255 {scopeCountLabel}
256 </PopoverTrigger>
257 <PopoverPopup
258 side="top"
259 align="start"
260 tooltipStyle
261 className="w-max max-w-80 whitespace-normal"
262 >
263 <p className="mb-1 font-medium">Granted scopes</p>
264 <div className="flex flex-col gap-0.5">
265 {scopes.map((scope) => (
266 <code key={scope} className="font-mono text-foreground/85">
267 {scope}
268 </code>
269 ))}
270 </div>
271 </PopoverPopup>
272 </Popover>
273 );
274}
275
276function parseManualDesktopSshTarget(input: {
277 readonly host: string;
278 readonly username: string;
279 readonly port: string;
280}): DesktopSshEnvironmentTarget {
281 const rawHost = input.host.trim();
282 if (rawHost.length === 0) {
283 throw new Error("SSH host or alias is required.");
284 }
285
286 let hostname = rawHost;
287 let username = input.username.trim() || null;
288 let port: number | null = null;
289
290 const atIndex = hostname.lastIndexOf("@");
291 if (atIndex > 0) {
292 const inlineUsername = hostname.slice(0, atIndex).trim();
293 hostname = hostname.slice(atIndex + 1).trim();
294 if (!username && inlineUsername.length > 0) {
295 username = inlineUsername;
296 }
297 }
298
299 const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname);
300 if (bracketedHostMatch) {
301 hostname = bracketedHostMatch[1]!.trim();
302 if (bracketedHostMatch[2]) {
303 port = Number.parseInt(bracketedHostMatch[2], 10);
304 }
305 } else {
306 const colonSegments = hostname.split(":");
307 if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) {
308 hostname = colonSegments[0]!.trim();
309 port = Number.parseInt(colonSegments[1]!, 10);
310 }
311 }
312
313 const rawPort = input.port.trim();
314 if (rawPort.length > 0) {
315 port = Number.parseInt(rawPort, 10);
316 }
317
318 if (hostname.length === 0) {
319 throw new Error("SSH host or alias is required.");
320 }
321
322 if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) {
323 throw new Error("SSH port must be between 1 and 65535.");
324 }
325
326 return {
327 alias: hostname,
328 hostname,
329 username,
330 port,
331 };
332}
333
334function parsePairingUrlFields(
335 input: string,
336): { readonly host: string; readonly pairingCode: string } | null {
337 const trimmed = input.trim();
338 if (!trimmed) return null;
339
340 try {
341 const urlLikeInput =
342 /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//")
343 ? trimmed
344 : `https://${trimmed}`;
345 const url = new URL(urlLikeInput, window.location.origin);
346 const hostedPairingRequest = readHostedPairingRequest(url);
347 if (hostedPairingRequest) {
348 return {
349 host: hostedPairingRequest.host,
350 pairingCode: hostedPairingRequest.token,
351 };
352 }
353
354 const pairingCode = getPairingTokenFromUrl(url);
355 if (!pairingCode) return null;
356 return {
357 host: url.origin,
358 pairingCode,
359 };
360 } catch {
361 return null;
362 }
363}
364
365function parseRemotePairingFields(input: { readonly host: string; readonly pairingCode: string }): {
366 readonly host: string;
367 readonly pairingCode: string;
368} {
369 const parsedPairingUrl = parsePairingUrlFields(input.host);
370 if (parsedPairingUrl) return parsedPairingUrl;
371
372 const host = input.host.trim();
373 const pairingCode = input.pairingCode.trim();
374 if (!host) {
375 throw new Error("Enter a backend host.");
376 }
377 if (!pairingCode) {
378 throw new Error("Enter a pairing code.");
379 }
380 return { host, pairingCode };
381}
382
383function formatDesktopSshConnectionError(error: unknown): string {
384 const fallback = "Failed to connect SSH host.";
385 const rawMessage = error instanceof Error ? error.message : fallback;
386 const withoutIpcPrefix = rawMessage.replace(
387 /^Error invoking remote method 'desktop:ensure-ssh-environment':\s*/u,
388 "",
389 );
390 const withoutTaggedErrorPrefix = withoutIpcPrefix.replace(/^Ssh[A-Za-z]+Error:\s*/u, "");
391 return withoutTaggedErrorPrefix.trim() || fallback;
392}
393
394const ENDPOINT_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-2.5 sm:px-4";
395
396type AccessSectionPresentation = "current" | "endpoint-rail";
397
398function accessRowClassName(_presentation: AccessSectionPresentation) {
399 return ITEM_ROW_CLASSNAME;
400}
401
402function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) {
403 if (presentation === "endpoint-rail") {
404 return cn(
405 "relative first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4",
406 !isAvailable && "bg-muted/15",
407 );
408 }
409
410 return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24");
411}
412
413function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) {
414 return [...links].toSorted(
415 (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
416 );
417}
418
419/** Closed-header summary for the Authorized clients fold. */
420function summarizeAuthorizedClients(
421 sessions: ReadonlyArray<ServerClientSessionRecord>,
422 links: ReadonlyArray<ServerPairingLinkRecord>,
423): string {
424 const parts = [
425 `${sessions.length} ${sessions.length === 1 ? "client" : "clients"}`,
426 links.length > 0
427 ? `${links.length} ${links.length === 1 ? "pairing link" : "pairing links"}`
428 : null,
429 ];
430 return parts.filter((part): part is string => part !== null).join(" · ");
431}
432
433function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRecord>) {
434 return [...sessions].toSorted((left, right) => {
435 if (left.current !== right.current) {
436 return left.current ? -1 : 1;
437 }
438 if (left.connected !== right.connected) {
439 return left.connected ? -1 : 1;
440 }
441 return new Date(right.issuedAt).getTime() - new Date(left.issuedAt).getTime();
442 });
443}
444
445function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord {
446 return {
447 ...pairingLink,
448 scopes: pairingLink.permissions ?? pairingLink.scopes,
449 createdAt: DateTime.formatIso(pairingLink.createdAt),
450 expiresAt: DateTime.formatIso(pairingLink.expiresAt),
451 };
452}
453
454function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord {
455 return {
456 ...clientSession,
457 scopes: clientSession.permissions ?? clientSession.scopes,
458 issuedAt: DateTime.formatIso(clientSession.issuedAt),
459 expiresAt: DateTime.formatIso(clientSession.expiresAt),
460 lastConnectedAt:
461 clientSession.lastConnectedAt === null
462 ? null
463 : DateTime.formatIso(clientSession.lastConnectedAt),
464 };
465}
466
467function selectPairingEndpoint(
468 endpoints: ReadonlyArray<AdvertisedEndpoint>,
469 defaultEndpointKey?: string | null,
470): AdvertisedEndpoint | null {
471 const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable");
472 if (defaultEndpointKey) {
473 const selectedEndpoint = availableEndpoints.find(
474 (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey,
475 );
476 if (selectedEndpoint) {
477 return selectedEndpoint;
478 }
479 }
480 return (
481 availableEndpoints.find((endpoint) => endpoint.isDefault) ??
482 availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ??
483 availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ??
484 null
485 );
486}
487
488function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean {
489 return endpoint.id.startsWith("tailscale-magicdns:");
490}
491
492function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string {
493 if (endpoint.id.startsWith("desktop-loopback:")) {
494 return "desktop-core:loopback:http";
495 }
496 if (endpoint.id.startsWith("desktop-lan:")) {
497 return "desktop-core:lan:http";
498 }
499 if (endpoint.id.startsWith("tailscale-ip:")) {
500 return "tailscale:ip:http";
501 }
502 if (isTailscaleHttpsEndpoint(endpoint)) {
503 return "tailscale:magicdns:https";
504 }
505
506 let scheme = "unknown";
507 try {
508 scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, "");
509 } catch {
510 // Keep the stored preference stable even if a custom endpoint is malformed.
511 }
512
513 return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`;
514}
515
516function resolveAdvertisedEndpointPairingUrl(
517 endpoint: AdvertisedEndpoint,
518 credential: string,
519): string {
520 if (endpoint.compatibility.hostedHttpsApp === "compatible") {
521 return (
522 resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ??
523 resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential)
524 );
525 }
526 return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential);
527}
528
529function resolveCurrentOriginPairingUrl(credential: string): string {
530 const url = new URL("/pair", window.location.href);
531 return setPairingTokenOnUrl(url, credential).toString();
532}
533
534function isHostedAppPairingUrl(value: string): boolean {
535 try {
536 const url = new URL(value);
537 return url.pathname === "/pair" && url.searchParams.has("host");
538 } catch {
539 return false;
540 }
541}
542
543function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string {
544 if (isHostedAppPairingUrl(url)) {
545 return "Opens the hosted app, no install needed";
546 }
547 switch (endpoint.reachability) {
548 case "lan":
549 return "Devices on the same network";
550 case "private-network":
551 return "Devices on your private network";
552 case "public":
553 return "Reachable from anywhere";
554 case "loopback":
555 return "Clients on this machine";
556 }
557}
558
559type PairingLinkListRowProps = {
560 pairingLink: ServerPairingLinkRecord;
561 credential: string | undefined;
562 endpointUrl: string | null | undefined;
563 endpoints: ReadonlyArray<AdvertisedEndpoint>;
564 defaultEndpointKey: string | null;
565 presentation?: AccessSectionPresentation;
566 revokingPairingLinkId: string | null;
567 onRevoke: (id: string) => void;
568 canRevoke: boolean;
569};
570
571const PairingLinkListRow = memo(function PairingLinkListRow({
572 pairingLink,
573 credential,
574 endpointUrl,
575 endpoints,
576 defaultEndpointKey,
577 presentation = "current",
578 revokingPairingLinkId,
579 onRevoke,
580 canRevoke,
581}: PairingLinkListRowProps) {
582 const nowMs = useRelativeTimeTick(1_000);
583 const expiresAtMs = useMemo(
584 () => new Date(pairingLink.expiresAt).getTime(),
585 [pairingLink.expiresAt],
586 );
587 const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false);
588 const [isQrPanelOpen, setIsQrPanelOpen] = useState(false);
589 // Ephemeral per-row choice of which endpoint the QR encodes (AdvertisedEndpoint.id);
590 // null falls back to the saved default endpoint.
591 const [qrEndpointId, setQrEndpointId] = useState<string | null>(null);
592 const qrPanelId = useId();
593
594 const currentOriginPairingUrl = useMemo(
595 () => (credential ? resolveCurrentOriginPairingUrl(credential) : null),
596 [credential],
597 );
598 const hostedPairingUrl = useMemo(
599 () =>
600 credential && endpointUrl != null && endpointUrl !== ""
601 ? resolveHostedPairingUrl(endpointUrl, credential)
602 : null,
603 [endpointUrl, credential],
604 );
605 const endpointPairingUrl = useMemo(() => {
606 const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey);
607 return endpoint && credential
608 ? resolveAdvertisedEndpointPairingUrl(endpoint, credential)
609 : null;
610 }, [defaultEndpointKey, endpoints, credential]);
611 const endpointCopyOptions = useMemo(() => {
612 const options: Array<{
613 readonly id: string;
614 readonly preferenceKey: string;
615 readonly label: string;
616 readonly url: string;
617 readonly detail: string;
618 readonly qrShareable: boolean;
619 }> = [];
620 if (!credential) return options;
621 for (const endpoint of endpoints) {
622 if (endpoint.status === "unavailable") {
623 continue;
624 }
625 const url = resolveAdvertisedEndpointPairingUrl(endpoint, credential);
626 options.push({
627 id: endpoint.id,
628 preferenceKey: endpointDefaultPreferenceKey(endpoint),
629 label: endpoint.label,
630 url,
631 detail: endpointShareHint(endpoint, url),
632 qrShareable: isQrShareableEndpoint(endpoint),
633 });
634 }
635 return options;
636 }, [endpoints, credential]);
637 const shareablePairingUrl =
638 endpointPairingUrl ??
639 (credential && endpointUrl != null && endpointUrl !== ""
640 ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, credential))
641 : isLoopbackHostname(window.location.hostname)
642 ? null
643 : currentOriginPairingUrl);
644 // Value of the copy attempt that last failed. The clipboard-failure reveal
645 // dialog must show exactly what failed to copy, not the row's default URL.
646 const [failedCopyValue, setFailedCopyValue] = useState<string | null>(null);
647 const revealValue = failedCopyValue ?? shareablePairingUrl ?? credential ?? "";
648 const isRevealValueUrl = revealValue !== credential;
649 const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue);
650 // Never render a QR for a loopback URL, even in the manual-copy fallback.
651 const isRevealValueQrShareable =
652 endpointCopyOptions.find((option) => option.url === revealValue)?.qrShareable ?? true;
653 const canCopyToClipboard =
654 typeof window !== "undefined" &&
655 window.isSecureContext &&
656 navigator.clipboard?.writeText != null;
657
658 const { copyToClipboard } = useCopyToClipboard<{
659 value: string;
660 kind: "code" | "hosted-link" | "link";
661 }>({
662 onCopy: ({ kind }) => {
663 toastManager.add({
664 type: "success",
665 title:
666 kind === "hosted-link"
667 ? "Hosted app link copied"
668 : kind === "link"
669 ? "Pairing URL copied"
670 : "Pairing code copied",
671 description:
672 kind === "hosted-link"
673 ? "Open it in the browser on the device you want to connect."
674 : kind === "link"
675 ? "Open it in the client you want to pair to this environment."
676 : "Paste it into another client to finish pairing.",
677 });
678 },
679 onError: (error, { value, kind }) => {
680 // Captured per attempt so concurrent copies cannot make the dialog
681 // reveal a different value than the one that failed.
682 setFailedCopyValue(value);
683 setIsRevealDialogOpen(true);
684 toastManager.add(
685 stackedThreadToast({
686 type: "error",
687 title: canCopyToClipboard
688 ? kind === "hosted-link"
689 ? "Could not copy hosted app link"
690 : kind === "link"
691 ? "Could not copy pairing URL"
692 : "Could not copy pairing code"
693 : "Clipboard copy unavailable",
694 description: canCopyToClipboard ? error.message : "Showing the full value instead.",
695 }),
696 );
697 },
698 });
699
700 const copyPairingValue = useCallback(
701 (value: string, kind: "code" | "hosted-link" | "link") => {
702 copyToClipboard(value, { value, kind });
703 },
704 [copyToClipboard],
705 );
706
707 const copyKindForUrl = useCallback(
708 (url: string): "hosted-link" | "link" => (isHostedAppPairingUrl(url) ? "hosted-link" : "link"),
709 [],
710 );
711
712 const handleCopyCode = useCallback(() => {
713 if (credential) copyPairingValue(credential, "code");
714 }, [copyPairingValue, credential]);
715
716 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
717
718 const primaryLabel = pairingLink.label ?? "Pairing link";
719 const selectedQrOption = selectQrEndpointOption(
720 endpointCopyOptions,
721 qrEndpointId,
722 defaultEndpointKey,
723 );
724 const qrPairingUrl = selectedQrOption?.url ?? shareablePairingUrl;
725 // With no endpoint list the fallback is never loopback: selectPairingEndpoint
726 // skips loopback and the current-origin fallback is guarded by
727 // isLoopbackHostname, so only an explicit loopback selection hides the QR.
728 const canRenderQrForSelection = selectedQrOption?.qrShareable ?? true;
729 if (expiresAtMs <= nowMs) {
730 return null;
731 }
732
733 return (
734 <div className={accessRowClassName(presentation)}>
735 <div className={ITEM_ROW_INNER_CLASSNAME}>
736 <div className="min-w-0 flex-1 space-y-1">
737 <div className="flex min-h-5 items-center gap-1.5">
738 <ConnectionStatusDot
739 tooltipText={`Link created at ${formatAccessTimestamp(pairingLink.createdAt)}`}
740 dotClassName="bg-warning"
741 />
742 <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3>
743 </div>
744 <p className="text-xs text-muted-foreground">
745 <Tooltip>
746 <TooltipTrigger render={<span />}>
747 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
748 </TooltipTrigger>
749 <TooltipPopup side="top">{expiresAbsolute}</TooltipPopup>
750 </Tooltip>
751 <span aria-hidden> · </span>
752 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />
753 </p>
754 {!credential ? (
755 <p className="text-2xs text-muted-foreground/70">
756 Create a new link to share from this client.
757 </p>
758 ) : shareablePairingUrl === null ? (
759 <p className="text-2xs text-muted-foreground/70">
760 Copy the token and pair from another client using this backend&apos;s reachable host.
761 </p>
762 ) : null}
763 </div>
764 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
765 {shareablePairingUrl && canCopyToClipboard ? (
766 <Button
767 size="xs"
768 variant="outline"
769 aria-expanded={isQrPanelOpen}
770 aria-controls={qrPanelId}
771 onClick={() => setIsQrPanelOpen((open) => !open)}
772 >
773 <QrCodeIcon aria-hidden />
774 Share
775 </Button>
776 ) : null}
777 <Dialog
778 open={credential !== undefined && isRevealDialogOpen}
779 onOpenChange={(open) => {
780 setIsRevealDialogOpen(open);
781 if (!open) setFailedCopyValue(null);
782 }}
783 >
784 {!credential ? null : canCopyToClipboard ? (
785 shareablePairingUrl ? null : (
786 <Button size="xs" variant="outline" onClick={handleCopyCode}>
787 Copy code
788 </Button>
789 )
790 ) : (
791 <DialogTrigger render={<Button size="xs" variant="outline" />}>
792 {shareablePairingUrl ? "Show link" : "Show code"}
793 </DialogTrigger>
794 )}
795 <DialogPopup className="max-w-md">
796 <DialogHeader>
797 <DialogTitle>
798 {isRevealValueUrl
799 ? isRevealValueHostedAppPairingUrl
800 ? "Hosted app pairing link"
801 : "Pairing link"
802 : "Pairing code"}
803 </DialogTitle>
804 <DialogDescription>
805 {isRevealValueUrl
806 ? isRevealValueHostedAppPairingUrl
807 ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect."
808 : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect."
809 : "Clipboard copy is unavailable here. Manually copy this code into another client."}
810 </DialogDescription>
811 </DialogHeader>
812 <DialogPanel>
813 <Textarea
814 readOnly
815 value={revealValue}
816 rows={isRevealValueUrl ? 4 : 3}
817 onFocus={(event) => event.currentTarget.select()}
818 onClick={(event) => event.currentTarget.select()}
819 />
820 {isRevealValueUrl && isRevealValueQrShareable ? (
821 <div className="flex justify-center rounded-xl border border-border/60 bg-muted/30 p-4">
822 <QRCodeSvg
823 value={revealValue}
824 size={132}
825 level="M"
826 marginSize={2}
827 title="Pairing link — scan to open on another device"
828 />
829 </div>
830 ) : null}
831 </DialogPanel>
832 <DialogFooter variant="bare">
833 <Button variant="outline" onClick={() => setIsRevealDialogOpen(false)}>
834 Done
835 </Button>
836 {canCopyToClipboard ? (
837 <Button variant="outline" onClick={handleCopyCode}>
838 Copy code
839 </Button>
840 ) : null}
841 </DialogFooter>
842 </DialogPopup>
843 </Dialog>
844 <Button
845 size="xs"
846 variant="destructive-outline"
847 disabled={!canRevoke || revokingPairingLinkId === pairingLink.id}
848 onClick={() => void onRevoke(pairingLink.id)}
849 >
850 {revokingPairingLinkId === pairingLink.id ? "Revoking…" : "Revoke"}
851 </Button>
852 </div>
853 </div>
854 {isQrPanelOpen && qrPairingUrl !== null ? (
855 <div
856 id={qrPanelId}
857 className="mt-3 flex flex-col gap-4 border-t border-border/50 pt-3 sm:flex-row sm:items-start sm:justify-between"
858 >
859 <div className="min-w-0 flex-1 space-y-3">
860 {endpointCopyOptions.length > 1 ? (
861 <div
862 className="space-y-1.5"
863 role="radiogroup"
864 aria-label="Endpoint the pairing QR code and URL use"
865 >
866 <p className="text-2xs text-muted-foreground/70">Reach this machine via</p>
867 {endpointCopyOptions.map((option) => {
868 const isSelected = option.id === selectedQrOption?.id;
869 return (
870 <button
871 key={option.id}
872 type="button"
873 role="radio"
874 aria-checked={isSelected}
875 className={cn(
876 "flex w-full items-baseline gap-2 rounded-lg border px-2.5 py-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
877 isSelected
878 ? "border-foreground/60 bg-muted/30"
879 : "border-border/50 hover:bg-muted/20",
880 )}
881 onClick={() => setQrEndpointId(option.id)}
882 >
883 <span
884 className={cn(
885 "text-xs font-medium",
886 isSelected ? "text-foreground" : "text-muted-foreground",
887 )}
888 >
889 {option.label}
890 </span>
891 <span className="min-w-0 truncate text-2xs text-muted-foreground/70">
892 {option.detail}
893 </span>
894 </button>
895 );
896 })}
897 </div>
898 ) : null}
899 <div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-2.5 py-1.5">
900 <Tooltip>
901 <TooltipTrigger
902 render={
903 <code className="min-w-0 flex-1 truncate font-mono text-2xs text-muted-foreground">
904 {qrPairingUrl}
905 </code>
906 }
907 />
908 <TooltipPopup side="top">{qrPairingUrl}</TooltipPopup>
909 </Tooltip>
910 <Button
911 size="xs"
912 variant="ghost"
913 className="shrink-0"
914 onClick={() => copyPairingValue(qrPairingUrl, copyKindForUrl(qrPairingUrl))}
915 >
916 Copy link
917 </Button>
918 </div>
919 <Button size="xs" variant="ghost" onClick={handleCopyCode}>
920 Copy code only
921 </Button>
922 </div>
923 {canRenderQrForSelection ? (
924 <div className="w-fit shrink-0 self-center rounded-xl bg-white p-3 sm:self-start">
925 <QRCodeSvg
926 value={qrPairingUrl}
927 size={168}
928 level="M"
929 marginSize={1}
930 title="Pairing link — scan to open on another device"
931 />
932 </div>
933 ) : (
934 <div className="flex size-[192px] shrink-0 items-center justify-center self-center rounded-xl border border-border/50 p-4 sm:self-start">
935 <p className="text-center text-2xs text-muted-foreground/70">
936 No QR for this endpoint. Another device scanning a loopback link would dial itself;
937 copy the URL for use on this machine instead.
938 </p>
939 </div>
940 )}
941 </div>
942 ) : null}
943 </div>
944 );
945});
946
947type ConnectedClientListRowProps = {
948 clientSession: ServerClientSessionRecord;
949 presentation?: AccessSectionPresentation;
950 revokingClientSessionId: string | null;
951 onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;
952 canRevoke: boolean;
953};
954
955const ConnectedClientListRow = memo(function ConnectedClientListRow({
956 clientSession,
957 presentation = "current",
958 revokingClientSessionId,
959 onRevokeSession,
960 canRevoke,
961}: ConnectedClientListRowProps) {
962 const nowMs = useRelativeTimeTick(1_000);
963 const isLive = clientSession.current || clientSession.connected;
964 const lastConnectedAt = clientSession.lastConnectedAt;
965 const statusTooltip = isLive
966 ? lastConnectedAt
967 ? `Connected for ${formatElapsedDurationLabel(lastConnectedAt, nowMs)}`
968 : "Connected"
969 : lastConnectedAt
970 ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}`
971 : "Not connected yet.";
972 const deviceInfoBits = [
973 clientSession.client.deviceType !== "unknown"
974 ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1)
975 : null,
976 clientSession.client.os ?? null,
977 clientSession.client.browser ?? null,
978 clientSession.client.ipAddress ?? null,
979 ].filter((value): value is string => value !== null);
980 const primaryLabel =
981 clientSession.client.label ??
982 ([clientSession.client.os, clientSession.client.browser].filter(Boolean).join(" · ") ||
983 clientSession.subject);
984
985 return (
986 <div className={accessRowClassName(presentation)}>
987 <div className={ITEM_ROW_INNER_CLASSNAME}>
988 <div className="min-w-0 flex-1 space-y-1">
989 <div className="flex min-h-5 items-center gap-1.5">
990 <ConnectionStatusDot
991 tooltipText={statusTooltip}
992 dotClassName={isLive ? "bg-success" : "bg-muted-foreground/30"}
993 pingClassName={isLive ? "bg-success/60 duration-2000" : null}
994 />
995 <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3>
996 {clientSession.current ? (
997 <span className="text-3xs text-muted-foreground/80 rounded-md border border-border/50 bg-muted/50 px-1 py-0.5">
998 This device
999 </span>
1000 ) : null}
1001 </div>
1002 <p className="text-xs text-muted-foreground">
1003 {deviceInfoBits.length > 0 ? (
1004 <>
1005 {deviceInfoBits.join(" · ")}
1006 <span aria-hidden> · </span>
1007 </>
1008 ) : null}
1009 <AccessScopeSummary scopes={clientSession.scopes} label="Client scopes" />
1010 </p>
1011 </div>
1012 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
1013 {!clientSession.current ? (
1014 <Button
1015 size="xs"
1016 variant="destructive-outline"
1017 disabled={!canRevoke || revokingClientSessionId === clientSession.sessionId}
1018 onClick={() => void onRevokeSession(clientSession.sessionId)}
1019 >
1020 {revokingClientSessionId === clientSession.sessionId ? "Revoking…" : "Revoke"}
1021 </Button>
1022 ) : null}
1023 </div>
1024 </div>
1025 </div>
1026 );
1027});
1028
1029type AuthorizedClientsHeaderActionProps = {
1030 onPairingLinkCreated: (result: AuthPairingCredentialResult) => void;
1031 clientSessions: ReadonlyArray<ServerClientSessionRecord> | null;
1032 isRevokingOtherClients: boolean;
1033 onRevokeOtherClients: () => void;
1034 delegatableScopes: ReadonlyArray<AuthEnvironmentScope>;
1035};
1036
1037const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({
1038 onPairingLinkCreated,
1039 clientSessions,
1040 isRevokingOtherClients,
1041 onRevokeOtherClients,
1042 delegatableScopes,
1043}: AuthorizedClientsHeaderActionProps) {
1044 const primaryEnvironmentId = usePrimaryEnvironmentId();
1045 const [dialogOpen, setDialogOpen] = useState(false);
1046 const [pairingLabel, setPairingLabel] = useState("");
1047 const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthGrantScope>>([
1048 ...AuthStandardClientScopes,
1049 ]);
1050 const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope));
1051 const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false);
1052
1053 const handleCreatePairingLink = useCallback(async () => {
1054 if (
1055 primaryEnvironmentId === null ||
1056 selectedScopes.length === 0 ||
1057 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope) ||
1058 !selectedScopes.every((scope) => readEnvironmentScope(primaryEnvironmentId, scope))
1059 )
1060 return;
1061 setIsCreatingPairingLink(true);
1062 try {
1063 const created = await createServerPairingCredential({
1064 label: pairingLabel,
1065 scopes: selectedScopes,
1066 });
1067 onPairingLinkCreated(created);
1068 setPairingLabel("");
1069 setPairingScopes(
1070 AuthStandardClientScopes.filter((scope) => delegatableScopes.includes(scope)),
1071 );
1072 setDialogOpen(false);
1073 } catch (error) {
1074 const message = error instanceof Error ? error.message : "Failed to create pairing URL.";
1075 toastManager.add(
1076 stackedThreadToast({
1077 type: "error",
1078 title: "Could not create pairing URL",
1079 description: message,
1080 }),
1081 );
1082 } finally {
1083 setIsCreatingPairingLink(false);
1084 }
1085 }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]);
1086
1087 const togglePairingScope = useCallback((scope: AuthGrantScope, checked: boolean) => {
1088 setPairingScopes((current) => togglePairingScopeSelection(current, scope, checked));
1089 }, []);
1090
1091 return (
1092 <div className="flex items-center gap-2">
1093 <Button
1094 size="xs"
1095 variant="destructive-outline"
1096 disabled={isRevokingOtherClients || !canRevokeOtherClients(clientSessions)}
1097 onClick={() => void onRevokeOtherClients()}
1098 >
1099 {isRevokingOtherClients ? "Revoking…" : "Revoke others"}
1100 </Button>
1101 <Dialog
1102 open={dialogOpen}
1103 onOpenChange={(open) => {
1104 setDialogOpen(open);
1105 if (!open) {
1106 setPairingLabel("");
1107 setPairingScopes(
1108 AuthStandardClientScopes.filter((scope) => delegatableScopes.includes(scope)),
1109 );
1110 }
1111 }}
1112 >
1113 <DialogTrigger
1114 render={
1115 <Button size="xs" variant="default">
1116 <PlusIcon className="size-3" />
1117 Create link
1118 </Button>
1119 }
1120 />
1121 <DialogPopup className="max-w-md">
1122 <DialogHeader>
1123 <DialogTitle>Create pairing link</DialogTitle>
1124 <DialogDescription>
1125 Generate a one-time link that another device can use to pair with this backend as an
1126 authorized client.
1127 </DialogDescription>
1128 </DialogHeader>
1129 <DialogPanel>
1130 <label className="block">
1131 <span className="mb-1.5 block text-xs font-medium text-foreground">
1132 Client label (optional)
1133 </span>
1134 <Input
1135 value={pairingLabel}
1136 onChange={(event) => setPairingLabel(event.target.value)}
1137 placeholder="e.g. Living room iPad"
1138 disabled={isCreatingPairingLink}
1139 autoFocus
1140 />
1141 </label>
1142 <section className="space-y-3">
1143 <div className="flex items-center justify-between gap-3">
1144 <div>
1145 <h3 className="text-xs font-medium text-foreground">Permissions</h3>
1146 <p className="text-xs text-muted-foreground">
1147 Limit what the paired client can do.
1148 </p>
1149 </div>
1150 <div className="flex gap-1">
1151 <Button
1152 size="xs"
1153 variant="outline"
1154 disabled={isCreatingPairingLink}
1155 onClick={() =>
1156 setPairingScopes(
1157 [
1158 AuthOrchestrationReadScope,
1159 AuthFilesystemReadScope,
1160 AuthDiagnosticsReadScope,
1161 AuthTerminalReadScope,
1162 ].filter((scope) => delegatableScopes.includes(scope)),
1163 )
1164 }
1165 >
1166 Read only
1167 </Button>
1168 <Button
1169 size="xs"
1170 variant="outline"
1171 disabled={isCreatingPairingLink}
1172 onClick={() =>
1173 setPairingScopes(
1174 AuthStandardClientScopes.filter((scope) =>
1175 delegatableScopes.includes(scope),
1176 ),
1177 )
1178 }
1179 >
1180 Standard
1181 </Button>
1182 </div>
1183 </div>
1184 <div className="divide-y divide-border/60 rounded-lg border border-input bg-muted/25">
1185 {PAIRING_SCOPE_OPTIONS.filter(({ scope }) => delegatableScopes.includes(scope)).map(
1186 ({ scope, title, description }) => (
1187 <label
1188 key={scope}
1189 className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
1190 >
1191 <Checkbox
1192 className="mt-0.5"
1193 checked={pairingScopes.includes(scope)}
1194 disabled={isCreatingPairingLink}
1195 onCheckedChange={(checked) => togglePairingScope(scope, checked === true)}
1196 />
1197 <span className="min-w-0">
1198 <span className="block text-xs font-medium text-foreground">{title}</span>
1199 <span className="block text-xs leading-snug text-muted-foreground">
1200 {description}
1201 </span>
1202 </span>
1203 </label>
1204 ),
1205 )}
1206 </div>
1207 {selectedScopes.length === 0 ? (
1208 <p className="text-xs text-destructive">Select at least one permission.</p>
1209 ) : selectedScopes.includes(AuthAccessWriteScope) ? (
1210 <p className="text-xs text-warning">
1211 This client can create or revoke access for other devices.
1212 </p>
1213 ) : null}
1214 </section>
1215 </DialogPanel>
1216 <DialogFooter variant="bare">
1217 <Button
1218 variant="outline"
1219 disabled={isCreatingPairingLink}
1220 onClick={() => setDialogOpen(false)}
1221 >
1222 Cancel
1223 </Button>
1224 <Button
1225 disabled={isCreatingPairingLink || selectedScopes.length === 0}
1226 onClick={() => void handleCreatePairingLink()}
1227 >
1228 {isCreatingPairingLink ? "Creating…" : "Create link"}
1229 </Button>
1230 </DialogFooter>
1231 </DialogPopup>
1232 </Dialog>
1233 </div>
1234 );
1235});
1236
1237type PairingClientsListProps = {
1238 endpointUrl: string | null | undefined;
1239 endpoints: ReadonlyArray<AdvertisedEndpoint>;
1240 defaultEndpointKey: string | null;
1241 presentation?: AccessSectionPresentation;
1242 isLoading: boolean;
1243 pairingLinks: ReadonlyArray<ServerPairingLinkRecord>;
1244 createdPairingCredentials: ReadonlyMap<string, string>;
1245 clientSessions: ReadonlyArray<ServerClientSessionRecord>;
1246 revokingPairingLinkId: string | null;
1247 revokingClientSessionId: string | null;
1248 onRevokePairingLink: (id: string) => void;
1249 onRevokeClientSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;
1250 canRevoke: boolean;
1251};
1252
1253const PairingClientsList = memo(function PairingClientsList({
1254 endpointUrl,
1255 endpoints,
1256 defaultEndpointKey,
1257 presentation = "current",
1258 isLoading,
1259 pairingLinks,
1260 createdPairingCredentials,
1261 clientSessions,
1262 revokingPairingLinkId,
1263 revokingClientSessionId,
1264 onRevokePairingLink,
1265 onRevokeClientSession,
1266 canRevoke,
1267}: PairingClientsListProps) {
1268 return (
1269 <>
1270 {pairingLinks.map((pairingLink) => (
1271 <PairingLinkListRow
1272 key={pairingLink.id}
1273 pairingLink={pairingLink}
1274 credential={createdPairingCredentials.get(pairingLink.id)}
1275 endpointUrl={endpointUrl}
1276 endpoints={endpoints}
1277 defaultEndpointKey={defaultEndpointKey}
1278 presentation={presentation}
1279 revokingPairingLinkId={revokingPairingLinkId}
1280 onRevoke={onRevokePairingLink}
1281 canRevoke={canRevoke}
1282 />
1283 ))}
1284
1285 {clientSessions.map((clientSession) => (
1286 <ConnectedClientListRow
1287 key={clientSession.sessionId}
1288 clientSession={clientSession}
1289 presentation={presentation}
1290 revokingClientSessionId={revokingClientSessionId}
1291 onRevokeSession={onRevokeClientSession}
1292 canRevoke={canRevoke}
1293 />
1294 ))}
1295
1296 {pairingLinks.length === 0 && clientSessions.length === 0 && !isLoading ? (
1297 <div className={accessRowClassName(presentation)}>
1298 <p className="text-xs text-muted-foreground/60">No pairing links or client sessions.</p>
1299 </div>
1300 ) : null}
1301 </>
1302 );
1303});
1304
1305type AdvertisedEndpointListRowProps = {
1306 endpoint: AdvertisedEndpoint;
1307 isDefault: boolean;
1308 presentation?: AccessSectionPresentation;
1309 onSetDefault: (endpoint: AdvertisedEndpoint) => void;
1310 onSetupTailscaleServe: (endpoint: AdvertisedEndpoint) => void;
1311 onDisableTailscaleServe: (endpoint: AdvertisedEndpoint) => void;
1312 isUpdatingTailscaleServe: boolean;
1313};
1314
1315const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({
1316 endpoint,
1317 isDefault,
1318 presentation = "current",
1319 onSetDefault,
1320 onSetupTailscaleServe,
1321 onDisableTailscaleServe,
1322 isUpdatingTailscaleServe,
1323}: AdvertisedEndpointListRowProps) {
1324 const isAvailable = endpoint.status === "available";
1325 const needsTailscaleSetup = isTailscaleHttpsEndpoint(endpoint) && endpoint.status !== "available";
1326 const canDisableTailscaleServe =
1327 isTailscaleHttpsEndpoint(endpoint) && endpoint.status === "available";
1328 const shouldShowEndpointUrl = !needsTailscaleSetup;
1329 const isEndpointRail = presentation === "endpoint-rail";
1330 return (
1331 <div className={endpointRowClassName(presentation, isAvailable)}>
1332 {isEndpointRail && isDefault ? (
1333 <span className="absolute inset-y-2 left-0 w-1 rounded-r-full bg-primary" aria-hidden />
1334 ) : null}
1335 <div className="flex min-h-6 min-w-0 flex-col gap-2 sm:-my-0.5 sm:flex-row sm:items-center">
1336 <div className="flex min-w-0 items-baseline gap-3">
1337 <h3 className="shrink-0 text-sm leading-5 font-medium text-foreground">
1338 {endpoint.label}
1339 </h3>
1340 {shouldShowEndpointUrl ? (
1341 <Tooltip>
1342 <TooltipTrigger
1343 render={
1344 <p className="min-w-0 truncate text-xs leading-5 text-muted-foreground">
1345 {endpoint.httpBaseUrl}
1346 </p>
1347 }
1348 />
1349 <TooltipPopup side="top">{endpoint.httpBaseUrl}</TooltipPopup>
1350 </Tooltip>
1351 ) : null}
1352 {!isAvailable ? (
1353 <span className="shrink-0 rounded-md border border-border/70 px-1 py-0.5 text-3xs text-muted-foreground">
1354 Setup required
1355 </span>
1356 ) : null}
1357 </div>
1358 <div className="ml-auto flex min-h-6 shrink-0 items-center justify-end gap-2">
1359 {isDefault ? (
1360 <span className="rounded-md border border-primary/30 bg-primary/10 px-1 py-0.5 text-3xs text-primary">
1361 Default
1362 </span>
1363 ) : null}
1364 {needsTailscaleSetup ? (
1365 <Button
1366 size="xs"
1367 variant="outline"
1368 onClick={() => onSetupTailscaleServe(endpoint)}
1369 disabled={isUpdatingTailscaleServe}
1370 >
1371 {isUpdatingTailscaleServe ? "Restarting…" : "Setup"}
1372 </Button>
1373 ) : null}
1374 {canDisableTailscaleServe ? (
1375 <Button
1376 size="xs"
1377 variant="destructive-outline"
1378 onClick={() => onDisableTailscaleServe(endpoint)}
1379 disabled={isUpdatingTailscaleServe}
1380 >
1381 {isUpdatingTailscaleServe ? "Restarting…" : "Disable"}
1382 </Button>
1383 ) : null}
1384 {!needsTailscaleSetup && !isDefault ? (
1385 <Button size="xs" variant="outline" onClick={() => onSetDefault(endpoint)}>
1386 Set as default
1387 </Button>
1388 ) : null}
1389 </div>
1390 </div>
1391 </div>
1392 );
1393});
1394
1395function NetworkAccessDescription({
1396 endpoint,
1397 hiddenEndpointCount,
1398 expanded,
1399 onToggleExpanded,
1400 fallback,
1401}: {
1402 endpoint: AdvertisedEndpoint | null;
1403 hiddenEndpointCount: number;
1404 expanded: boolean;
1405 onToggleExpanded: () => void;
1406 fallback: ReactNode;
1407}) {
1408 if (!endpoint) {
1409 return fallback;
1410 }
1411
1412 const summary = (
1413 <>
1414 <span className="min-w-0 truncate">{endpoint.httpBaseUrl}</span>
1415 {hiddenEndpointCount > 0 ? (
1416 <span className="shrink-0 text-xs font-medium">
1417 {expanded ? "Hide" : `+${hiddenEndpointCount}`}
1418 </span>
1419 ) : null}
1420 </>
1421 );
1422
1423 return (
1424 <span className="inline-flex min-w-0 max-w-full items-baseline gap-1">
1425 <span className="shrink-0">Reachable at</span>
1426 {hiddenEndpointCount > 0 ? (
1427 <button
1428 type="button"
1429 className="inline-flex min-w-0 max-w-full items-baseline gap-2 border-b border-dotted border-muted-foreground/60 text-left text-muted-foreground underline-offset-4 hover:border-foreground hover:text-foreground"
1430 onClick={onToggleExpanded}
1431 aria-expanded={expanded}
1432 >
1433 {summary}
1434 </button>
1435 ) : (
1436 <span className="inline-flex min-w-0 max-w-full items-baseline gap-2">{summary}</span>
1437 )}
1438 </span>
1439 );
1440}
1441
1442type SavedBackendListRowProps = {
1443 environment: EnvironmentPresentation;
1444 removingEnvironmentId: EnvironmentId | null;
1445 onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
1446 onRemove: (environment: EnvironmentPresentation) => void;
1447 onAddRoute: (environment: EnvironmentPresentation) => void;
1448};
1449
1450/**
1451 * Status word for a row subtitle: "Reconnecting: <reason>" instead of the
1452 * long-form sentence, since the row has one line and the full text is one
1453 * hover away.
1454 */
1455function savedBackendStatus(environment: EnvironmentPresentation): {
1456 readonly text: string;
1457 readonly tone: "muted" | "error";
1458} {
1459 if (!environment.entry.enabled && environment.connection.phase !== "unsupported")
1460 return { text: "Off", tone: "muted" };
1461 const { connection } = environment;
1462 switch (connection.phase) {
1463 case "connected":
1464 return { text: "Connected", tone: "muted" };
1465 case "connecting":
1466 return { text: "Connecting", tone: "muted" };
1467 case "reconnecting":
1468 return {
1469 text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting",
1470 tone: "error",
1471 };
1472 // Not a failure: the machine is fine, this build just cannot talk to it.
1473 case "unsupported":
1474 return { text: "Client not supported", tone: "muted" };
1475 case "error":
1476 return {
1477 text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed",
1478 tone: "error",
1479 };
1480 case "offline":
1481 return { text: "Offline", tone: "muted" };
1482 case "available":
1483 return { text: "Not connected", tone: "muted" };
1484 }
1485}
1486
1487/**
1488 * One added machine in the Environments list. The switch is the main action;
1489 * the update icon appears only when that machine can take an update; the
1490 * row menu holds the icon override, trace ID, and removal.
1491 */
1492function SavedBackendListRow({
1493 environment,
1494 removingEnvironmentId,
1495 onSetEnabled,
1496 onRemove,
1497 onAddRoute,
1498}: SavedBackendListRowProps) {
1499 const [routesOpen, setRoutesOpen] = useState(false);
1500 const [permissionsOpen, setPermissionsOpen] = useState(false);
1501 const environmentId = environment.environmentId;
1502 const unsupported = environment.connection.phase === "unsupported";
1503 const enabled = environment.entry.enabled && !unsupported;
1504 const isConnected = environment.connection.phase === "connected";
1505 const isRemoving = removingEnvironmentId === environmentId;
1506 const errorTraceId = environment.connection.traceId;
1507 const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({
1508 target: "trace ID",
1509 onCopy: ({ traceId }) => {
1510 toastManager.add({
1511 type: "success",
1512 title: "Trace ID copied",
1513 description: traceId,
1514 });
1515 },
1516 onError: (error) => {
1517 toastManager.add(
1518 stackedThreadToast({
1519 type: "error",
1520 title: "Could not copy trace ID",
1521 description: error.message,
1522 }),
1523 );
1524 },
1525 });
1526 const copyTraceId = useCallback(
1527 (traceId: string) => {
1528 copyTraceIdToClipboard(traceId, { traceId });
1529 },
1530 [copyTraceIdToClipboard],
1531 );
1532 const { copyToClipboard: copyMcpUrl } = useCopyToClipboard<{ url: string }>({
1533 target: "MCP URL",
1534 onCopy: ({ url }) => {
1535 toastManager.add({
1536 type: "success",
1537 title: "MCP URL copied",
1538 description: `Add it to an agent, e.g. claude mcp add --transport http t3 ${url}`,
1539 });
1540 },
1541 onError: (error) => {
1542 toastManager.add(
1543 stackedThreadToast({
1544 type: "error",
1545 title: "Could not copy MCP URL",
1546 description: error.message,
1547 }),
1548 );
1549 },
1550 });
1551 const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
1552 const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
1553 const resumingServerUpdate =
1554 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
1555 const status = savedBackendStatus(environment);
1556 const serverVersion = environment.serverConfig?.environment.serverVersion ?? null;
1557 // A saved T3 Connect machine this device has never reached (unsupported,
1558 // or not yet connected) still has a descriptor from relay discovery, so
1559 // it can wear its detected glyph instead of the generic server. Discovery
1560 // empties its map on every refresh, so hold the last descriptor seen or
1561 // the glyph would blink back to the generic one each time.
1562 const relayDiscovery = useRelayEnvironmentDiscovery();
1563 const discoveredDescriptor = Option.getOrNull(
1564 relayDiscovery.environments.get(environmentId)?.status ?? Option.none(),
1565 )?.descriptor;
1566 const [lastDescriptor, setLastDescriptor] = useState(discoveredDescriptor);
1567 if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) {
1568 setLastDescriptor(discoveredDescriptor);
1569 }
1570 // Held for the same reason as the descriptor, so Copy MCP URL survives a refresh.
1571 const discoveredRelayHttpBaseUrl =
1572 relayDiscovery.environments.get(environmentId)?.environment.endpoint.httpBaseUrl;
1573 const [lastRelayHttpBaseUrl, setLastRelayHttpBaseUrl] = useState(discoveredRelayHttpBaseUrl);
1574 if (
1575 discoveredRelayHttpBaseUrl !== undefined &&
1576 discoveredRelayHttpBaseUrl !== lastRelayHttpBaseUrl
1577 ) {
1578 setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl);
1579 }
1580 const prepared = usePreparedConnection(environmentId);
1581 const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null;
1582 const mcpUrl = environmentMcpUrl({
1583 entry: environment.entry,
1584 relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl,
1585 connectedTarget,
1586 });
1587 const machineKind = resolveEnvironmentMachineKind(
1588 environment.serverConfig ??
1589 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),
1590 );
1591 const routeCount = connectionRoutes(environment.entry).length;
1592 const subtitleText = [
1593 environmentTransportLabel(environment, connectedTarget),
1594 resumingServerUpdate ? "Restarting" : status.text,
1595 enabled && versionMismatch ? serverVersion : null,
1596 ]
1597 .filter((value): value is string => value !== null)
1598 .join(" · ");
1599
1600 // Only a connected, enabled machine can take a remote update; a switched-off
1601 // one keeps the version note so the icon is not a surprise later.
1602 const showUpdateAction =
1603 enabled &&
1604 isConnected &&
1605 versionMismatch !== null &&
1606 (serverUpdateState.status === "idle" || serverUpdateState.status === "failed");
1607
1608 const statusTooltip = `${
1609 unsupported
1610 ? (environment.connection.error ?? connectionStatusText(environment.connection))
1611 : enabled
1612 ? connectionStatusText(environment.connection)
1613 : "Switched off"
1614 }${
1615 versionMismatch
1616 ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}`
1617 : ""
1618 }`;
1619
1620 return (
1621 <EnvironmentRow
1622 kind={machineKind}
1623 label={environment.label}
1624 dimmed={!enabled}
1625 subtitle={
1626 <span className="flex min-w-0 items-center gap-1">
1627 <Tooltip>
1628 {/* The status can change while the tooltip is open, and base-ui only
1629 re-measures the popup when the trigger's payload changes. */}
1630 <TooltipTrigger
1631 payload={statusTooltip}
1632 render={
1633 <span
1634 className={cn(
1635 "min-w-0 truncate",
1636 enabled &&
1637 status.tone === "error" &&
1638 !resumingServerUpdate &&
1639 "text-destructive",
1640 )}
1641 />
1642 }
1643 >
1644 {subtitleText}
1645 </TooltipTrigger>
1646 <TooltipPopup side="top" className="whitespace-pre-wrap">
1647 {statusTooltip}
1648 </TooltipPopup>
1649 </Tooltip>
1650 <span aria-hidden className="shrink-0">
1651 ·
1652 </span>
1653 <button
1654 type="button"
1655 aria-expanded={routesOpen}
1656 onClick={() => setRoutesOpen((open) => !open)}
1657 className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"
1658 >
1659 {routeCount === 1 ? "Routes" : `${routeCount} routes`}
1660 <ChevronRightIcon
1661 aria-hidden
1662 className={cn(
1663 "size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
1664 routesOpen && "rotate-90",
1665 )}
1666 />
1667 </button>
1668 <span aria-hidden className="shrink-0">
1669 ·
1670 </span>
1671 <button
1672 type="button"
1673 aria-expanded={permissionsOpen}
1674 aria-controls={`remote-permissions-${environmentId}`}
1675 onClick={() => setPermissionsOpen((open) => !open)}
1676 className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"
1677 >
1678 Permissions
1679 <ChevronRightIcon
1680 aria-hidden
1681 className={cn(
1682 "size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
1683 permissionsOpen && "rotate-90",
1684 )}
1685 />
1686 </button>
1687 </span>
1688 }
1689 below={
1690 serverUpdateState.status !== "idle" ? (
1691 <div className="mt-1 max-w-md">
1692 <ServerUpdateProgress state={serverUpdateState} />
1693 </div>
1694 ) : null
1695 }
1696 detail={
1697 <>
1698 {routesOpen && (
1699 <EnvironmentRoutesList
1700 environment={environment}
1701 onAddRoute={() => onAddRoute(environment)}
1702 />
1703 )}
1704 {permissionsOpen && (
1705 <div
1706 id={`remote-permissions-${environmentId}`}
1707 className="mt-2 border-t border-border/50"
1708 >
1709 <SessionPermissions
1710 environmentId={environmentId}
1711 connected={isConnected}
1712 routeContext
1713 />
1714 </div>
1715 )}
1716 </>
1717 }
1718 >
1719 {unsupported &&
1720 environment.entry.serverUpdateRequired === true &&
1721 serverUpdateState.status !== "running" ? (
1722 <OutdatedServerUpdateAction
1723 environmentId={environmentId}
1724 serverLabel={`${environment.label} server`}
1725 fromVersion={lastDescriptor?.serverVersion}
1726 targetVersion={APP_VERSION}
1727 label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
1728 />
1729 ) : null}
1730 {showUpdateAction ? (
1731 <ServerUpdateAction
1732 environmentId={environmentId}
1733 serverLabel={`${environment.label} server`}
1734 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1735 installation={environment.serverConfig?.environment.capabilities.serverInstallation}
1736 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
1737 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}
1738 targetVersion={versionMismatch.clientVersion}
1739 label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
1740 appearance="icon"
1741 />
1742 ) : null}
1743 <Tooltip>
1744 <TooltipTrigger
1745 render={
1746 <Switch
1747 size="sm"
1748 checked={enabled}
1749 disabled={isRemoving || unsupported}
1750 aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`}
1751 onCheckedChange={(checked) => onSetEnabled(environmentId, checked)}
1752 />
1753 }
1754 />
1755 <TooltipPopup side="top">
1756 {unsupported ? "Client not supported" : enabled ? "Switch off" : "Switch on"}
1757 </TooltipPopup>
1758 </Tooltip>
1759 <Menu>
1760 <MenuTrigger
1761 render={
1762 <Button
1763 type="button"
1764 variant="ghost-muted"
1765 size="icon-xs"
1766 disabled={isRemoving}
1767 aria-label={`More actions for ${environment.label}`}
1768 />
1769 }
1770 >
1771 <EllipsisIcon className="size-3.5" />
1772 </MenuTrigger>
1773 <MenuPopup align="end">
1774 <EnvironmentIconMenu
1775 environmentId={environmentId}
1776 serverConfig={environment.serverConfig}
1777 />
1778 <MenuItem onClick={() => setRoutesOpen((open) => !open)}>
1779 <RouteIcon />
1780 {routesOpen ? "Hide routes" : "Routes"}
1781 </MenuItem>
1782 {mcpUrl ? (
1783 <MenuItem onClick={() => copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL</MenuItem>
1784 ) : null}
1785 {errorTraceId ? (
1786 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
1787 ) : null}
1788 <MenuSeparator />
1789 <MenuItem variant="destructive" onClick={() => onRemove(environment)}>
1790 {isRemoving ? "Removing…" : "Remove from this device…"}
1791 </MenuItem>
1792 </MenuPopup>
1793 </Menu>
1794 </EnvironmentRow>
1795 );
1796}
1797
1798function CloudLinkSwitch({
1799 checked,
1800 disabled,
1801 disabledReason,
1802 onCheckedChange,
1803 ariaLabel = "Enable T3 Connect",
1804}: {
1805 readonly checked: boolean;
1806 readonly disabled: boolean;
1807 readonly disabledReason: string | null;
1808 readonly onCheckedChange?: (enabled: boolean) => void;
1809 readonly ariaLabel?: string;
1810}) {
1811 const control = (
1812 <Switch
1813 aria-label={ariaLabel}
1814 checked={checked}
1815 disabled={disabled}
1816 {...(onCheckedChange ? { onCheckedChange } : {})}
1817 />
1818 );
1819 return disabledReason ? (
1820 <Tooltip>
1821 <TooltipTrigger render={<span className="inline-flex">{control}</span>} />
1822 <TooltipPopup side="top">{disabledReason}</TooltipPopup>
1823 </Tooltip>
1824 ) : (
1825 control
1826 );
1827}
1828
1829function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {
1830 const primaryEnvironmentId = usePrimaryEnvironmentId();
1831 const {
1832 isSignedIn,
1833 linkState: primaryCloudLinkState,
1834 linkRevoked,
1835 managedTunnelActive,
1836 managedTunnelOutOfSync,
1837 publishAgentActivity,
1838 holdWebhooksWhileOffline,
1839 operationError,
1840 reconcileCloudState,
1841 } = useCloudLinkController();
1842 const [isUpdating, setIsUpdating] = useState(false);
1843 const [isUpdatingPreference, setIsUpdatingPreference] = useState(false);
1844
1845 const disabledReason = !isSignedIn
1846 ? "Sign in to T3 Connect to manage this environment."
1847 : !canManageRelay
1848 ? "Your session does not have permission to manage T3 Connect access."
1849 : null;
1850 const isBusy = isUpdating || isUpdatingPreference;
1851
1852 const updateManagedTunnel = async (enabled: boolean) => {
1853 if (
1854 primaryEnvironmentId === null ||
1855 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
1856 )
1857 return;
1858 setIsUpdating(true);
1859 const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });
1860 if (ok) {
1861 // Turning the tunnel off while publishing stays on downgrades the link
1862 // rather than removing it — say so instead of claiming an unlink.
1863 toastManager.add({
1864 type: "success",
1865 title: enabled
1866 ? "T3 Connect linked"
1867 : publishAgentActivity
1868 ? "T3 Connect tunnel disabled"
1869 : "T3 Connect unlinked",
1870 description: enabled
1871 ? "This environment is available through T3 Connect."
1872 : publishAgentActivity
1873 ? "The managed tunnel was removed. Agent activity publishing stays on."
1874 : "This environment is no longer available through T3 Connect.",
1875 });
1876 }
1877 setIsUpdating(false);
1878 };
1879
1880 const updatePublishAgentActivity = async (enabled: boolean) => {
1881 if (
1882 primaryEnvironmentId === null ||
1883 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
1884 )
1885 return;
1886 setIsUpdatingPreference(true);
1887 const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled });
1888 if (ok) {
1889 toastManager.add({
1890 type: "success",
1891 title: enabled ? "Agent activity enabled" : "Agent activity disabled",
1892 description: enabled
1893 ? "This environment publishes agent activity to your mobile clients."
1894 : "This environment will stop publishing agent activity.",
1895 });
1896 }
1897 setIsUpdatingPreference(false);
1898 };
1899
1900 const updateHoldWebhooks = async (enabled: boolean) => {
1901 setIsUpdatingPreference(true);
1902 const ok = await reconcileCloudState({
1903 managedTunnel: managedTunnelActive,
1904 publish: publishAgentActivity,
1905 holdWebhooksWhileOffline: enabled,
1906 });
1907 if (ok) {
1908 toastManager.add({
1909 type: "success",
1910 title: enabled ? "Webhooks held while offline" : "Webhooks no longer held",
1911 description: enabled
1912 ? "T3 Connect keeps webhook requests for up to 24 hours while this environment is offline."
1913 : "Requests to an offline environment now fail. Anything already held is still delivered.",
1914 });
1915 }
1916 setIsUpdatingPreference(false);
1917 };
1918
1919 return (
1920 <>
1921 {window.desktopBridge ? (
1922 <SettingsRow
1923 title={searchableSetting("t3-connect").title}
1924 description={
1925 linkRevoked
1926 ? "This environment was removed from T3 Connect. Turn it on to link it again."
1927 : managedTunnelActive
1928 ? "This environment is available to your other devices through T3 Connect."
1929 : managedTunnelOutOfSync
1930 ? "T3 Connect is out of sync. This environment is linked only to publish agent activity, so other devices cannot connect. Turn T3 Connect on to repair it."
1931 : "Make this environment available to your other devices through T3 Connect."
1932 }
1933 status={operationError ?? primaryCloudLinkState.error}
1934 control={
1935 <CloudLinkSwitch
1936 checked={managedTunnelActive}
1937 disabled={
1938 !canManageRelay ||
1939 !isSignedIn ||
1940 primaryCloudLinkState.data === null ||
1941 primaryCloudLinkState.isPending ||
1942 isBusy
1943 }
1944 disabledReason={disabledReason}
1945 onCheckedChange={(enabled) => void updateManagedTunnel(enabled)}
1946 />
1947 }
1948 />
1949 ) : linkRevoked ? (
1950 <SettingsRow
1951 title={searchableSetting("t3-connect").title}
1952 description="This environment was removed from T3 Connect. Run `t3 connect link` on the host to link it again."
1953 />
1954 ) : null}
1955 <SettingsRow
1956 title={searchableSetting("publish-agent-activity").title}
1957 description="Send activity to mobile notifications and Live Activities without T3 Connect."
1958 control={
1959 <CloudLinkSwitch
1960 ariaLabel="Publish agent activity to mobile clients"
1961 checked={publishAgentActivity}
1962 disabled={
1963 !canManageRelay ||
1964 !isSignedIn ||
1965 primaryCloudLinkState.data === null ||
1966 primaryCloudLinkState.isPending ||
1967 isBusy
1968 }
1969 disabledReason={disabledReason}
1970 onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)}
1971 />
1972 }
1973 />
1974 {managedTunnelActive ? (
1975 <SettingsRow
1976 title={searchableSetting("hold-webhooks-while-offline").title}
1977 description="Keep webhook requests for up to 24 hours while this environment is offline, then deliver them. Off: T3 Connect only forwards requests and stores nothing."
1978 control={
1979 <CloudLinkSwitch
1980 ariaLabel="Hold webhook requests while this environment is offline"
1981 checked={holdWebhooksWhileOffline}
1982 disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy}
1983 disabledReason={disabledReason}
1984 onCheckedChange={(enabled) => void updateHoldWebhooks(enabled)}
1985 />
1986 }
1987 />
1988 ) : null}
1989 </>
1990 );
1991}
1992
1993function CloudLinkRow({
1994 canReadRelay,
1995 canManageRelay,
1996}: {
1997 readonly canReadRelay: boolean;
1998 readonly canManageRelay: boolean;
1999}) {
2000 if (!hasCloudPublicConfig()) return null;
2001 if (!canReadRelay) {
2002 return (
2003 <SettingsRow
2004 title="T3 Connect"
2005 description="To edit these settings, pair this connection with both View relay and Manage relay permissions."
2006 />
2007 );
2008 }
2009 return (
2010 <CloudAuthBoundary
2011 fallback={
2012 <SettingsRow title="T3 Connect" description="Reach your computers through your T3 account.">
2013 <Button variant="outline" size="sm" onClick={() => activateCloudAuthentication(true)}>
2014 Sign in
2015 </Button>
2016 </SettingsRow>
2017 }
2018 >
2019 <ConfiguredCloudLinkRow canManageRelay={canManageRelay} />
2020 </CloudAuthBoundary>
2021 );
2022}
2023
2024function EmptyRemoteEnvironments() {
2025 return (
2026 <Empty className="min-h-52">
2027 <EmptyMedia variant="icon">
2028 <ChevronsLeftRightEllipsisIcon />
2029 </EmptyMedia>
2030 <EmptyHeader>
2031 <EmptyTitle>No saved remote environments</EmptyTitle>
2032 <EmptyDescription>Add an environment to connect your computers.</EmptyDescription>
2033 </EmptyHeader>
2034 </Empty>
2035 );
2036}
2037
2038function CloudRemoteEnvironmentRows({
2039 primaryEnvironmentId,
2040 savedEnvironments,
2041}: {
2042 readonly primaryEnvironmentId: EnvironmentId | null;
2043 readonly savedEnvironments: ReadonlyArray<EnvironmentPresentation>;
2044}) {
2045 return hasCloudPublicConfig() ? (
2046 <CloudEnvironmentConnectRows
2047 primaryEnvironmentId={primaryEnvironmentId}
2048 savedEnvironments={savedEnvironments}
2049 empty={<EmptyRemoteEnvironments />}
2050 />
2051 ) : savedEnvironments.length === 0 ? (
2052 <EmptyRemoteEnvironments />
2053 ) : null;
2054}
2055
2056export function ConnectionsSettings() {
2057 const desktopBridge = window.desktopBridge;
2058 const [directPairing, setDirectPairing] = useState(false);
2059 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
2060 const { environments } = useEnvironments();
2061 const primaryEnvironment = usePrimaryEnvironment();
2062 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
2063 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {
2064 reportFailure: false,
2065 });
2066 const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false });
2067 const registerEnvironment = useAtomCommand(environmentCatalog.register, {
2068 reportFailure: false,
2069 });
2070 const relayDiscoveryState = useRelayEnvironmentDiscovery();
2071 const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, {
2072 reportFailure: false,
2073 });
2074 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
2075 const primarySessionState = usePrimarySessionState();
2076 const currentSessionScopes = primarySessionState.data?.authenticated
2077 ? (primarySessionState.data.permissions ?? primarySessionState.data.scopes ?? null)
2078 : null;
2079 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);
2080 // Catalog order is the order the machines were added; rows never jump when
2081 // one is switched off.
2082 const savedEnvironments = useMemo(
2083 () =>
2084 environments.filter(
2085 (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget",
2086 ),
2087 [environments],
2088 );
2089 // The WSL backend is managed from the WSL row under this machine, so it has
2090 // no row of its own in the list.
2091 const listedEnvironments = useMemo(
2092 () =>
2093 savedEnvironments.filter(
2094 (environment) => !isDesktopLocalConnectionTarget(environment.entry.target),
2095 ),
2096 [savedEnvironments],
2097 );
2098 // Machines "Update all" can reach: switched on, connected, behind the client
2099 // version, remotely updatable, and not already mid-update. The button only
2100 // renders when this list is non-empty.
2101 const savedServerUpdateStatesAtom = useMemo(
2102 () =>
2103 Atom.make((get) =>
2104 savedEnvironments.map((environment) => ({
2105 environment,
2106 updateStatus: get(serverEnvironment.updateStateAtom(environment.environmentId)).status,
2107 })),
2108 ),
2109 [savedEnvironments],
2110 );
2111 const savedServerUpdateStates = useAtomValue(savedServerUpdateStatesAtom);
2112 const savedServerUpdateTargets = useMemo(
2113 () =>
2114 savedServerUpdateStates.flatMap(({ environment, updateStatus }): ServerUpdateTarget[] => {
2115 const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
2116 const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig);
2117 const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig);
2118 if (
2119 !mismatch ||
2120 updateStatus === "running" ||
2121 !environment.entry.enabled ||
2122 environment.connection.phase !== "connected" ||
2123 isDesktopLocalConnectionTarget(environment.entry.target) ||
2124 // Manual-update machines only offer a copy command on their row.
2125 selfUpdate === null ||
2126 (selfUpdate === "desktop-managed" && !desktopAppUpdate)
2127 ) {
2128 return [];
2129 }
2130 return [
2131 {
2132 environmentId: environment.environmentId,
2133 serverLabel: environment.label,
2134 selfUpdate,
2135 installation: environment.serverConfig?.environment.capabilities.serverInstallation,
2136 desktopAppUpdate,
2137 threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),
2138 continueThreadsAfterServerUpdate:
2139 environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false,
2140 targetVersion: mismatch.clientVersion,
2141 },
2142 ];
2143 }),
2144 [savedServerUpdateStates],
2145 );
2146 // Switched-off machines never receive threads, so they stay out of the
2147 // load balancing and GitHub sharing lists. The WSL backend has no row in
2148 // the Environments list but does take threads, so it stays in here. This
2149 // machine leads the list.
2150 const loadBalancingEnvironments = useMemo(
2151 () => [
2152 ...(primaryEnvironment ? [primaryEnvironment] : []),
2153 ...savedEnvironments.filter((environment) => environment.entry.enabled),
2154 ],
2155 [primaryEnvironment, savedEnvironments],
2156 );
2157 const savedDesktopSshEnvironmentKeys = useMemo(() => {
2158 const keys = new Set<string>();
2159 for (const environment of savedEnvironments) {
2160 const profile = environment.entry.profile;
2161 if (
2162 environment.entry.target._tag !== "SshConnectionTarget" ||
2163 Option.isNone(profile) ||
2164 profile.value._tag !== "SshConnectionProfile"
2165 ) {
2166 continue;
2167 }
2168 const target = profile.value.target;
2169 keys.add(target.alias);
2170 keys.add(formatDesktopSshTarget(target));
2171 }
2172 return keys;
2173 }, [savedEnvironments]);
2174 const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState<
2175 string | null
2176 >(null);
2177 const [desktopAccessManagementMutationError, setDesktopAccessManagementMutationError] = useState<
2178 string | null
2179 >(null);
2180 // Only this client's creation response can supply a shareable credential.
2181 const [createdPairingCredentials, setCreatedPairingCredentials] = useState<
2182 ReadonlyMap<string, string>
2183 >(() => new Map());
2184 const handlePairingLinkCreated = useCallback((created: AuthPairingCredentialResult) => {
2185 setCreatedPairingCredentials((current) => new Map(current).set(created.id, created.credential));
2186 }, []);
2187 const [revokingDesktopPairingLinkId, setRevokingDesktopPairingLinkId] = useState<string | null>(
2188 null,
2189 );
2190 const [revokingDesktopClientSessionId, setRevokingDesktopClientSessionId] = useState<
2191 string | null
2192 >(null);
2193 const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false);
2194 const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false);
2195 // Set when the dialog adds a route to a saved machine instead of a new one.
2196 const [routeTarget, setRouteTarget] = useState<EnvironmentPresentation | null>(null);
2197 const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote");
2198 const [savedBackendHost, setSavedBackendHost] = useState("");
2199 const [savedBackendPairingCode, setSavedBackendPairingCode] = useState("");
2200 const [savedBackendSshHost, setSavedBackendSshHost] = useState("");
2201 const [savedBackendSshUsername, setSavedBackendSshUsername] = useState("");
2202 const [savedBackendSshPort, setSavedBackendSshPort] = useState("");
2203 const [sshHostSuggestionsOpen, setSshHostSuggestionsOpen] = useState(false);
2204 // Tracks the arrow-key/hover highlight so Enter selects it instead of submitting the typed text.
2205 const highlightedSshHostRef = useRef<DesktopDiscoveredSshHost | undefined>(undefined);
2206 const [savedBackendError, setSavedBackendError] = useState<string | null>(null);
2207 const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false);
2208 const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] =
2209 useState<EnvironmentId | null>(null);
2210 const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false);
2211 const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false);
2212 const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false);
2213 const [isUpdatingWslBackend, setIsUpdatingWslBackend] = useState(false);
2214 const [desktopWslMutationError, setDesktopWslMutationError] = useState<string | null>(null);
2215 // Pending WSL setting change waiting on user confirmation. Set when
2216 // the user tries a destructive change (disable, switch distro,
2217 // toggle wsl-only) while the WSL backend has saved-env state on this
2218 // machine. Confirming applies the change; cancelling drops it
2219 // without touching the persisted setting. Null when nothing is
2220 // pending.
2221 type PendingWslChange =
2222 // wasWslOnly is true when the user picked Off while wsl-only mode
2223 // was active. In that case "disable" also clears wsl-only and
2224 // relaunches onto the Windows backend, because leaving wsl-only on
2225 // with wslBackendEnabled off is a meaningless state (wsl-only is
2226 // only honoured when the WSL backend is enabled).
2227 | { readonly kind: "disable"; readonly wasWslOnly: boolean }
2228 | { readonly kind: "distro"; readonly nextDistro: string | null }
2229 // Asked at enable time so the user picks the mode upfront instead
2230 // of being dropped into "both backends" and having to discover the
2231 // wsl-only switch separately. Resolved through enable-mode action
2232 // buttons on the dialog rather than a single Confirm.
2233 | { readonly kind: "enable"; readonly nextDistro: string | null }
2234 | { readonly kind: "wsl-only"; readonly nextValue: boolean };
2235 const [pendingWslChange, setPendingWslChange] = useState<PendingWslChange | null>(null);
2236 const isWslConfirmDialogOpen = pendingWslChange !== null;
2237 const [pendingTailscaleServeEndpoint, setPendingTailscaleServeEndpoint] =
2238 useState<AdvertisedEndpoint | null>(null);
2239 const [disableTailscaleServeDialogOpen, setDisableTailscaleServeDialogOpen] = useState(false);
2240 const [tailscaleServePortInput, setTailscaleServePortInput] = useState(
2241 String(DEFAULT_TAILSCALE_SERVE_PORT),
2242 );
2243 const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState<
2244 DesktopServerExposureState["mode"] | null
2245 >(null);
2246 const primaryServerConfig = primaryEnvironment?.serverConfig ?? null;
2247 const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig);
2248 const primaryServerUpdateState = useAtomValue(
2249 serverEnvironment.updateStateAtom(primaryEnvironmentId),
2250 );
2251 const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false);
2252 const defaultAdvertisedEndpointKey = useUiStateStore(
2253 (state) => state.defaultAdvertisedEndpointKey,
2254 );
2255 const setDefaultAdvertisedEndpointKey = useUiStateStore(
2256 (state) => state.setDefaultAdvertisedEndpointKey,
2257 );
2258 const canReadAccess = useEnvironmentScope(primaryEnvironmentId, AuthAccessReadScope);
2259 const canWriteAccess = useEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope);
2260 const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope);
2261 const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope);
2262 const canManageRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope);
2263 const canManageLocalBackend = !isLocalEnvironmentDisabled() && canMaintain;
2264 const authAccessChanges = useEnvironmentQuery(
2265 canReadAccess && primaryEnvironmentId !== null
2266 ? authEnvironment.accessChanges({
2267 environmentId: primaryEnvironmentId,
2268 input: null,
2269 })
2270 : null,
2271 );
2272 const desktopNetworkAccess = useEnvironmentQuery(
2273 canManageLocalBackend && desktopBridge ? desktopNetworkAccessStateAtom : null,
2274 );
2275 const isSshDiscoveryActive =
2276 desktopBridge !== undefined && addBackendDialogOpen && savedBackendMode === "ssh";
2277 const desktopSshHosts = useEnvironmentQuery(
2278 isSshDiscoveryActive ? desktopSshHostsStateAtom : null,
2279 );
2280 // The discovery atom is kept alive across dialog opens, so re-read SSH config
2281 // each time the SSH tab is shown; stale hosts stay visible while it refreshes.
2282 const refreshDesktopSshHosts = desktopSshHosts.refresh;
2283 useEffect(() => {
2284 if (isSshDiscoveryActive) refreshDesktopSshHosts();
2285 }, [isSshDiscoveryActive, refreshDesktopSshHosts]);
2286 const desktopWsl = useEnvironmentQuery(
2287 canManageLocalBackend && desktopBridge ? desktopWslStateAtom : null,
2288 );
2289 const desktopWslState = desktopWsl.data;
2290 const desktopWslError = desktopWslMutationError ?? desktopWsl.error;
2291 const isLoadingWslState = desktopWsl.isPending && desktopWsl.data === null;
2292 const discoveredSshHosts = desktopSshHosts.data ?? EMPTY_DISCOVERED_SSH_HOSTS;
2293 const unsavedDiscoveredSshHosts = useMemo(
2294 () =>
2295 discoveredSshHosts.filter((target) => {
2296 const address = formatDesktopSshTarget(target);
2297 return (
2298 !savedDesktopSshEnvironmentKeys.has(target.alias) &&
2299 !savedDesktopSshEnvironmentKeys.has(address)
2300 );
2301 }),
2302 [discoveredSshHosts, savedDesktopSshEnvironmentKeys],
2303 );
2304 const filteredDiscoveredSshHosts = useMemo(
2305 () => filterDiscoveredSshHosts(unsavedDiscoveredSshHosts, savedBackendSshHost),
2306 [savedBackendSshHost, unsavedDiscoveredSshHosts],
2307 );
2308 const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending && desktopSshHosts.data === null;
2309 const discoveredSshHostsError = desktopSshHosts.error;
2310 const hasSshHostSuggestionContent =
2311 desktopBridge !== undefined &&
2312 (isLoadingDiscoveredSshHosts || unsavedDiscoveredSshHosts.length > 0);
2313 const desktopServerExposureState = desktopNetworkAccess.data?.serverExposureState ?? null;
2314 const desktopAdvertisedEndpoints =
2315 desktopNetworkAccess.data?.advertisedEndpoints ?? EMPTY_ADVERTISED_ENDPOINTS;
2316 const desktopServerExposureError =
2317 desktopServerExposureMutationError ?? desktopNetworkAccess.error;
2318 const desktopAccessManagementError =
2319 desktopAccessManagementMutationError ?? authAccessChanges.error;
2320 const isLoadingDesktopAccessManagement =
2321 authAccessChanges.isPending && authAccessChanges.data === null;
2322 const desktopPairingLinks = useMemo(() => {
2323 const event = authAccessChanges.data;
2324 if (event?.type !== "snapshot") return [];
2325 return sortDesktopPairingLinks(
2326 event.payload.pairingLinks.map((pairingLink: AuthPairingLink) =>
2327 toDesktopPairingLinkRecord(pairingLink),
2328 ),
2329 );
2330 }, [authAccessChanges.data]);
2331 const desktopClientSessions = useMemo(() => {
2332 const event = authAccessChanges.data;
2333 if (event?.type !== "snapshot") return [];
2334 return sortDesktopClientSessions(
2335 event.payload.clientSessions.map((clientSession: AuthClientSession) =>
2336 toDesktopClientSessionRecord(clientSession),
2337 ),
2338 );
2339 }, [authAccessChanges.data]);
2340 const isLocalBackendNetworkAccessible = desktopBridge
2341 ? desktopServerExposureState?.mode === "network-accessible"
2342 : currentAuthPolicy === "remote-reachable";
2343 const trimmedTailscaleServePortInput = tailscaleServePortInput.trim();
2344 const parsedTailscaleServePort = Number(trimmedTailscaleServePortInput);
2345 const isTailscaleServePortValid =
2346 /^\d+$/u.test(trimmedTailscaleServePortInput) &&
2347 Number.isInteger(parsedTailscaleServePort) &&
2348 parsedTailscaleServePort >= 1 &&
2349 parsedTailscaleServePort <= 65_535;
2350
2351 const pendingTailscaleServeBaseUrl = useMemo(() => {
2352 if (!pendingTailscaleServeEndpoint) return null;
2353 if (!isTailscaleServePortValid) return pendingTailscaleServeEndpoint.httpBaseUrl;
2354 if (parsedTailscaleServePort === DEFAULT_TAILSCALE_SERVE_PORT) {
2355 return pendingTailscaleServeEndpoint.httpBaseUrl;
2356 }
2357 try {
2358 const url = new URL(pendingTailscaleServeEndpoint.httpBaseUrl);
2359 url.port = String(parsedTailscaleServePort);
2360 return url.toString().replace(/\/$/u, "");
2361 } catch {
2362 return pendingTailscaleServeEndpoint.httpBaseUrl;
2363 }
2364 }, [isTailscaleServePortValid, parsedTailscaleServePort, pendingTailscaleServeEndpoint]);
2365
2366 const handleDesktopServerExposureChange = useCallback(
2367 async (checked: boolean) => {
2368 if (
2369 !desktopBridge ||
2370 primaryEnvironmentId === null ||
2371 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
2372 )
2373 return;
2374 setIsUpdatingDesktopServerExposure(true);
2375 setDesktopServerExposureMutationError(null);
2376 try {
2377 await desktopBridge.setServerExposureMode(checked ? "network-accessible" : "local-only");
2378 refreshDesktopNetworkAccessState();
2379 setIsDesktopServerExposureDialogOpen(false);
2380 setIsUpdatingDesktopServerExposure(false);
2381 } catch (error) {
2382 const message =
2383 error instanceof Error ? error.message : "Failed to update network exposure.";
2384 setIsDesktopServerExposureDialogOpen(false);
2385 setDesktopServerExposureMutationError(message);
2386 toastManager.add(
2387 stackedThreadToast({
2388 type: "error",
2389 title: "Could not update network access",
2390 description: message,
2391 }),
2392 );
2393 setIsUpdatingDesktopServerExposure(false);
2394 }
2395 },
2396 [desktopBridge, primaryEnvironmentId],
2397 );
2398
2399 const handleConfirmDesktopServerExposureChange = useCallback(() => {
2400 if (pendingDesktopServerExposureMode === null) return;
2401 const checked = pendingDesktopServerExposureMode === "network-accessible";
2402 void handleDesktopServerExposureChange(checked);
2403 }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]);
2404
2405 const handleConfirmTailscaleServeSetup = useCallback(async () => {
2406 if (
2407 !desktopBridge ||
2408 primaryEnvironmentId === null ||
2409 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
2410 )
2411 return;
2412 if (!isTailscaleServePortValid) return;
2413 setIsUpdatingTailscaleServe(true);
2414 setDesktopServerExposureMutationError(null);
2415 try {
2416 await desktopBridge.setTailscaleServeEnabled({
2417 enabled: true,
2418 port: parsedTailscaleServePort,
2419 });
2420 refreshDesktopNetworkAccessState();
2421 setPendingTailscaleServeEndpoint(null);
2422 } catch (error) {
2423 const message =
2424 error instanceof Error ? error.message : "Failed to configure Tailscale HTTPS.";
2425 setDesktopServerExposureMutationError(message);
2426 toastManager.add(
2427 stackedThreadToast({
2428 type: "error",
2429 title: "Could not set up Tailscale HTTPS",
2430 description: message,
2431 }),
2432 );
2433 } finally {
2434 setIsUpdatingTailscaleServe(false);
2435 }
2436 }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort, primaryEnvironmentId]);
2437
2438 const handleStartTailscaleServeSetup = useCallback(
2439 (endpoint: AdvertisedEndpoint) => {
2440 setTailscaleServePortInput(
2441 String(desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT),
2442 );
2443 setPendingTailscaleServeEndpoint(endpoint);
2444 },
2445 [desktopServerExposureState?.tailscaleServePort],
2446 );
2447
2448 const handleConfirmTailscaleServeDisable = useCallback(async () => {
2449 if (
2450 !desktopBridge ||
2451 primaryEnvironmentId === null ||
2452 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
2453 )
2454 return;
2455 setIsUpdatingTailscaleServe(true);
2456 setDesktopServerExposureMutationError(null);
2457 try {
2458 await desktopBridge.setTailscaleServeEnabled({
2459 enabled: false,
2460 port: desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT,
2461 });
2462 refreshDesktopNetworkAccessState();
2463 setDisableTailscaleServeDialogOpen(false);
2464 } catch (error) {
2465 const message = error instanceof Error ? error.message : "Failed to disable Tailscale HTTPS.";
2466 setDesktopServerExposureMutationError(message);
2467 toastManager.add(
2468 stackedThreadToast({
2469 type: "error",
2470 title: "Could not disable Tailscale HTTPS",
2471 description: message,
2472 }),
2473 );
2474 } finally {
2475 setIsUpdatingTailscaleServe(false);
2476 }
2477 }, [desktopBridge, desktopServerExposureState, primaryEnvironmentId]);
2478
2479 const handleStartTailscaleServeDisable = useCallback((_endpoint: AdvertisedEndpoint) => {
2480 setDisableTailscaleServeDialogOpen(true);
2481 }, []);
2482
2483 const handleRevokeDesktopPairingLink = useCallback(
2484 async (id: string) => {
2485 if (
2486 primaryEnvironmentId === null ||
2487 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope)
2488 )
2489 return;
2490 setRevokingDesktopPairingLinkId(id);
2491 setDesktopAccessManagementMutationError(null);
2492 try {
2493 await revokeServerPairingLink(id);
2494 } catch (error) {
2495 const message = error instanceof Error ? error.message : "Failed to revoke pairing link.";
2496 setDesktopAccessManagementMutationError(message);
2497 toastManager.add(
2498 stackedThreadToast({
2499 type: "error",
2500 title: "Could not revoke pairing link",
2501 description: message,
2502 }),
2503 );
2504 } finally {
2505 setRevokingDesktopPairingLinkId(null);
2506 }
2507 },
2508 [primaryEnvironmentId],
2509 );
2510
2511 const handleRevokeDesktopClientSession = useCallback(
2512 async (sessionId: ServerClientSessionRecord["sessionId"]) => {
2513 if (
2514 primaryEnvironmentId === null ||
2515 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope)
2516 )
2517 return;
2518 setRevokingDesktopClientSessionId(sessionId);
2519 setDesktopAccessManagementMutationError(null);
2520 try {
2521 await revokeServerClientSession(sessionId);
2522 } catch (error) {
2523 const message = error instanceof Error ? error.message : "Failed to revoke client access.";
2524 setDesktopAccessManagementMutationError(message);
2525 toastManager.add(
2526 stackedThreadToast({
2527 type: "error",
2528 title: "Could not revoke client access",
2529 description: message,
2530 }),
2531 );
2532 } finally {
2533 setRevokingDesktopClientSessionId(null);
2534 }
2535 },
2536 [primaryEnvironmentId],
2537 );
2538
2539 const handleRevokeOtherDesktopClients = useCallback(async () => {
2540 if (
2541 primaryEnvironmentId === null ||
2542 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope)
2543 )
2544 return;
2545 setIsRevokingOtherDesktopClients(true);
2546 setDesktopAccessManagementMutationError(null);
2547 try {
2548 const revokedCount = await revokeOtherServerClientSessions();
2549 toastManager.add({
2550 type: "success",
2551 title: revokedCount === 1 ? "Revoked 1 other client" : `Revoked ${revokedCount} clients`,
2552 description: "Other paired clients will need a new pairing link before reconnecting.",
2553 });
2554 } catch (error) {
2555 const message = error instanceof Error ? error.message : "Failed to revoke other clients.";
2556 setDesktopAccessManagementMutationError(message);
2557 toastManager.add(
2558 stackedThreadToast({
2559 type: "error",
2560 title: "Could not revoke other clients",
2561 description: message,
2562 }),
2563 );
2564 } finally {
2565 setIsRevokingOtherDesktopClients(false);
2566 }
2567 }, [primaryEnvironmentId]);
2568
2569 // Shared by manual SSH submission and discovered-host selection.
2570 const connectSavedBackendSshTarget = useCallback(
2571 async (target: DesktopSshEnvironmentTarget) => {
2572 setIsAddingSavedBackend(true);
2573 setSavedBackendError(null);
2574 const result = await connectSshEnvironment({
2575 target,
2576 label: "",
2577 ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}),
2578 });
2579 if (result._tag === "Failure") {
2580 if (!isAtomCommandInterrupted(result)) {
2581 setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result)));
2582 }
2583 setIsAddingSavedBackend(false);
2584 return;
2585 }
2586
2587 setSavedBackendHost("");
2588 setSavedBackendPairingCode("");
2589 setSavedBackendSshHost("");
2590 setSavedBackendSshUsername("");
2591 setSavedBackendSshPort("");
2592 setAddBackendDialogOpen(false);
2593 toastManager.add({
2594 type: "success",
2595 title: routeTarget ? "Route added" : "Environment connected",
2596 description: routeTarget
2597 ? `${routeTarget.label} can now be reached over SSH ${target.alias}.`
2598 : `${target.alias} is ready over an SSH-managed tunnel.`,
2599 });
2600 setIsAddingSavedBackend(false);
2601 },
2602 [connectSshEnvironment, routeTarget],
2603 );
2604
2605 const handleAddSavedBackend = useCallback(async () => {
2606 if (savedBackendMode === "ssh") {
2607 let target: DesktopSshEnvironmentTarget;
2608 try {
2609 target = parseManualDesktopSshTarget({
2610 host: savedBackendSshHost,
2611 username: savedBackendSshUsername,
2612 port: savedBackendSshPort,
2613 });
2614 } catch (error) {
2615 setSavedBackendError(formatDesktopSshConnectionError(error));
2616 return;
2617 }
2618
2619 await connectSavedBackendSshTarget(target);
2620 return;
2621 }
2622
2623 setIsAddingSavedBackend(true);
2624 setSavedBackendError(null);
2625 let remotePairingInput: ReturnType<typeof parseRemotePairingFields>;
2626 try {
2627 remotePairingInput = parseRemotePairingFields({
2628 host: savedBackendHost,
2629 pairingCode: savedBackendPairingCode,
2630 });
2631 } catch (error) {
2632 const message = error instanceof Error ? error.message : "Failed to add backend.";
2633 setSavedBackendError(message);
2634 toastManager.add(
2635 stackedThreadToast({
2636 type: "error",
2637 title: "Could not add backend",
2638 description: message,
2639 }),
2640 );
2641 setIsAddingSavedBackend(false);
2642 return;
2643 }
2644
2645 const result = await connectPairing({
2646 ...remotePairingInput,
2647 ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}),
2648 });
2649 if (result._tag === "Failure") {
2650 if (!isAtomCommandInterrupted(result)) {
2651 const error = squashAtomCommandFailure(result);
2652 const message = error instanceof Error ? error.message : "Failed to add backend.";
2653 setSavedBackendError(message);
2654 toastManager.add(
2655 stackedThreadToast({
2656 type: "error",
2657 title: "Could not add backend",
2658 description: message,
2659 }),
2660 );
2661 }
2662 setIsAddingSavedBackend(false);
2663 return;
2664 }
2665
2666 setSavedBackendHost("");
2667 setSavedBackendPairingCode("");
2668 setSavedBackendSshHost("");
2669 setSavedBackendSshUsername("");
2670 setSavedBackendSshPort("");
2671 setAddBackendDialogOpen(false);
2672 toastManager.add(
2673 routeTarget
2674 ? {
2675 type: "success",
2676 title: "Route added",
2677 description: `${routeTarget.label} now has another way to connect.`,
2678 }
2679 : {
2680 type: "success",
2681 title: "Backend added",
2682 description: "The environment is saved and will reconnect on app startup.",
2683 },
2684 );
2685 setIsAddingSavedBackend(false);
2686 }, [
2687 routeTarget,
2688 connectPairing,
2689 connectSavedBackendSshTarget,
2690 savedBackendHost,
2691 savedBackendMode,
2692 savedBackendPairingCode,
2693 savedBackendSshHost,
2694 savedBackendSshPort,
2695 savedBackendSshUsername,
2696 ]);
2697
2698 const handleSavedBackendSshFieldKeyDown = useCallback(
2699 (event: KeyboardEvent<HTMLInputElement>) => {
2700 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
2701 if (event.key === "Enter" && savedBackendSshHost.trim().length > 0) {
2702 event.preventDefault();
2703 void handleAddSavedBackend();
2704 }
2705 },
2706 [handleAddSavedBackend, savedBackendSshHost],
2707 );
2708
2709 // Resolves a picked alias before connecting it through the manual SSH flow.
2710 const handleSelectSshHostSuggestion = useCallback(
2711 async (target: DesktopDiscoveredSshHost) => {
2712 if (isAddingSavedBackend || !desktopBridge) return;
2713
2714 setIsAddingSavedBackend(true);
2715 setSavedBackendError(null);
2716 setSavedBackendSshHost(target.alias);
2717 let resolved: DesktopSshEnvironmentTarget;
2718 try {
2719 resolved = await desktopBridge.resolveSshHost(target.alias);
2720 } catch (error) {
2721 setSavedBackendError(formatDesktopSshConnectionError(error));
2722 setIsAddingSavedBackend(false);
2723 return;
2724 }
2725 setSavedBackendSshUsername(resolved.username ?? "");
2726 setSavedBackendSshPort(resolved.port === null ? "" : String(resolved.port));
2727 await connectSavedBackendSshTarget(resolved);
2728 },
2729 [connectSavedBackendSshTarget, desktopBridge, isAddingSavedBackend],
2730 );
2731
2732 const handleSavedBackendSshHostKeyDown = useCallback(
2733 (event: KeyboardEvent<HTMLInputElement>) => {
2734 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
2735
2736 // The popup only renders when there is content, so an "open" flag alone is not enough.
2737 const isSshHostPopupVisible = sshHostSuggestionsOpen && hasSshHostSuggestionContent;
2738 if (isSshHostPopupVisible) {
2739 const command = resolveShortcutCommand(event, keybindings, {
2740 platform: navigator.platform,
2741 context: { modelPickerOpen: false },
2742 });
2743 const index = threadJumpIndexFromCommand(command ?? "");
2744 const target = index === null ? undefined : filteredDiscoveredSshHosts[index];
2745 if (target) {
2746 event.preventDefault();
2747 event.stopPropagation();
2748 setSshHostSuggestionsOpen(false);
2749 void handleSelectSshHostSuggestion(target);
2750 return;
2751 }
2752
2753 if (event.key === "Escape") {
2754 event.preventDefault();
2755 event.stopPropagation();
2756 return;
2757 }
2758 }
2759
2760 // A highlighted row means Enter belongs to the autocomplete, which selects it.
2761 const hasHighlightedSshHost =
2762 isSshHostPopupVisible && highlightedSshHostRef.current !== undefined;
2763 if (
2764 !event.defaultPrevented &&
2765 !hasHighlightedSshHost &&
2766 event.key === "Enter" &&
2767 savedBackendSshHost.trim().length > 0
2768 ) {
2769 event.preventDefault();
2770 void handleAddSavedBackend();
2771 }
2772 },
2773 [
2774 filteredDiscoveredSshHosts,
2775 handleAddSavedBackend,
2776 handleSelectSshHostSuggestion,
2777 hasSshHostSuggestionContent,
2778 keybindings,
2779 savedBackendSshHost,
2780 sshHostSuggestionsOpen,
2781 ],
2782 );
2783
2784 const handleSetSavedBackendEnabled = useCallback(
2785 async (environmentId: EnvironmentId, enabled: boolean) => {
2786 setSavedBackendError(null);
2787 const result = await setEnvironmentEnabled({ environmentId, enabled });
2788 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2789 const error = squashAtomCommandFailure(result);
2790 const message =
2791 error instanceof Error
2792 ? error.message
2793 : `Failed to switch the backend ${enabled ? "on" : "off"}.`;
2794 setSavedBackendError(message);
2795 toastManager.add(
2796 stackedThreadToast({
2797 type: "error",
2798 title: `Could not switch backend ${enabled ? "on" : "off"}`,
2799 description: message,
2800 }),
2801 );
2802 }
2803 },
2804 [setEnvironmentEnabled],
2805 );
2806
2807 const removeSavedBackend = useCallback(
2808 async (environment: EnvironmentPresentation) => {
2809 const environmentId = environment.environmentId;
2810 setRemovingSavedEnvironmentId(environmentId);
2811 setSavedBackendError(null);
2812 const result = await removeEnvironment(environmentId);
2813 setRemovingSavedEnvironmentId(null);
2814 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2815 const error = squashAtomCommandFailure(result);
2816 const message = error instanceof Error ? error.message : "Failed to remove backend.";
2817 setSavedBackendError(message);
2818 toastManager.add(
2819 stackedThreadToast({
2820 type: "error",
2821 title: "Could not remove backend",
2822 description: message,
2823 }),
2824 );
2825 }
2826 },
2827 [removeEnvironment],
2828 );
2829
2830 // Removing forgets the pairing, credentials, and cached threads on this
2831 // device. Switching off is the reversible path, so removal always confirms.
2832 // T3 Connect environments get their own dialog: removing one here leaves its
2833 // account registration, so it points to where that can be deregistered.
2834 const [pendingT3ConnectRemoval, setPendingT3ConnectRemoval] =
2835 useState<EnvironmentPresentation | null>(null);
2836 const handleRemoveSavedBackend = useCallback(
2837 async (environment: EnvironmentPresentation) => {
2838 if (environment.relayManaged && hasCloudPublicConfig()) {
2839 setPendingT3ConnectRemoval(environment);
2840 return;
2841 }
2842 // Fail closed: no mounted confirm host means no removal.
2843 const confirmed = await requestConfirmDialog(
2844 `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`,
2845 { variant: "destructive" },
2846 );
2847 if (confirmed === true) await removeSavedBackend(environment);
2848 },
2849 [removeSavedBackend],
2850 );
2851
2852 const visibleDesktopPairingLinks = desktopPairingLinks;
2853 const tailscaleHttpsEndpoint = useMemo(
2854 () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null,
2855 [desktopAdvertisedEndpoints],
2856 );
2857 const visibleDesktopNetworkAdvertisedEndpoints = useMemo(
2858 () =>
2859 isLocalBackendNetworkAccessible
2860 ? desktopAdvertisedEndpoints.filter((endpoint) => !isTailscaleHttpsEndpoint(endpoint))
2861 : [],
2862 [desktopAdvertisedEndpoints, isLocalBackendNetworkAccessible],
2863 );
2864 const visibleDesktopAdvertisedEndpoints = useMemo(
2865 () =>
2866 tailscaleHttpsEndpoint
2867 ? [...visibleDesktopNetworkAdvertisedEndpoints, tailscaleHttpsEndpoint]
2868 : visibleDesktopNetworkAdvertisedEndpoints,
2869 [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints],
2870 );
2871 const defaultDesktopNetworkAdvertisedEndpoint = useMemo(
2872 () =>
2873 selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey),
2874 [defaultAdvertisedEndpointKey, visibleDesktopNetworkAdvertisedEndpoints],
2875 );
2876 const defaultDesktopAdvertisedEndpoint = useMemo(
2877 () =>
2878 defaultDesktopNetworkAdvertisedEndpoint ??
2879 selectPairingEndpoint(
2880 tailscaleHttpsEndpoint ? [tailscaleHttpsEndpoint] : [],
2881 defaultAdvertisedEndpointKey,
2882 ),
2883 [defaultAdvertisedEndpointKey, defaultDesktopNetworkAdvertisedEndpoint, tailscaleHttpsEndpoint],
2884 );
2885 const defaultDesktopAdvertisedEndpointKey = defaultDesktopAdvertisedEndpoint
2886 ? endpointDefaultPreferenceKey(defaultDesktopAdvertisedEndpoint)
2887 : null;
2888 const handleSetDefaultAdvertisedEndpoint = useCallback(
2889 (endpoint: AdvertisedEndpoint) => {
2890 setDefaultAdvertisedEndpointKey(endpointDefaultPreferenceKey(endpoint));
2891 },
2892 [setDefaultAdvertisedEndpointKey],
2893 );
2894 const handleSavedBackendHostChange = useCallback((value: string) => {
2895 const parsedPairingUrl = parsePairingUrlFields(value);
2896 if (parsedPairingUrl) {
2897 setSavedBackendHost(parsedPairingUrl.host);
2898 setSavedBackendPairingCode(parsedPairingUrl.pairingCode);
2899 return;
2900 }
2901 setSavedBackendHost(value);
2902 }, []);
2903
2904 const renderConnectionModeCard = (input: {
2905 readonly mode: "remote" | "ssh";
2906 readonly title: string;
2907 readonly description: string;
2908 readonly icon?: ReactNode;
2909 }) => {
2910 const selected = savedBackendMode === input.mode;
2911 return (
2912 <button
2913 type="button"
2914 aria-pressed={selected}
2915 className={cn(
2916 "group flex min-h-24 items-start gap-3 rounded-lg border p-4 text-left",
2917 selected ? "border-primary/50 bg-primary/5" : "border-border/60 hover:bg-muted/40",
2918 )}
2919 disabled={isAddingSavedBackend}
2920 onClick={() => {
2921 setSavedBackendMode(input.mode);
2922 }}
2923 >
2924 {input.icon ? (
2925 <span
2926 className={cn(
2927 "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border",
2928 selected
2929 ? "border-primary/30 bg-primary/10 text-primary"
2930 : "border-border/70 bg-background text-muted-foreground group-hover:text-foreground",
2931 )}
2932 >
2933 {input.icon}
2934 </span>
2935 ) : null}
2936 <span className="min-w-0">
2937 <span className="block text-sm font-medium text-foreground">{input.title}</span>
2938 <span className="mt-1 block text-xs leading-relaxed text-muted-foreground">
2939 {input.description}
2940 </span>
2941 </span>
2942 </button>
2943 );
2944 };
2945
2946 const renderRemoteFields = () => (
2947 <div className="space-y-3">
2948 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_10rem]">
2949 <label className="block">
2950 <span className="mb-1.5 block text-xs font-medium text-foreground">Host</span>
2951 <Input
2952 value={savedBackendHost}
2953 onChange={(event) => handleSavedBackendHostChange(event.target.value)}
2954 placeholder="backend.example.com"
2955 disabled={isAddingSavedBackend}
2956 spellCheck={false}
2957 />
2958 </label>
2959 <label className="block">
2960 <span className="mb-1.5 block text-xs font-medium text-foreground">Pairing code</span>
2961 <Input
2962 value={savedBackendPairingCode}
2963 onChange={(event) => setSavedBackendPairingCode(event.target.value)}
2964 placeholder="PAIRCODE"
2965 disabled={isAddingSavedBackend}
2966 spellCheck={false}
2967 />
2968 </label>
2969 </div>
2970 <div>
2971 <span className="mt-1 block text-2xs text-muted-foreground">
2972 Paste a full pairing URL here to fill both fields automatically.
2973 </span>
2974 </div>
2975 </div>
2976 );
2977 // T3 Connect is offered as a route when this account can reach the machine
2978 // through it and it is not one of the machine's routes yet.
2979 const relayRouteOffer =
2980 routeTarget !== null &&
2981 !routeTarget.relayManaged &&
2982 relayDiscoveryState.environments.has(routeTarget.environmentId)
2983 ? relayDiscoveryState.environments.get(routeTarget.environmentId)!.environment
2984 : null;
2985 const addRelayRoute = async () => {
2986 if (relayRouteOffer === null || routeTarget === null) return;
2987 setIsAddingSavedBackend(true);
2988 const result = await registerEnvironment(
2989 new RelayConnectionRegistration({
2990 target: new RelayConnectionTarget({
2991 environmentId: relayRouteOffer.environmentId,
2992 label: routeTarget.label,
2993 }),
2994 }),
2995 );
2996 setIsAddingSavedBackend(false);
2997 if (result._tag === "Failure") {
2998 if (!isAtomCommandInterrupted(result)) {
2999 const error = squashAtomCommandFailure(result);
3000 setSavedBackendError(error instanceof Error ? error.message : "Could not add the route.");
3001 }
3002 return;
3003 }
3004 setAddBackendDialogOpen(false);
3005 toastManager.add({
3006 type: "success",
3007 title: "Route added",
3008 description: `${routeTarget.label} falls back to T3 Connect when its other routes are unreachable.`,
3009 });
3010 };
3011 const renderRemoteModeBody = () => (
3012 <div className="space-y-4">
3013 {relayRouteOffer !== null ? (
3014 <div className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2">
3015 <p className="text-xs text-muted-foreground">
3016 This machine is on your T3 Connect account. Use it as a fallback route.
3017 </p>
3018 <Button
3019 size="xs"
3020 variant="outline"
3021 disabled={isAddingSavedBackend}
3022 onClick={() => void addRelayRoute()}
3023 >
3024 Add T3 Connect
3025 </Button>
3026 </div>
3027 ) : null}
3028 {renderRemoteFields()}
3029 {savedBackendError ? <p className="text-xs text-destructive">{savedBackendError}</p> : null}
3030 <Button
3031 variant="outline"
3032 className="w-full"
3033 disabled={isAddingSavedBackend}
3034 onClick={() => void handleAddSavedBackend()}
3035 >
3036 <PlusIcon className="size-3.5" />
3037 {isAddingSavedBackend ? "Adding…" : routeTarget ? "Add route" : "Add environment"}
3038 </Button>
3039 </div>
3040 );
3041 const renderSshFields = () => (
3042 <div className="space-y-4">
3043 <div className="space-y-3">
3044 <div className="block">
3045 <label
3046 htmlFor="saved-backend-ssh-host"
3047 className="mb-1.5 block text-xs font-medium text-foreground"
3048 >
3049 SSH host or alias
3050 </label>
3051 <Autocomplete
3052 items={filteredDiscoveredSshHosts}
3053 itemToStringValue={(target) => target.alias}
3054 mode="none"
3055 openOnInputClick
3056 open={sshHostSuggestionsOpen}
3057 onOpenChange={setSshHostSuggestionsOpen}
3058 onItemHighlighted={(target) => {
3059 highlightedSshHostRef.current = target;
3060 }}
3061 value={savedBackendSshHost}
3062 onValueChange={(value, eventDetails) => {
3063 setSavedBackendSshHost(value);
3064 if (eventDetails.reason !== "item-press") return;
3065
3066 const target = filteredDiscoveredSshHosts.find((host) => host.alias === value);
3067 if (target) void handleSelectSshHostSuggestion(target);
3068 }}
3069 >
3070 <AutocompleteInput
3071 id="saved-backend-ssh-host"
3072 onKeyDown={handleSavedBackendSshHostKeyDown}
3073 placeholder="Search hosts or type devbox"
3074 disabled={isAddingSavedBackend}
3075 spellCheck={false}
3076 />
3077 {hasSshHostSuggestionContent ? (
3078 <AutocompletePopup>
3079 {isLoadingDiscoveredSshHosts ? (
3080 <div className="px-3 py-2 text-xs text-muted-foreground">Loading hosts…</div>
3081 ) : filteredDiscoveredSshHosts.length > 0 ? (
3082 <AutocompleteList className="max-h-72">
3083 {filteredDiscoveredSshHosts.map((target, index) => {
3084 const address = formatDesktopSshTarget(target);
3085 const shortcutCommand = index < 9 ? threadJumpCommandForIndex(index) : null;
3086 const shortcutLabel = shortcutCommand
3087 ? shortcutLabelForCommand(keybindings, shortcutCommand, navigator.platform)
3088 : null;
3089 return (
3090 <AutocompleteItem
3091 key={`${target.alias}:${target.hostname}:${target.port ?? ""}`}
3092 value={target}
3093 className="h-8 min-h-8 whitespace-nowrap"
3094 >
3095 <span className="min-w-0 truncate text-sm font-medium">
3096 {target.alias}
3097 </span>
3098 {address !== target.alias ? (
3099 <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
3100 {address}
3101 </span>
3102 ) : (
3103 <span className="flex-1" />
3104 )}
3105 {shortcutLabel ? (
3106 <CommandShortcut className="shrink-0">{shortcutLabel}</CommandShortcut>
3107 ) : null}
3108 </AutocompleteItem>
3109 );
3110 })}
3111 </AutocompleteList>
3112 ) : (
3113 <AutocompleteEmpty className="break-all">
3114 No hosts match "{savedBackendSshHost.trim()}".
3115 </AutocompleteEmpty>
3116 )}
3117 </AutocompletePopup>
3118 ) : null}
3119 </Autocomplete>
3120 </div>
3121 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem]">
3122 <label className="block">
3123 <span className="mb-1.5 block text-xs font-medium text-foreground">Username</span>
3124 <Input
3125 value={savedBackendSshUsername}
3126 onChange={(event) => setSavedBackendSshUsername(event.target.value)}
3127 onKeyDown={handleSavedBackendSshFieldKeyDown}
3128 placeholder="root"
3129 disabled={isAddingSavedBackend}
3130 spellCheck={false}
3131 />
3132 </label>
3133 <label className="block">
3134 <span className="mb-1.5 block text-xs font-medium text-foreground">Port</span>
3135 <Input
3136 value={savedBackendSshPort}
3137 onChange={(event) => setSavedBackendSshPort(event.target.value)}
3138 onKeyDown={handleSavedBackendSshFieldKeyDown}
3139 placeholder="22"
3140 inputMode="numeric"
3141 disabled={isAddingSavedBackend}
3142 spellCheck={false}
3143 />
3144 </label>
3145 </div>
3146 {savedBackendError || discoveredSshHostsError ? (
3147 <Alert variant="error">
3148 <AlertDescription>{savedBackendError ?? discoveredSshHostsError}</AlertDescription>
3149 </Alert>
3150 ) : null}
3151 <Button
3152 variant="outline"
3153 className="w-full"
3154 disabled={isAddingSavedBackend}
3155 onClick={() => void handleAddSavedBackend()}
3156 >
3157 <PlusIcon className="size-3.5" />
3158 {isAddingSavedBackend ? "Adding…" : routeTarget ? "Add route" : "Add environment"}
3159 </Button>
3160 </div>
3161 </div>
3162 );
3163 const renderNetworkAccessToggle = () => (
3164 <Switch
3165 checked={desktopServerExposureState?.mode === "network-accessible"}
3166 disabled={!desktopServerExposureState || isUpdatingDesktopServerExposure}
3167 onCheckedChange={(checked) => {
3168 setPendingDesktopServerExposureMode(checked ? "network-accessible" : "local-only");
3169 setIsDesktopServerExposureDialogOpen(true);
3170 }}
3171 aria-label="Enable network access"
3172 />
3173 );
3174 const renderEndpointRows = (presentation: AccessSectionPresentation) =>
3175 isAdvertisedEndpointListExpanded
3176 ? visibleDesktopNetworkAdvertisedEndpoints.map((endpoint) => {
3177 const endpointKey = endpointDefaultPreferenceKey(endpoint);
3178 return (
3179 <AdvertisedEndpointListRow
3180 key={endpoint.id}
3181 endpoint={endpoint}
3182 isDefault={endpointKey === defaultDesktopAdvertisedEndpointKey}
3183 presentation={presentation}
3184 onSetDefault={handleSetDefaultAdvertisedEndpoint}
3185 onSetupTailscaleServe={handleStartTailscaleServeSetup}
3186 onDisableTailscaleServe={handleStartTailscaleServeDisable}
3187 isUpdatingTailscaleServe={isUpdatingTailscaleServe}
3188 />
3189 );
3190 })
3191 : null;
3192 // Apply a setting change immediately. The orchestrator reconciles the
3193 // pool in the background and the primary backend is untouched, so we
3194 // don't gate this behind a confirmation dialog. After the desktop
3195 // side persists the change and nudges its orchestrator, we trigger
3196 // the renderer's reconciler so the WSL backend's saved-env-shaped
3197 // entry catches up (registers/unregisters) without a reload.
3198 const applyWslSettingChange = useCallback(
3199 async (apply: () => Promise<DesktopWslState>) => {
3200 if (
3201 !desktopBridge ||
3202 primaryEnvironmentId === null ||
3203 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
3204 )
3205 return;
3206 setIsUpdatingWslBackend(true);
3207 setDesktopWslMutationError(null);
3208 try {
3209 await apply();
3210 refreshDesktopWslState();
3211 // The connection platform source polls the desktop bootstrap list and
3212 // reconciles the environment catalog automatically, so toggling the WSL
3213 // backend on/off or switching distros is picked up here without an
3214 // explicit renderer reconcile.
3215 } catch (error) {
3216 const message = error instanceof Error ? error.message : "Failed to update WSL backend.";
3217 setDesktopWslMutationError(message);
3218 toastManager.add(
3219 stackedThreadToast({
3220 type: "error",
3221 title: "Could not change WSL backend",
3222 description: message,
3223 }),
3224 );
3225 refreshDesktopWslState();
3226 } finally {
3227 setIsUpdatingWslBackend(false);
3228 }
3229 },
3230 [desktopBridge, primaryEnvironmentId],
3231 );
3232
3233 // Reload the keep-alive WSL state atom. Clearing the mutation error before
3234 // refresh lets the atom-owned load error become the visible retry state.
3235 const loadWslState = useCallback(() => {
3236 setDesktopWslMutationError(null);
3237 refreshDesktopWslState();
3238 }, []);
3239
3240 // True when a desktop-local WSL backend is currently registered as an
3241 // environment on this machine. We use this as a proxy for "the user has work
3242 // that lives on the WSL side": if WSL has connected in a way that registered
3243 // the env, disabling or switching distros could disrupt open threads/projects.
3244 // If WSL never connected (fresh install, toggled on then immediately off,
3245 // etc.) there's no local environment, so we skip the confirmation dialog.
3246 const hasWslRegistrationToLose = useMemo(() => {
3247 return environments.some((environment) =>
3248 isDesktopLocalConnectionTarget(environment.entry.target),
3249 );
3250 }, [environments]);
3251
3252 // Single picker for "WSL backend off" vs "running on distro X". The
3253 // dropdown maps "Off" to disable and any distro entry to enable +
3254 // run on that distro. Splitting these into a separate switch and
3255 // dropdown was confusing — they're the same decision.
3256 const handleSelectWslMode = useCallback(
3257 (value: string) => {
3258 if (!desktopBridge || !desktopWslState) return;
3259 const defaultDistroName =
3260 desktopWslState.distros.find((distro) => distro.isDefault)?.name ?? null;
3261 if (value === BACKEND_VALUE_WSL_OFF) {
3262 // Match the recovery row's visibility (`enabled || wslOnly`): when WSL
3263 // went unavailable while wsl-only was persisted, `enabled` can be false
3264 // while `wslOnly` is true, and the "Switch to Windows" button must
3265 // still clear that state instead of silently no-op'ing.
3266 if (!desktopWslState.enabled && !desktopWslState.wslOnly) return;
3267 const wasWslOnly = desktopWslState.wslOnly;
3268 // Confirm when there's WSL state to lose, OR when wsl-only is
3269 // on (turning the only running backend off needs to switch
3270 // back to Windows and restart — always consequential).
3271 if (hasWslRegistrationToLose || wasWslOnly) {
3272 setPendingWslChange({ kind: "disable", wasWslOnly });
3273 return;
3274 }
3275 void applyWslSettingChange(() => desktopBridge.setWslBackendEnabled(false));
3276 return;
3277 }
3278 const nextDistro = value === BACKEND_VALUE_DEFAULT_WSL ? null : value;
3279 const resolvedNext = nextDistro ?? defaultDistroName;
3280 if (!desktopWslState.enabled) {
3281 // Was off, user picked a distro: ask whether to run both
3282 // backends or only WSL. We always ask here so the user picks
3283 // the mode upfront instead of having to discover the wsl-only
3284 // switch afterwards.
3285 setPendingWslChange({ kind: "enable", nextDistro });
3286 return;
3287 }
3288 // Already enabled — treat as a distro switch. Skip the change if
3289 // the user re-picked the row that's already selected.
3290 const resolvedCurrent = desktopWslState.distro ?? defaultDistroName;
3291 if (resolvedCurrent === resolvedNext) return;
3292 // Confirm when there's WSL registration to lose, OR in wsl-only mode:
3293 // there the primary IS the WSL backend, so a distro change relaunches
3294 // the app (the IPC handler does this) rather than swapping a secondary,
3295 // and the user should see that coming.
3296 if (hasWslRegistrationToLose || desktopWslState.wslOnly) {
3297 setPendingWslChange({ kind: "distro", nextDistro });
3298 return;
3299 }
3300 void applyWslSettingChange(() => desktopBridge.setWslDistro(nextDistro));
3301 },
3302 [applyWslSettingChange, desktopBridge, desktopWslState, hasWslRegistrationToLose],
3303 );
3304
3305 // Dispatched from the enable modal's two action buttons.
3306 const handleConfirmEnableWsl = useCallback(
3307 (mode: "both" | "wsl-only") => {
3308 if (!desktopBridge || !pendingWslChange || pendingWslChange.kind !== "enable") return;
3309 const nextDistro = pendingWslChange.nextDistro;
3310 setPendingWslChange(null);
3311 const persistedDistro = desktopWslState?.distro ?? null;
3312 void applyWslSettingChange(() =>
3313 applyWslEnableSelection({
3314 bridge: desktopBridge,
3315 mode,
3316 nextDistro,
3317 persistedDistro,
3318 }),
3319 );
3320 },
3321 [applyWslSettingChange, desktopBridge, desktopWslState, pendingWslChange],
3322 );
3323
3324 const handleToggleWslOnly = useCallback(
3325 (enabled: boolean) => {
3326 if (!desktopBridge || !desktopWslState || desktopWslState.wslOnly === enabled) return;
3327 // wsl-only changes which backend the pool uses as "primary",
3328 // which is decided once at app launch. The desktop side persists
3329 // the setting immediately but doesn't tear down or restart
3330 // anything itself; the renderer warns the user to expect a
3331 // restart and (in a follow-up) can trigger it automatically.
3332 // Always prompt — even enabling is consequential here.
3333 setPendingWslChange({ kind: "wsl-only", nextValue: enabled });
3334 },
3335 [desktopBridge, desktopWslState],
3336 );
3337
3338 const handleConfirmWslChange = useCallback(() => {
3339 if (!desktopBridge || !pendingWslChange) return;
3340 const change = pendingWslChange;
3341 // The enable kind resolves through handleConfirmEnableWsl, not
3342 // this single Confirm path.
3343 if (change.kind === "enable") return;
3344 setPendingWslChange(null);
3345 if (change.kind === "disable") {
3346 void applyWslSettingChange(async () => {
3347 const next = await desktopBridge.setWslBackendEnabled(false);
3348 if (change.wasWslOnly) {
3349 // Clearing wsl-only relaunches onto the Windows backend.
3350 return await desktopBridge.setWslOnly(false);
3351 }
3352 return next;
3353 });
3354 return;
3355 }
3356 if (change.kind === "distro") {
3357 void applyWslSettingChange(() => desktopBridge.setWslDistro(change.nextDistro));
3358 return;
3359 }
3360 void applyWslSettingChange(() => desktopBridge.setWslOnly(change.nextValue));
3361 }, [applyWslSettingChange, desktopBridge, pendingWslChange]);
3362
3363 const renderWslRow = () => {
3364 if (!desktopWslState) {
3365 // A load failed: keep a recovery row (with retry) visible instead of
3366 // silently hiding the section. The error persists across an in-flight
3367 // retry so the row doesn't flicker away, and the button reflects the
3368 // loading state. With no error we simply haven't loaded yet (or WSL
3369 // management isn't available), so render nothing.
3370 if (
3371 isWslSettingsRowVisible({ state: null, error: desktopWslError }) &&
3372 canManageLocalBackend
3373 ) {
3374 return (
3375 <SettingsRow
3376 {...searchableSetting("wsl-backend")}
3377 description="Couldn't load the WSL backend state."
3378 status={<span className="block text-destructive">{desktopWslError}</span>}
3379 control={
3380 <Button
3381 size="sm"
3382 variant="outline"
3383 onClick={loadWslState}
3384 disabled={isLoadingWslState}
3385 >
3386 {isLoadingWslState ? "Retrying…" : "Retry"}
3387 </Button>
3388 }
3389 />
3390 );
3391 }
3392 return null;
3393 }
3394 // WSL went unavailable while the user still has the WSL backend persisted
3395 // (it may have been uninstalled or its distro removed). The desktop side
3396 // falls back to the Windows backend, but the normal distro picker needs a
3397 // live distro list it no longer has. Without a control here the user would
3398 // be stranded on a WSL preference they can't clear, so render a recovery
3399 // row that switches back to Windows. When WSL is unavailable AND unused,
3400 // there's nothing to recover — keep the section hidden as before.
3401 if (!isWslSettingsRowVisible({ state: desktopWslState, error: desktopWslError })) {
3402 return null;
3403 }
3404 if (!desktopWslState.available) {
3405 return (
3406 <SettingsRow
3407 {...searchableSetting("wsl-backend")}
3408 description="WSL is unavailable, so Windows is running instead. Turn WSL off to clear this preference."
3409 status={
3410 desktopWslError ? (
3411 <span className="block text-destructive">{desktopWslError}</span>
3412 ) : null
3413 }
3414 control={
3415 <Button
3416 size="sm"
3417 variant="outline"
3418 disabled={isUpdatingWslBackend}
3419 onClick={() => handleSelectWslMode(BACKEND_VALUE_WSL_OFF)}
3420 >
3421 Switch to Windows
3422 </Button>
3423 }
3424 />
3425 );
3426 }
3427 // Distro is null when the user wants the WSL default. Map it to the
3428 // real default's name so the Select highlights a real option; fall
3429 // back to the sentinel only when no distros are listed yet (the
3430 // dropdown then renders a single placeholder that matches).
3431 const defaultDistroName =
3432 desktopWslState.distros.find((distro) => distro.isDefault)?.name ?? null;
3433 const selectValue = !desktopWslState.enabled
3434 ? BACKEND_VALUE_WSL_OFF
3435 : (desktopWslState.distro ?? defaultDistroName ?? BACKEND_VALUE_DEFAULT_WSL);
3436 const selectLabel =
3437 selectValue === BACKEND_VALUE_WSL_OFF
3438 ? "Off"
3439 : selectValue === BACKEND_VALUE_DEFAULT_WSL
3440 ? "Default distro"
3441 : selectValue;
3442 return (
3443 <>
3444 <SettingsRow
3445 {...searchableSetting("wsl-backend")}
3446 description="Run the selected WSL distro alongside Windows. Projects remain on their current filesystem."
3447 status={
3448 desktopWslError ? (
3449 <span className="block text-destructive">{desktopWslError}</span>
3450 ) : desktopWslState.preflightError ? (
3451 <span className="block text-destructive">
3452 WSL backend couldn't start: {desktopWslState.preflightError}
3453 </span>
3454 ) : null
3455 }
3456 control={
3457 <Select
3458 value={selectValue}
3459 onValueChange={(value) => {
3460 if (typeof value !== "string") return;
3461 handleSelectWslMode(value);
3462 }}
3463 >
3464 <SelectTrigger
3465 size="sm"
3466 className="w-full sm:w-56"
3467 aria-label="WSL backend"
3468 disabled={isUpdatingWslBackend}
3469 >
3470 <SelectValue>{selectLabel}</SelectValue>
3471 </SelectTrigger>
3472 <SelectPopup align="end" alignItemWithTrigger={false}>
3473 <SelectItem hideIndicator value={BACKEND_VALUE_WSL_OFF}>
3474 Off
3475 </SelectItem>
3476 {desktopWslState.distros.length === 0 ? (
3477 <SelectItem hideIndicator value={BACKEND_VALUE_DEFAULT_WSL}>
3478 Default distro
3479 </SelectItem>
3480 ) : (
3481 desktopWslState.distros.map((distro) => (
3482 <SelectItem hideIndicator key={distro.name} value={distro.name}>
3483 {distro.name}
3484 {distro.isDefault ? " (default)" : ""}
3485 </SelectItem>
3486 ))
3487 )}
3488 </SelectPopup>
3489 </Select>
3490 }
3491 />
3492 {desktopWslState.enabled ? (
3493 <SettingsRow
3494 title="WSL only"
3495 description="Run only the WSL backend. The Knife restarts when this changes."
3496 className="bg-muted/20 pl-7 sm:pl-8"
3497 control={
3498 <Switch
3499 checked={desktopWslState.wslOnly}
3500 disabled={isUpdatingWslBackend}
3501 onCheckedChange={(checked) => handleToggleWslOnly(checked)}
3502 aria-label="Run WSL only"
3503 />
3504 }
3505 />
3506 ) : null}
3507 </>
3508 );
3509 };
3510
3511 const renderTailscaleRow = () => (
3512 <SettingsRow
3513 title={searchableSetting("tailscale-https").title}
3514 description={
3515 tailscaleHttpsEndpoint
3516 ? tailscaleHttpsEndpoint.status === "available"
3517 ? tailscaleHttpsEndpoint.httpBaseUrl
3518 : "Use Tailscale Serve to expose this backend through a MagicDNS HTTPS URL."
3519 : "Start Tailscale to set up HTTPS access through MagicDNS."
3520 }
3521 control={
3522 tailscaleHttpsEndpoint ? (
3523 <Switch
3524 checked={tailscaleHttpsEndpoint.status === "available"}
3525 disabled={isUpdatingTailscaleServe}
3526 onCheckedChange={(checked) => {
3527 if (checked) {
3528 handleStartTailscaleServeSetup(tailscaleHttpsEndpoint);
3529 return;
3530 }
3531 handleStartTailscaleServeDisable(tailscaleHttpsEndpoint);
3532 }}
3533 aria-label="Enable Tailscale HTTPS"
3534 />
3535 ) : null
3536 }
3537 />
3538 );
3539 const renderAuthorizedClients = (presentation: AccessSectionPresentation) => (
3540 <>
3541 {desktopAccessManagementError ? (
3542 <div className={accessRowClassName(presentation)}>
3543 <p className="text-xs text-destructive">{desktopAccessManagementError}</p>
3544 </div>
3545 ) : null}
3546 <PairingClientsList
3547 endpointUrl={desktopServerExposureState?.endpointUrl}
3548 endpoints={visibleDesktopAdvertisedEndpoints}
3549 defaultEndpointKey={defaultDesktopAdvertisedEndpointKey}
3550 presentation={presentation}
3551 isLoading={isLoadingDesktopAccessManagement}
3552 pairingLinks={visibleDesktopPairingLinks}
3553 createdPairingCredentials={createdPairingCredentials}
3554 clientSessions={desktopClientSessions}
3555 revokingPairingLinkId={revokingDesktopPairingLinkId}
3556 revokingClientSessionId={revokingDesktopClientSessionId}
3557 onRevokePairingLink={handleRevokeDesktopPairingLink}
3558 onRevokeClientSession={handleRevokeDesktopClientSession}
3559 canRevoke={canWriteAccess}
3560 />
3561 </>
3562 );
3563 const renderNetworkAccessRow = () => (
3564 <SettingsRow
3565 title={searchableSetting("network-access").title}
3566 description={
3567 isLocalBackendNetworkAccessible ? (
3568 <NetworkAccessDescription
3569 endpoint={defaultDesktopNetworkAdvertisedEndpoint}
3570 hiddenEndpointCount={Math.max(visibleDesktopNetworkAdvertisedEndpoints.length - 1, 0)}
3571 expanded={isAdvertisedEndpointListExpanded}
3572 onToggleExpanded={() => setIsAdvertisedEndpointListExpanded((expanded) => !expanded)}
3573 fallback={
3574 desktopServerExposureState?.endpointUrl
3575 ? `Reachable at ${desktopServerExposureState.endpointUrl}`
3576 : desktopServerExposureState?.advertisedHost
3577 ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.`
3578 : "Exposed on all interfaces."
3579 }
3580 />
3581 ) : desktopServerExposureState ? (
3582 "Limited to this machine."
3583 ) : (
3584 "Loading…"
3585 )
3586 }
3587 status={
3588 desktopServerExposureError ? (
3589 <span className="block text-destructive">{desktopServerExposureError}</span>
3590 ) : null
3591 }
3592 control={renderNetworkAccessToggle()}
3593 />
3594 );
3595 const renderDisabledNetworkAccessRow = () => (
3596 <SettingsRow
3597 title={searchableSetting("network-access").title}
3598 description={
3599 desktopBridge
3600 ? "This connection cannot view or change network exposure."
3601 : currentAuthPolicy === "remote-reachable"
3602 ? "Remote access is already configured. Change network exposure where the server starts."
3603 : currentAuthPolicy === "loopback-browser"
3604 ? "Only this machine can connect. Restart with a non-loopback host for remote pairing."
3605 : "Network exposure information is unavailable."
3606 }
3607 control={
3608 !desktopBridge &&
3609 (currentAuthPolicy === "remote-reachable" || currentAuthPolicy === "loopback-browser") ? (
3610 <Tooltip>
3611 <TooltipTrigger
3612 render={
3613 <span className="inline-flex">
3614 <Switch
3615 checked={isLocalBackendNetworkAccessible}
3616 disabled
3617 aria-label="Enable network access"
3618 />
3619 </span>
3620 }
3621 />
3622 <TooltipPopup side="top">
3623 Network exposure changes restart the backend and must be controlled where the server
3624 process is launched.
3625 </TooltipPopup>
3626 </Tooltip>
3627 ) : undefined
3628 }
3629 />
3630 );
3631
3632 const primarySettings = (
3633 <>
3634 {primaryEnvironmentId !== null ? (
3635 <>
3636 <SettingsSection
3637 {...searchableSetting("connections-environment")}
3638 title={
3639 primaryEnvironment?.label ?? (desktopBridge ? "This machine" : "Primary environment")
3640 }
3641 icon={
3642 <EnvironmentMachineIcon
3643 aria-hidden
3644 kind={
3645 primaryServerConfig
3646 ? resolveEnvironmentMachineKind(primaryServerConfig)
3647 : "desktop"
3648 }
3649 className="size-4"
3650 />
3651 }
3652 headerAction={
3653 primaryEnvironmentId !== null ? (
3654 <Menu>
3655 <MenuTrigger
3656 render={
3657 <Button
3658 type="button"
3659 variant="ghost-muted"
3660 size="icon-xs"
3661 aria-label="More actions for this machine"
3662 />
3663 }
3664 >
3665 <EllipsisIcon className="size-3.5" />
3666 </MenuTrigger>
3667 <MenuPopup align="end">
3668 <EnvironmentIconMenu
3669 environmentId={primaryEnvironmentId}
3670 serverConfig={primaryServerConfig}
3671 />
3672 </MenuPopup>
3673 </Menu>
3674 ) : null
3675 }
3676 >
3677 <LocalEnvironmentSetting />
3678 {canManageLocalBackend ? (
3679 <SettingsRow
3680 title="Version"
3681 description={
3682 primaryServerUpdateState.status !== "idle" ? (
3683 <ServerUpdateProgress state={primaryServerUpdateState} />
3684 ) : (
3685 [
3686 primaryServerConfig?.environment.serverVersion ?? null,
3687 primaryEnvironment?.displayUrl ?? null,
3688 ]
3689 .filter((value): value is string => value !== null)
3690 .join(" · ") || "Loading…"
3691 )
3692 }
3693 control={
3694 primaryVersionMismatch &&
3695 primaryEnvironmentId !== null &&
3696 primaryServerUpdateState.status !== "running" ? (
3697 <ServerUpdateAction
3698 size="sm"
3699 environmentId={primaryEnvironmentId}
3700 serverLabel={
3701 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"
3702 }
3703 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3704 installation={
3705 primaryServerConfig?.environment.capabilities.serverInstallation
3706 }
3707 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
3708 threadContinuation={supportsServerUpdateThreadContinuation(
3709 primaryServerConfig,
3710 )}
3711 targetVersion={primaryVersionMismatch.clientVersion}
3712 label={
3713 primaryServerUpdateState.status === "failed"
3714 ? "Retry update"
3715 : `Update to ${primaryVersionMismatch.clientVersion}`
3716 }
3717 />
3718 ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? (
3719 <span className="text-xs text-muted-foreground">Up to date</span>
3720 ) : undefined
3721 }
3722 />
3723 ) : null}
3724 {canManageLocalBackend && desktopBridge ? (
3725 <>
3726 {renderNetworkAccessRow()}
3727 {renderEndpointRows("endpoint-rail")}
3728 {renderTailscaleRow()}
3729 {renderWslRow()}
3730 {canReadRelay || canManageRelay ? (
3731 <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} />
3732 ) : null}
3733 </>
3734 ) : canManageLocalBackend ? (
3735 <>
3736 {renderDisabledNetworkAccessRow()}
3737 {canReadRelay || canManageRelay ? (
3738 <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} />
3739 ) : null}
3740 </>
3741 ) : null}
3742 {primaryEnvironment ? (
3743 <details className="group px-3 sm:px-4">
3744 <summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 rounded-md text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
3745 Permissions
3746 <ChevronRightIcon
3747 aria-hidden
3748 className="size-3 text-muted-foreground group-open:rotate-90"
3749 />
3750 </summary>
3751 <SessionPermissions
3752 environmentId={primaryEnvironment.environmentId}
3753 connected={primaryEnvironment.connection.phase === "connected"}
3754 />
3755 </details>
3756 ) : null}
3757 </SettingsSection>
3758
3759 {canReadAccess || canWriteAccess ? (
3760 <FoldedSettingsSection
3761 id="authorized-clients"
3762 title="Authorized clients"
3763 summary={summarizeAuthorizedClients(
3764 desktopClientSessions,
3765 visibleDesktopPairingLinks,
3766 )}
3767 control={
3768 canWriteAccess ? (
3769 <AuthorizedClientsHeaderAction
3770 delegatableScopes={currentSessionScopes ?? []}
3771 onPairingLinkCreated={handlePairingLinkCreated}
3772 clientSessions={
3773 canReadAccess &&
3774 authAccessChanges.error === null &&
3775 authAccessChanges.data?.type === "snapshot"
3776 ? desktopClientSessions
3777 : null
3778 }
3779 isRevokingOtherClients={isRevokingOtherDesktopClients}
3780 onRevokeOtherClients={handleRevokeOtherDesktopClients}
3781 />
3782 ) : undefined
3783 }
3784 >
3785 <ScrollArea
3786 scrollFade
3787 chainVerticalScroll
3788 className="max-h-[22.5rem]"
3789 data-testid="authorized-clients-scroll-area"
3790 >
3791 {canReadAccess ? (
3792 renderAuthorizedClients("current")
3793 ) : (
3794 <p className="px-4 py-3 text-xs text-muted-foreground">
3795 This connection can create access links but cannot view authorized clients.
3796 </p>
3797 )}
3798 </ScrollArea>
3799 </FoldedSettingsSection>
3800 ) : null}
3801 <AlertDialog
3802 open={isDesktopServerExposureDialogOpen && canManageLocalBackend}
3803 onOpenChange={(open) => {
3804 if (isUpdatingDesktopServerExposure) return;
3805 setIsDesktopServerExposureDialogOpen(open);
3806 }}
3807 onOpenChangeComplete={(open) => {
3808 if (!open) setPendingDesktopServerExposureMode(null);
3809 }}
3810 >
3811 <AlertDialogPopup>
3812 <AlertDialogHeader>
3813 <AlertDialogTitle>
3814 {pendingDesktopServerExposureMode === "network-accessible"
3815 ? "Enable network access?"
3816 : "Disable network access?"}
3817 </AlertDialogTitle>
3818 <AlertDialogDescription>
3819 {pendingDesktopServerExposureMode === "network-accessible"
3820 ? "Let your other devices connect to The Knife over the network. Pair devices to give them access. The Knife will restart."
3821 : "Devices connected over your local network will disconnect. Existing tunnels, such as T3 Connect or Tailscale HTTPS, keep working. The Knife will restart."}
3822 </AlertDialogDescription>
3823 </AlertDialogHeader>
3824 <AlertDialogFooter>
3825 <AlertDialogClose
3826 disabled={isUpdatingDesktopServerExposure}
3827 render={<Button variant="outline" disabled={isUpdatingDesktopServerExposure} />}
3828 >
3829 <span className="[text-box:trim-both_cap_alphabetic]">Cancel</span>
3830 </AlertDialogClose>
3831 <Button
3832 variant="default"
3833 onClick={handleConfirmDesktopServerExposureChange}
3834 disabled={
3835 !canManageLocalBackend ||
3836 pendingDesktopServerExposureMode === null ||
3837 isUpdatingDesktopServerExposure
3838 }
3839 >
3840 {isUpdatingDesktopServerExposure && <Spinner size="sm" />}
3841 <span className="[text-box:trim-both_cap_alphabetic]">
3842 {isUpdatingDesktopServerExposure
3843 ? "Restarting…"
3844 : pendingDesktopServerExposureMode === "network-accessible"
3845 ? "Restart and enable"
3846 : "Restart and disable"}
3847 </span>
3848 </Button>
3849 </AlertDialogFooter>
3850 </AlertDialogPopup>
3851 </AlertDialog>
3852 <AlertDialog
3853 open={isWslConfirmDialogOpen && canManageLocalBackend}
3854 onOpenChange={(open) => {
3855 if (isUpdatingWslBackend) return;
3856 if (!open) setPendingWslChange(null);
3857 }}
3858 >
3859 <AlertDialogPopup>
3860 <AlertDialogHeader>
3861 <AlertDialogTitle>
3862 {pendingWslChange?.kind === "disable"
3863 ? pendingWslChange.wasWslOnly
3864 ? "Turn off WSL and switch back to Windows?"
3865 : "Disable WSL backend?"
3866 : pendingWslChange?.kind === "distro"
3867 ? "Switch WSL distro?"
3868 : pendingWslChange?.kind === "enable"
3869 ? "Start the WSL backend"
3870 : pendingWslChange?.nextValue
3871 ? "Run only the WSL backend?"
3872 : "Re-enable the Windows backend?"}
3873 </AlertDialogTitle>
3874 <AlertDialogDescription>
3875 {pendingWslChange?.kind === "disable"
3876 ? pendingWslChange.wasWslOnly
3877 ? "The Knife will restart on the Windows backend. Threads and projects opened against WSL stay safe inside the distro and become available again when you re-enable WSL."
3878 : "The WSL backend will stop. Threads and projects opened against WSL stay safe inside the distro, but they'll be unavailable in The Knife until you re-enable WSL."
3879 : pendingWslChange?.kind === "distro"
3880 ? "The Knife will restart the WSL backend on the new distro. Sessions still running on the current distro will be interrupted."
3881 : pendingWslChange?.kind === "enable"
3882 ? "Run the WSL backend alongside the Windows one, or stop the Windows backend and use only WSL? You can change this later from Settings."
3883 : pendingWslChange?.nextValue
3884 ? "The Knife will restart and start only the WSL backend. Your Windows-side projects won't be accessible until you turn this off again."
3885 : "The Knife will restart and bring the Windows backend back up alongside WSL."}
3886 </AlertDialogDescription>
3887 </AlertDialogHeader>
3888 <AlertDialogFooter>
3889 <AlertDialogClose
3890 disabled={isUpdatingWslBackend}
3891 render={<Button variant="outline" disabled={isUpdatingWslBackend} />}
3892 >
3893 Cancel
3894 </AlertDialogClose>
3895 {pendingWslChange?.kind === "enable" ? (
3896 <>
3897 <Button
3898 variant="outline"
3899 onClick={() => handleConfirmEnableWsl("wsl-only")}
3900 disabled={isUpdatingWslBackend || !canManageLocalBackend}
3901 >
3902 {isUpdatingWslBackend ? (
3903 <>
3904 <Spinner size="sm" />
3905 Applying…
3906 </>
3907 ) : (
3908 "Use only WSL"
3909 )}
3910 </Button>
3911 <Button
3912 variant="default"
3913 onClick={() => handleConfirmEnableWsl("both")}
3914 disabled={isUpdatingWslBackend || !canManageLocalBackend}
3915 >
3916 {isUpdatingWslBackend ? (
3917 <>
3918 <Spinner size="sm" />
3919 Applying…
3920 </>
3921 ) : (
3922 "Run both backends"
3923 )}
3924 </Button>
3925 </>
3926 ) : (
3927 <Button
3928 variant={
3929 pendingWslChange?.kind === "disable" ||
3930 (pendingWslChange?.kind === "wsl-only" && pendingWslChange.nextValue)
3931 ? "destructive"
3932 : "default"
3933 }
3934 onClick={handleConfirmWslChange}
3935 disabled={isUpdatingWslBackend || !canManageLocalBackend}
3936 >
3937 {isUpdatingWslBackend ? (
3938 <>
3939 <Spinner size="sm" />
3940 Applying…
3941 </>
3942 ) : pendingWslChange?.kind === "disable" ? (
3943 pendingWslChange.wasWslOnly ? (
3944 "Switch to Windows"
3945 ) : (
3946 "Disable WSL"
3947 )
3948 ) : pendingWslChange?.kind === "distro" ? (
3949 "Switch distro"
3950 ) : pendingWslChange?.nextValue ? (
3951 "Restart and enable"
3952 ) : (
3953 "Restart and disable"
3954 )}
3955 </Button>
3956 )}
3957 </AlertDialogFooter>
3958 </AlertDialogPopup>
3959 </AlertDialog>
3960 <AlertDialog
3961 open={disableTailscaleServeDialogOpen && canManageLocalBackend}
3962 onOpenChange={(open) => {
3963 if (isUpdatingTailscaleServe) return;
3964 setDisableTailscaleServeDialogOpen(open);
3965 }}
3966 >
3967 <AlertDialogPopup>
3968 <AlertDialogHeader>
3969 <AlertDialogTitle>Disable Tailscale HTTPS?</AlertDialogTitle>
3970 <AlertDialogDescription>
3971 The Knife will restart the local backend without Tailscale Serve.
3972 </AlertDialogDescription>
3973 </AlertDialogHeader>
3974 <AlertDialogFooter>
3975 <AlertDialogClose
3976 disabled={isUpdatingTailscaleServe}
3977 render={<Button variant="outline" disabled={isUpdatingTailscaleServe} />}
3978 >
3979 Cancel
3980 </AlertDialogClose>
3981 <Button
3982 variant="destructive"
3983 onClick={() => void handleConfirmTailscaleServeDisable()}
3984 disabled={isUpdatingTailscaleServe || !canManageLocalBackend}
3985 >
3986 {isUpdatingTailscaleServe ? (
3987 <>
3988 <Spinner size="sm" />
3989 Restarting…
3990 </>
3991 ) : (
3992 "Restart and disable"
3993 )}
3994 </Button>
3995 </AlertDialogFooter>
3996 </AlertDialogPopup>
3997 </AlertDialog>
3998 <Dialog
3999 open={pendingTailscaleServeEndpoint !== null}
4000 onOpenChange={(open) => {
4001 if (isUpdatingTailscaleServe) return;
4002 if (!open) setPendingTailscaleServeEndpoint(null);
4003 }}
4004 >
4005 <DialogPopup className="max-w-md">
4006 <DialogHeader>
4007 <DialogTitle>Set up Tailscale HTTPS?</DialogTitle>
4008 <DialogDescription>
4009 The Knife will restart the local backend with Tailscale Serve enabled and ask
4010 Tailscale to proxy HTTPS traffic to this backend.
4011 </DialogDescription>
4012 </DialogHeader>
4013 <DialogPanel>
4014 <label className="block">
4015 <span className="text-sm font-medium text-foreground">HTTPS port</span>
4016 <Input
4017 className="mt-2"
4018 type="number"
4019 inputMode="numeric"
4020 min={1}
4021 max={65_535}
4022 step={1}
4023 value={tailscaleServePortInput}
4024 onChange={(event) => setTailscaleServePortInput(event.target.value)}
4025 disabled={isUpdatingTailscaleServe}
4026 />
4027 </label>
4028 {!isTailscaleServePortValid ? (
4029 <p className="mt-2 text-xs text-destructive">Enter a port from 1 to 65535.</p>
4030 ) : null}
4031 <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2">
4032 <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p>
4033 <Tooltip>
4034 <TooltipTrigger
4035 render={
4036 <p className="mt-1 truncate text-sm text-foreground">
4037 {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}
4038 </p>
4039 }
4040 />
4041 {pendingTailscaleServeBaseUrl ? (
4042 <TooltipPopup side="top">{pendingTailscaleServeBaseUrl}</TooltipPopup>
4043 ) : null}
4044 </Tooltip>
4045 </div>
4046 </DialogPanel>
4047 <DialogFooter>
4048 <DialogClose
4049 disabled={isUpdatingTailscaleServe}
4050 render={<Button variant="outline" disabled={isUpdatingTailscaleServe} />}
4051 >
4052 Cancel
4053 </DialogClose>
4054 <Button
4055 onClick={() => void handleConfirmTailscaleServeSetup()}
4056 disabled={isUpdatingTailscaleServe || !isTailscaleServePortValid}
4057 >
4058 {isUpdatingTailscaleServe ? (
4059 <>
4060 <Spinner size="sm" />
4061 Restarting…
4062 </>
4063 ) : (
4064 "Enable"
4065 )}
4066 </Button>
4067 </DialogFooter>
4068 </DialogPopup>
4069 </Dialog>
4070 </>
4071 ) : (
4072 <SettingsSection {...searchableSetting("connections-environment")}>
4073 <SettingsRow
4074 title="No environment selected"
4075 description="Connect an environment to view its settings and access."
4076 />
4077 {/* A turned-off local environment leaves no primary environment, so its switch lives here too. */}
4078 <LocalEnvironmentSetting />
4079 {canReadRelay || canManageRelay ? (
4080 <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} />
4081 ) : null}
4082 </SettingsSection>
4083 )}
4084 </>
4085 );
4086
4087 return (
4088 <SettingsPageContainer>
4089 {primarySettings}
4090 <SelfHostedServerSettings />
4091 <SettingsSection
4092 {...searchableSetting("remote-environments")}
4093 title="Environments"
4094 headerAction={
4095 <div className="flex items-center gap-1">
4096 {savedServerUpdateTargets.length > 0 ? (
4097 <ServerUpdatesAction targets={savedServerUpdateTargets} variant="ghost-muted" />
4098 ) : null}
4099 <Dialog
4100 open={addBackendDialogOpen}
4101 onOpenChange={(open) => {
4102 setAddBackendDialogOpen(open);
4103 if (open) {
4104 setRouteTarget(null);
4105 setDirectPairing(false);
4106 } else {
4107 setSavedBackendError(null);
4108 }
4109 }}
4110 >
4111 <Tooltip>
4112 <TooltipTrigger
4113 render={
4114 <DialogTrigger
4115 render={
4116 <Button size="xs" variant="ghost-muted" aria-label="Add environment">
4117 <PlusIcon className="size-3" />
4118 <span>Add environment</span>
4119 </Button>
4120 }
4121 />
4122 }
4123 />
4124 <TooltipPopup side="top">Add environment</TooltipPopup>
4125 </Tooltip>
4126 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">
4127 <DialogHeader>
4128 <DialogTitle>
4129 {routeTarget ? `Add a route to ${routeTarget.label}` : "Connect"}
4130 </DialogTitle>
4131 <DialogDescription>
4132 {routeTarget
4133 ? "Pair this machine again over another address, such as its Tailscale name. It joins the existing routes instead of adding a second machine."
4134 : "Connect to your computers through an account or your own server."}
4135 </DialogDescription>
4136 </DialogHeader>
4137 <DialogPanel>
4138 {!routeTarget && !directPairing ? (
4139 <RemoteConnectionFlow
4140 onDirect={() => setDirectPairing(true)}
4141 onConnected={() => setAddBackendDialogOpen(false)}
4142 onCloudSelected={() => setAddBackendDialogOpen(false)}
4143 />
4144 ) : (
4145 <div className="space-y-4">
4146 {!routeTarget ? (
4147 <Button variant="ghost" size="sm" onClick={() => setDirectPairing(false)}>
4148 Back
4149 </Button>
4150 ) : null}
4151 <div className="grid gap-3 sm:grid-cols-2">
4152 {renderConnectionModeCard({
4153 mode: "remote",
4154 title: "Remote link",
4155 description: "Enter a backend host and pairing code.",
4156 icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />,
4157 })}
4158 {desktopBridge
4159 ? renderConnectionModeCard({
4160 mode: "ssh",
4161 title: "SSH",
4162 description:
4163 "Use local SSH config, agent, and tunnels for the backend.",
4164 icon: <TerminalIcon aria-hidden className="size-4" />,
4165 })
4166 : null}
4167 </div>
4168 <AnimatedHeight>
4169 {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()}
4170 </AnimatedHeight>
4171 </div>
4172 )}
4173 </DialogPanel>
4174 </DialogPopup>
4175 </Dialog>
4176 </div>
4177 }
4178 >
4179 {listedEnvironments.map((environment) => (
4180 <SavedBackendListRow
4181 key={environment.environmentId}
4182 environment={environment}
4183 removingEnvironmentId={removingSavedEnvironmentId}
4184 onSetEnabled={handleSetSavedBackendEnabled}
4185 onRemove={handleRemoveSavedBackend}
4186 onAddRoute={(target) => {
4187 setRouteTarget(target);
4188 setSavedBackendError(null);
4189 setAddBackendDialogOpen(true);
4190 }}
4191 />
4192 ))}
4193 <CloudRemoteEnvironmentRows
4194 primaryEnvironmentId={primaryEnvironmentId}
4195 savedEnvironments={savedEnvironments}
4196 />
4197 </SettingsSection>
4198 {hasCloudPublicConfig() ? (
4199 <CloudAuthBoundary>
4200 <RemoveT3ConnectEnvironmentDialog
4201 environmentLabel={pendingT3ConnectRemoval?.label ?? null}
4202 onCancel={() => setPendingT3ConnectRemoval(null)}
4203 onConfirm={() => {
4204 if (!pendingT3ConnectRemoval) return;
4205 setPendingT3ConnectRemoval(null);
4206 void removeSavedBackend(pendingT3ConnectRemoval);
4207 }}
4208 />
4209 </CloudAuthBoundary>
4210 ) : null}
4211 <LoadBalancingSettings environments={loadBalancingEnvironments} />
4212 <GitHubRoutingSettings environments={loadBalancingEnvironments} />
4213 </SettingsPageContainer>
4214 );
4215}