Open History to see the last commit
1import { SessionPermissions } from "./SessionPermissions";
2import {
3 ChevronRightIcon,
4 ChevronsLeftRightEllipsisIcon,
5 EllipsisIcon,
6 RouteIcon,
7} from "lucide-react";
8import { useAtomValue } from "@effect/atom-react";
9import { useEffect, useEffectEvent, useState } from "react";
10import {
11 AuthRelayWriteScope,
12 type EnvironmentId,
13 connectReachabilityHelpUrl,
14 formatConnectReachable,
15 managedCapabilityDenial,
16 resolveEnvironmentMachineKind,
17} from "@t3tools/contracts";
18import {
19 connectionRoutes,
20 hasSavedBearerRoute,
21 connectionStatusText,
22 environmentMcpUrl,
23} from "@t3tools/client-runtime/connection";
24import * as Option from "effect/Option";
25
26import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
27import { cn } from "../../lib/utils";
28import { SettingsRow } from "./settingsLayout";
29import { searchableSetting } from "./settingsSearch";
30import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
31import { EnvironmentRoutesList } from "./EnvironmentRoutesList";
32import {
33 ConnectionDiagnosisPanel,
34 ConnectionDiagnosisSummary,
35 useConnectionDiagnosis,
36} from "./ConnectionDiagnosisDetails";
37import { usePreparedConnection } from "~/state/session";
38import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow";
39import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
40import { Switch } from "../ui/switch";
41import { stackedThreadToast, toastManager } from "../ui/toast";
42import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
43import { Button, InlineButton } from "../ui/button";
44import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
45import {
46 resolveServerConfigVersionMismatch,
47 resolveServerSelfUpdateCapability,
48 supportsDesktopAppUpdate,
49} from "~/versionSkew";
50import { hasCloudPublicConfig } from "~/cloud/publicConfig";
51import { isElectron } from "~/env";
52import { useT3ConnectTurnOn } from "~/cloud/useT3ConnectTurnOn";
53import { useCloudLinkController } from "~/cloud/useCloudLinkController";
54import { useManagedTunnelUsage } from "~/cloud/managedRelayState";
55import { useT3ConnectAccountPage } from "../clerk/T3ConnectAccountPages";
56import { TunnelLimitAlert, TunnelUsageMeter } from "../cloud/TunnelUsage";
57import { environmentCatalog } from "~/connection/catalog";
58import { readEnvironmentScope } from "~/state/session";
59import { type EnvironmentPresentation, useRelayEnvironmentDiscovery } from "~/state/environments";
60import { APP_VERSION } from "~/branding";
61import { useManagedPolicy } from "~/state/managedPolicy";
62import { useAtomCommand } from "../../state/use-atom-command";
63import { useUpdateEnvironmentSettings } from "~/hooks/useSettings";
64import { serverEnvironment } from "~/state/server";
65import {
66 OutdatedServerUpdateAction,
67 ServerUpdateAction,
68 ServerUpdateProgress,
69} from "../ServerUpdateAction";
70import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
71import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
72
73type SavedBackendListRowProps = {
74 environment: EnvironmentPresentation;
75 removingEnvironmentId: EnvironmentId | null;
76 onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
77 onRemove: (environment: EnvironmentPresentation) => void;
78 onAddRoute: (environment: EnvironmentPresentation) => void;
79};
80
81function savedBackendStatus(environment: EnvironmentPresentation): {
82 readonly text: string;
83 readonly tone: "muted" | "error";
84} {
85 if (!environment.entry.enabled && environment.connection.phase !== "unsupported")
86 return { text: "Off", tone: "muted" };
87 const { connection } = environment;
88 switch (connection.phase) {
89 case "connected":
90 return { text: "Connected", tone: "muted" };
91 case "connecting":
92 return { text: "Connecting", tone: "muted" };
93 case "reconnecting":
94 if (connection.awaitingApproval) {
95 return { text: connection.error ?? "Waiting for approval", tone: "muted" };
96 }
97 return {
98 text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting",
99 tone: "error",
100 };
101 case "unsupported":
102 return { text: "Client not supported", tone: "muted" };
103 case "error":
104 return {
105 text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed",
106 tone: "error",
107 };
108 case "offline":
109 return { text: "Offline", tone: "muted" };
110 case "available":
111 return { text: "Not connected", tone: "muted" };
112 }
113}
114
115export function SavedBackendListRow({
116 environment,
117 removingEnvironmentId,
118 onSetEnabled,
119 onRemove,
120 onAddRoute,
121}: SavedBackendListRowProps) {
122 const [routesOpen, setRoutesOpen] = useState(false);
123 const [permissionsOpen, setPermissionsOpen] = useState(false);
124 const [diagnosisOpen, setDiagnosisOpen] = useState(false);
125 const environmentId = environment.environmentId;
126 const unsupported = environment.connection.phase === "unsupported";
127 const blocked = unsupported && !hasSavedBearerRoute(environment.entry);
128 const enabled = environment.entry.enabled && !unsupported;
129 const isConnected = environment.connection.phase === "connected";
130 const isRemoving = removingEnvironmentId === environmentId;
131 const errorTraceId = environment.connection.traceId;
132 const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({
133 target: "trace ID",
134 onError: (error) => {
135 toastManager.add(
136 stackedThreadToast({
137 type: "error",
138 title: "Could not copy trace ID",
139 description: error.message,
140 }),
141 );
142 },
143 });
144 const copyTraceId = (traceId: string) => {
145 copyTraceIdToClipboard(traceId, { traceId });
146 };
147 const { copyToClipboard: copyMcpUrl } = useCopyToClipboard<{ url: string }>({
148 target: "MCP URL",
149 onCopy: ({ url }) => {
150 toastManager.add({
151 type: "success",
152 title: "MCP URL copied",
153 description: `Add it to an agent, e.g. claude mcp add --transport http t3 ${url}`,
154 });
155 },
156 onError: (error) => {
157 toastManager.add(
158 stackedThreadToast({
159 type: "error",
160 title: "Could not copy MCP URL",
161 description: error.message,
162 }),
163 );
164 },
165 });
166 const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
167 const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
168 const resumingServerUpdate =
169 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
170 const status = savedBackendStatus(environment);
171 const serverVersion = environment.serverConfig?.environment.serverVersion ?? null;
172 // Discovery empties its map on every refresh, so hold the last descriptor
173 // seen or the glyph would blink back to the generic one each time.
174 const relayDiscovery = useRelayEnvironmentDiscovery();
175 const discoveredDescriptor = Option.getOrNull(
176 relayDiscovery.environments.get(environmentId)?.status ?? Option.none(),
177 )?.descriptor;
178 const [lastDescriptor, setLastDescriptor] = useState(discoveredDescriptor);
179 if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) {
180 setLastDescriptor(discoveredDescriptor);
181 }
182 const discoveredRelayHttpBaseUrl =
183 relayDiscovery.environments.get(environmentId)?.environment.endpoint.httpBaseUrl;
184 const [lastRelayHttpBaseUrl, setLastRelayHttpBaseUrl] = useState(discoveredRelayHttpBaseUrl);
185 if (
186 discoveredRelayHttpBaseUrl !== undefined &&
187 discoveredRelayHttpBaseUrl !== lastRelayHttpBaseUrl
188 ) {
189 setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl);
190 }
191 const retryNow = useAtomCommand(environmentCatalog.retryNow);
192 const {
193 diagnosis: currentDiagnosis,
194 state: connectionState,
195 relayOnly,
196 } = useConnectionDiagnosis(environment);
197 const diagnosis = resumingServerUpdate ? null : currentDiagnosis;
198 const prepared = usePreparedConnection(environmentId);
199 const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null;
200 const mcpUrl = environmentMcpUrl({
201 entry: environment.entry,
202 relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl,
203 connectedTarget,
204 });
205 const machineKind = resolveEnvironmentMachineKind(
206 environment.serverConfig ??
207 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),
208 );
209 const routeCount = connectionRoutes(environment.entry).length;
210 const subtitleText = [
211 environmentTransportLabel(environment, connectedTarget),
212 // The headline below names the failure in full.
213 diagnosis ? null : resumingServerUpdate ? "Restarting" : status.text,
214 enabled && versionMismatch ? serverVersion : null,
215 ]
216 .filter((value): value is string => value !== null)
217 .join(" · ");
218
219 const showUpdateAction =
220 enabled &&
221 isConnected &&
222 versionMismatch !== null &&
223 (serverUpdateState.status === "idle" || serverUpdateState.status === "failed");
224
225 const statusTooltip = `${
226 unsupported
227 ? (environment.connection.error ?? connectionStatusText(environment.connection))
228 : enabled
229 ? connectionStatusText(environment.connection)
230 : "Switched off"
231 }${
232 versionMismatch
233 ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}`
234 : ""
235 }`;
236
237 return (
238 <EnvironmentRow
239 kind={machineKind}
240 label={environment.label}
241 dimmed={!enabled}
242 subtitle={
243 <span className="flex min-w-0 items-center gap-1">
244 <Tooltip>
245 {/* The status can change while the tooltip is open, and base-ui only
246 re-measures the popup when the trigger's payload changes. */}
247 <TooltipTrigger
248 payload={statusTooltip}
249 render={
250 <span
251 className={cn(
252 "min-w-0 truncate",
253 enabled &&
254 status.tone === "error" &&
255 !resumingServerUpdate &&
256 !diagnosis &&
257 "text-destructive",
258 )}
259 />
260 }
261 >
262 {subtitleText}
263 </TooltipTrigger>
264 <TooltipPopup side="top" className="whitespace-pre-wrap">
265 {statusTooltip}
266 </TooltipPopup>
267 </Tooltip>
268 <span aria-hidden className="shrink-0">
269 ·
270 </span>
271 <button
272 type="button"
273 aria-expanded={routesOpen}
274 onClick={() => setRoutesOpen((open) => !open)}
275 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"
276 >
277 {routeCount === 1 ? "Routes" : `${routeCount} routes`}
278 <ChevronRightIcon
279 aria-hidden
280 className={cn(
281 "size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
282 routesOpen && "rotate-90",
283 )}
284 />
285 </button>
286 <span aria-hidden className="shrink-0">
287 ·
288 </span>
289 <button
290 type="button"
291 aria-expanded={permissionsOpen}
292 aria-controls={`remote-permissions-${environmentId}`}
293 onClick={() => setPermissionsOpen((open) => !open)}
294 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"
295 >
296 Permissions
297 <ChevronRightIcon
298 aria-hidden
299 className={cn(
300 "size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
301 permissionsOpen && "rotate-90",
302 )}
303 />
304 </button>
305 </span>
306 }
307 below={
308 <>
309 {diagnosis ? (
310 <ConnectionDiagnosisSummary
311 diagnosis={diagnosis}
312 retryAt={connectionState.phase === "backoff" ? connectionState.retryAt : null}
313 attempt={connectionState.attempt}
314 expanded={diagnosisOpen}
315 onToggle={() => setDiagnosisOpen((open) => !open)}
316 />
317 ) : null}
318 {serverUpdateState.status !== "idle" ? (
319 <div className="mt-1 max-w-md">
320 <ServerUpdateProgress state={serverUpdateState} />
321 </div>
322 ) : null}
323 </>
324 }
325 detail={
326 <>
327 {diagnosis && diagnosisOpen ? (
328 <ConnectionDiagnosisPanel
329 hostLabel={environment.label}
330 diagnosis={diagnosis}
331 onRetry={() => void retryNow(environmentId)}
332 onAddRoute={relayOnly ? () => onAddRoute(environment) : null}
333 />
334 ) : null}
335 {routesOpen && (
336 <EnvironmentRoutesList
337 environment={environment}
338 onAddRoute={() => onAddRoute(environment)}
339 />
340 )}
341 {permissionsOpen && (
342 <div
343 id={`remote-permissions-${environmentId}`}
344 className="mt-2 border-t border-border/50"
345 >
346 <SessionPermissions
347 environmentId={environmentId}
348 connected={isConnected}
349 routeContext
350 />
351 </div>
352 )}
353 </>
354 }
355 >
356 {unsupported &&
357 environment.entry.serverUpdateRequired === true &&
358 serverUpdateState.status !== "running" ? (
359 <OutdatedServerUpdateAction
360 environmentId={environmentId}
361 serverLabel={`${environment.label} server`}
362 fromVersion={lastDescriptor?.serverVersion}
363 targetVersion={APP_VERSION}
364 label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
365 />
366 ) : null}
367 {showUpdateAction ? (
368 <ServerUpdateAction
369 environmentId={environmentId}
370 serverLabel={`${environment.label} server`}
371 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
372 installation={environment.serverConfig?.environment.capabilities.serverInstallation}
373 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
374 targetVersion={versionMismatch.clientVersion}
375 label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
376 appearance="icon"
377 />
378 ) : null}
379 <Tooltip>
380 <TooltipTrigger
381 render={
382 <Switch
383 size="sm"
384 checked={enabled}
385 disabled={isRemoving || blocked}
386 aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`}
387 onCheckedChange={(checked) => onSetEnabled(environmentId, checked)}
388 />
389 }
390 />
391 <TooltipPopup side="top">
392 {blocked ? "Client not supported" : enabled ? "Switch off" : "Switch on"}
393 </TooltipPopup>
394 </Tooltip>
395 <Menu>
396 <MenuTrigger
397 render={
398 <Button
399 type="button"
400 variant="ghost-muted"
401 size="icon-xs"
402 disabled={isRemoving}
403 aria-label={`More actions for ${environment.label}`}
404 />
405 }
406 >
407 <EllipsisIcon className="size-3.5" />
408 </MenuTrigger>
409 <MenuPopup align="end">
410 <EnvironmentIconMenu
411 environmentId={environmentId}
412 serverConfig={environment.serverConfig}
413 />
414 <MenuItem onClick={() => setRoutesOpen((open) => !open)}>
415 <RouteIcon />
416 {routesOpen ? "Hide routes" : "Routes"}
417 </MenuItem>
418 {mcpUrl ? (
419 <MenuItem onClick={() => copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL</MenuItem>
420 ) : null}
421 {errorTraceId ? (
422 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
423 ) : null}
424 <MenuSeparator />
425 <MenuItem variant="destructive" onClick={() => onRemove(environment)}>
426 {isRemoving ? "Removing…" : "Remove from this device…"}
427 </MenuItem>
428 </MenuPopup>
429 </Menu>
430 </EnvironmentRow>
431 );
432}
433
434function CloudLinkSwitch({
435 checked,
436 disabled,
437 disabledReason,
438 onCheckedChange,
439 ariaLabel = "Enable T3 Connect",
440}: {
441 readonly checked: boolean;
442 readonly disabled: boolean;
443 readonly disabledReason: string | null;
444 readonly onCheckedChange?: (enabled: boolean) => void;
445 readonly ariaLabel?: string;
446}) {
447 const control = (
448 <Switch
449 aria-label={ariaLabel}
450 checked={checked}
451 disabled={disabled}
452 {...(onCheckedChange ? { onCheckedChange } : {})}
453 />
454 );
455 return disabledReason ? (
456 <Tooltip>
457 <TooltipTrigger render={<span className="inline-flex">{control}</span>} />
458 <TooltipPopup side="top">{disabledReason}</TooltipPopup>
459 </Tooltip>
460 ) : (
461 control
462 );
463}
464
465/** Tunnel meter, plus the upgrade prompt after the relay refuses a tunnel at the cap. */
466function T3ConnectTunnelUsage({ tunnelLimit }: { readonly tunnelLimit: number | null }) {
467 const usage = useManagedTunnelUsage();
468 const accountPage = useT3ConnectAccountPage();
469 const openAccountPage = accountPage.open;
470 // Usage when the relay refused. The relay also counts tunnels still being set up, so only
471 // a freed tunnel or a higher cap clears the alert, not a meter that merely shows room.
472 const [usedAtRefusal, setUsedAtRefusal] = useState<number | null>(null);
473 if (tunnelLimit === null) {
474 if (usedAtRefusal !== null) setUsedAtRefusal(null);
475 } else if (usedAtRefusal === null && usage.used !== null) {
476 setUsedAtRefusal(usage.used);
477 }
478 const showLimitAlert =
479 tunnelLimit !== null &&
480 !(usedAtRefusal !== null && usage.used !== null && usage.used < usedAtRefusal) &&
481 !(usage.limit !== null && usage.limit > tunnelLimit);
482 const meter =
483 usage.used !== null && usage.limit !== null ? (
484 <TunnelUsageMeter
485 used={usage.used}
486 limit={usage.limit}
487 action={
488 // At the cap the alert below carries the same actions.
489 !openAccountPage || showLimitAlert ? null : usage.upgrade ? (
490 <InlineButton onClick={() => openAccountPage("t3-chat")}>Get more tunnels</InlineButton>
491 ) : usage.used >= usage.limit ? (
492 <InlineButton onClick={() => openAccountPage("t3-connect")}>
493 Manage tunnels
494 </InlineButton>
495 ) : null
496 }
497 />
498 ) : null;
499 return (
500 <>
501 {meter || showLimitAlert ? (
502 <div className="flex flex-col gap-3 pb-3">
503 {meter}
504 {showLimitAlert ? (
505 <TunnelLimitAlert
506 upgrade={usage.upgrade}
507 onLinkT3Chat={openAccountPage ? () => openAccountPage("t3-chat") : null}
508 onManageTunnels={openAccountPage ? () => openAccountPage("t3-connect") : null}
509 />
510 ) : null}
511 </div>
512 ) : null}
513 {/* Kept outside the conditional so an open account page does not remount. */}
514 {accountPage.portals}
515 </>
516 );
517}
518
519function ConfiguredCloudLinkRow({
520 environmentId,
521 canManageRelay,
522 connectDeviceApproval,
523}: {
524 readonly environmentId: EnvironmentId;
525 readonly canManageRelay: boolean;
526 readonly connectDeviceApproval: ConnectDeviceApprovalControl | null;
527}) {
528 const updateEnvironmentSettings = useUpdateEnvironmentSettings(environmentId);
529 const {
530 isSignedIn,
531 linkState: cloudLinkState,
532 linkRevoked,
533 managedTunnelActive,
534 managedTunnelOutOfSync,
535 publishAgentActivity,
536 holdWebhooksWhileOffline,
537 operationError,
538 tunnelLimit,
539 reconcileCloudState,
540 } = useCloudLinkController(environmentId);
541 const { openAuthPrompt } = useT3ConnectAuthPrompt();
542 // The server unlinks and refuses relay changes under such a policy; unlinking stays possible.
543 const connectDenial = managedCapabilityDenial(useManagedPolicy(environmentId), "connect");
544 const turnOn = useT3ConnectTurnOn(environmentId);
545 // Turning on links from this desktop's own backend only; turning off works from anywhere.
546 const showSwitch = isElectron && (turnOn.kind !== "on-host" || managedTunnelActive);
547 const [isUpdating, setIsUpdating] = useState(false);
548 const [isUpdatingPreference, setIsUpdatingPreference] = useState(false);
549
550 const disabledReason = canManageRelay
551 ? null
552 : "Your session does not have permission to manage T3 Connect access.";
553 const isBusy = isUpdating || isUpdatingPreference;
554 const switchDisabled =
555 !canManageRelay ||
556 !isSignedIn ||
557 cloudLinkState.data === null ||
558 cloudLinkState.isPending ||
559 isBusy;
560 // `isSignedIn` is undefined until Clerk loads. Signed out, one row shows a
561 // sign-in button instead of its switch; the onboarding dialog that opens after
562 // a fresh sign-in offers both linking and publishing.
563 const signedOut = isSignedIn === false;
564 const signInButton = (
565 <Button size="sm" variant="outline" onClick={openAuthPrompt}>
566 Sign in
567 </Button>
568 );
569 const refreshLinkState = useEffectEvent(cloudLinkState.refresh);
570 // The server rechecks reachability when the tunnel's 30 s readiness poll changes.
571 // Poll while mounted, so a failed read does not stop later ones.
572 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- 30 s interval that refreshes the cloud link reachability while mounted
573 useEffect(() => {
574 const interval = window.setInterval(() => refreshLinkState(), 30_000);
575 return () => window.clearInterval(interval);
576 }, []);
577 const reachability = managedTunnelActive ? cloudLinkState.data?.reachability : undefined;
578 const reachabilityStatus =
579 reachability?.status === "reachable" ? (
580 formatConnectReachable(reachability)
581 ) : reachability?.status === "unreachable" ? (
582 <span className="block text-destructive">
583 {[reachability.reason, reachability.hint, `Fix: ${reachability.fix}`]
584 .filter(Boolean)
585 .join(" ")}{" "}
586 <a
587 href={connectReachabilityHelpUrl(reachability.cause)}
588 target="_blank"
589 rel="noreferrer"
590 className="underline underline-offset-2"
591 >
592 More info
593 </a>
594 </span>
595 ) : null;
596
597 const updateManagedTunnel = async (enabled: boolean) => {
598 if (!readEnvironmentScope(environmentId, AuthRelayWriteScope)) return;
599 setIsUpdating(true);
600 const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });
601 if (ok) {
602 toastManager.add({
603 type: "success",
604 title: enabled
605 ? "T3 Connect linked"
606 : publishAgentActivity
607 ? "T3 Connect tunnel disabled"
608 : "T3 Connect unlinked",
609 description: enabled
610 ? "This environment is available through T3 Connect."
611 : publishAgentActivity
612 ? "The managed tunnel was removed. Agent activity publishing stays on."
613 : "This environment is no longer available through T3 Connect.",
614 });
615 }
616 setIsUpdating(false);
617 };
618
619 const updatePublishAgentActivity = async (enabled: boolean) => {
620 if (!readEnvironmentScope(environmentId, AuthRelayWriteScope)) return;
621 setIsUpdatingPreference(true);
622 const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled });
623 if (ok) {
624 toastManager.add({
625 type: "success",
626 title: enabled ? "Agent activity enabled" : "Agent activity disabled",
627 description: enabled
628 ? "This environment publishes agent activity to your mobile clients."
629 : "This environment will stop publishing agent activity.",
630 });
631 }
632 setIsUpdatingPreference(false);
633 };
634
635 const updateHoldWebhooks = async (enabled: boolean) => {
636 setIsUpdatingPreference(true);
637 const ok = await reconcileCloudState({
638 managedTunnel: managedTunnelActive,
639 publish: publishAgentActivity,
640 holdWebhooksWhileOffline: enabled,
641 });
642 if (ok) {
643 toastManager.add({
644 type: "success",
645 title: enabled ? "Webhooks held while offline" : "Webhooks no longer held",
646 description: enabled
647 ? "T3 Connect keeps webhook requests for up to 24 hours while this environment is offline."
648 : "Requests to an offline environment now fail. Anything already held is still delivered.",
649 });
650 }
651 setIsUpdatingPreference(false);
652 };
653
654 return (
655 <>
656 {showSwitch ? (
657 <SettingsRow
658 title={searchableSetting("t3-connect").title}
659 description={
660 connectDenial !== undefined && !managedTunnelActive
661 ? connectDenial
662 : signedOut
663 ? "Sign in to make this environment available to your other devices through T3 Connect."
664 : linkRevoked
665 ? "This environment was removed from T3 Connect. Turn it on to link it again."
666 : managedTunnelActive
667 ? reachability?.status === "unreachable"
668 ? "T3 Connect is on, but other devices cannot reach this environment."
669 : reachability?.status === "reachable"
670 ? "This environment is available to your other devices through T3 Connect."
671 : "T3 Connect is on. Reachability has not been checked yet."
672 : managedTunnelOutOfSync
673 ? "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."
674 : "Make this environment available to your other devices through T3 Connect."
675 }
676 status={operationError ?? cloudLinkState.error ?? reachabilityStatus}
677 managed={managedTunnelActive ? undefined : connectDenial}
678 control={
679 signedOut ? (
680 signInButton
681 ) : (
682 <CloudLinkSwitch
683 checked={managedTunnelActive}
684 disabled={switchDisabled}
685 disabledReason={disabledReason}
686 onCheckedChange={(enabled) => void updateManagedTunnel(enabled)}
687 />
688 )
689 }
690 >
691 {signedOut || connectDenial !== undefined ? null : (
692 <T3ConnectTunnelUsage tunnelLimit={tunnelLimit} />
693 )}
694 </SettingsRow>
695 ) : linkRevoked ? (
696 <SettingsRow
697 title={searchableSetting("t3-connect").title}
698 description="This environment was removed from T3 Connect. Run `t3 connect link` on the host to link it again."
699 />
700 ) : isElectron ? (
701 <SettingsRow
702 title={searchableSetting("t3-connect").title}
703 description="Run `t3 connect link` on the host, then restart T3 Code there."
704 />
705 ) : null}
706 {signedOut && showSwitch ? null : (
707 <SettingsRow
708 title={searchableSetting("publish-agent-activity").title}
709 description={
710 signedOut
711 ? "Sign in to send activity to mobile notifications and Live Activities."
712 : "Send activity to mobile notifications and Live Activities without T3 Connect."
713 }
714 managed={connectDenial}
715 control={
716 signedOut ? (
717 signInButton
718 ) : (
719 <CloudLinkSwitch
720 ariaLabel="Publish agent activity to mobile clients"
721 checked={publishAgentActivity}
722 disabled={switchDisabled}
723 disabledReason={disabledReason}
724 onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)}
725 />
726 )
727 }
728 />
729 )}
730 {managedTunnelActive && !signedOut ? (
731 <SettingsRow
732 title={searchableSetting("hold-webhooks-while-offline").title}
733 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."
734 managed={connectDenial}
735 control={
736 <CloudLinkSwitch
737 ariaLabel="Hold webhook requests while this environment is offline"
738 checked={holdWebhooksWhileOffline}
739 disabled={switchDisabled}
740 disabledReason={disabledReason}
741 onCheckedChange={(enabled) => void updateHoldWebhooks(enabled)}
742 />
743 }
744 />
745 ) : null}
746 {managedTunnelActive && connectDeviceApproval ? (
747 <SettingsRow
748 title={searchableSetting("connect-device-approval").title}
749 description="Devices that sign in to your T3 Connect account wait until someone already connected here approves them."
750 managed={connectDenial}
751 control={
752 <CloudLinkSwitch
753 ariaLabel="Require approval for new T3 Connect devices"
754 checked={connectDeviceApproval.required}
755 disabled={!connectDeviceApproval.canManage}
756 disabledReason={
757 connectDeviceApproval.canManage
758 ? null
759 : "Your session does not have permission to manage access."
760 }
761 onCheckedChange={(enabled) =>
762 updateEnvironmentSettings({ requireConnectDeviceApproval: enabled })
763 }
764 />
765 }
766 />
767 ) : null}
768 </>
769 );
770}
771
772/** Older servers have no device approval, so callers pass null and the row stays hidden. */
773export interface ConnectDeviceApprovalControl {
774 readonly required: boolean;
775 readonly canManage: boolean;
776}
777
778export function CloudLinkRow({
779 environmentId,
780 canReadRelay,
781 canManageRelay,
782 connectDeviceApproval,
783}: {
784 readonly environmentId: EnvironmentId;
785 readonly canReadRelay: boolean;
786 readonly canManageRelay: boolean;
787 readonly connectDeviceApproval: ConnectDeviceApprovalControl | null;
788}) {
789 if (!hasCloudPublicConfig()) return null;
790 if (!canReadRelay) {
791 return (
792 <SettingsRow
793 title="T3 Connect"
794 description="To edit these settings, pair this connection with both View relay and Manage relay permissions."
795 />
796 );
797 }
798 return (
799 <ConfiguredCloudLinkRow
800 environmentId={environmentId}
801 canManageRelay={canManageRelay}
802 connectDeviceApproval={connectDeviceApproval}
803 />
804 );
805}
806
807function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) {
808 return (
809 <Empty className="min-h-52">
810 <EmptyMedia variant="icon">
811 <ChevronsLeftRightEllipsisIcon />
812 </EmptyMedia>
813 <EmptyHeader>
814 <EmptyTitle>No saved remote environments</EmptyTitle>
815 <EmptyDescription>
816 {cloudEnabled
817 ? "Click “Add environment” to pair another environment, or connect one from T3 Connect."
818 : "Click “Add environment” to pair another environment."}
819 </EmptyDescription>
820 </EmptyHeader>
821 </Empty>
822 );
823}
824
825export function CloudRemoteEnvironmentRows({
826 primaryEnvironmentId,
827 savedEnvironments,
828}: {
829 readonly primaryEnvironmentId: EnvironmentId | null;
830 readonly savedEnvironments: ReadonlyArray<EnvironmentPresentation>;
831}) {
832 return hasCloudPublicConfig() ? (
833 <CloudEnvironmentConnectRows
834 primaryEnvironmentId={primaryEnvironmentId}
835 savedEnvironments={savedEnvironments}
836 empty={<EmptyRemoteEnvironments />}
837 />
838 ) : savedEnvironments.length === 0 ? (
839 <EmptyRemoteEnvironments cloudEnabled={false} />
840 ) : null;
841}