| author | |
| committer | |
| log | 8cc9a6fa6b0e3b6d6528793e942982d7de040833 |
| tree | c9056d9ef8951389117c3f47f67edb8eed0342b1 |
| parent | b2d17b7108766a989374d670ff12de39b6c4d033 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@mac.lan>7 files changed, 417 insertions(+), 35 deletions(-)
apps/web/src/cloud/linkEnvironment.test.ts+33| ... | ... | @@ -33,6 +33,7 @@ import { |
| 33 | 33 | readPrimaryCloudLinkState, |
| 34 | 34 | type CloudLinkTarget, |
| 35 | 35 | unlinkPrimaryEnvironmentFromCloud, |
| 36 | updatePrimaryCloudPreferences, | |
| 36 | 37 | } from "./linkEnvironment"; |
| 37 | 38 | |
| 38 | 39 | const TARGET: CloudLinkTarget = { |
| ... | ... | @@ -252,6 +253,38 @@ describe("web cloud link environment client", () => { |
| 252 | 253 | }), |
| 253 | 254 | ); |
| 254 | 255 | |
| 256 | it.effect("updates agent activity publishing for the explicit primary target", () => | |
| 257 | Effect.gen(function* () { | |
| 258 | const fetchMock = vi.fn().mockResolvedValue( | |
| 259 | Response.json({ | |
| 260 | linked: true, | |
| 261 | cloudUserId: "user-1", | |
| 262 | relayUrl: "https://relay.example.test", | |
| 263 | relayIssuer: "https://relay.example.test", | |
| 264 | publishAgentActivity: true, | |
| 265 | }), | |
| 266 | ); | |
| 267 | vi.stubGlobal("fetch", fetchMock); | |
| 268 | ||
| 269 | const state = yield* withServices( | |
| 270 | updatePrimaryCloudPreferences({ | |
| 271 | target: TARGET, | |
| 272 | publishAgentActivity: true, | |
| 273 | }), | |
| 274 | ); | |
| 275 | ||
| 276 | expect(state.publishAgentActivity).toBe(true); | |
| 277 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe( | |
| 278 | "http://127.0.0.1:3000/api/connect/preferences", | |
| 279 | ); | |
| 280 | expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); | |
| 281 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 282 | expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ | |
| 283 | publishAgentActivity: true, | |
| 284 | }); | |
| 285 | }), | |
| 286 | ); | |
| 287 | ||
| 255 | 288 | it.effect("links an available primary environment without invoking installation", () => |
| 256 | 289 | Effect.gen(function* () { |
| 257 | 290 | const fetchMock = vi |
apps/web/src/cloud/linkEnvironmentAtoms.ts+9| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | linkPrimaryEnvironmentToCloud, |
| 9 | 9 | type CloudLinkTarget, |
| 10 | 10 | unlinkPrimaryEnvironmentFromCloud, |
| 11 | updatePrimaryCloudPreferences, | |
| 11 | 12 | } from "./linkEnvironment"; |
| 12 | 13 | |
| 13 | 14 | const cloudLinkScheduler = createAtomCommandScheduler(); |
| ... | ... | @@ -31,3 +32,11 @@ export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRunti |
| 31 | 32 | execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) => |
| 32 | 33 | unlinkPrimaryEnvironmentFromCloud(input), |
| 33 | 34 | }); |
| 35 | ||
| 36 | export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connectionAtomRuntime, { | |
| 37 | label: "web:cloud:update-primary-environment-preferences", | |
| 38 | scheduler: cloudLinkScheduler, | |
| 39 | concurrency: cloudLinkConcurrency, | |
| 40 | execute: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean }) => | |
| 41 | updatePrimaryCloudPreferences(input), | |
| 42 | }); |
apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | mobileClientNotificationDetail, | |
| 6 | mobileClientPlatformLabel, | |
| 7 | mobileClientUpdatedAtLabel, | |
| 8 | } from "./MobileClientsUserProfilePage.logic"; | |
| 9 | ||
| 10 | function device(overrides: Partial<RelayClientDeviceRecord> = {}): RelayClientDeviceRecord { | |
| 11 | return { | |
| 12 | deviceId: "device-1", | |
| 13 | label: "Julius’s iPhone", | |
| 14 | platform: "ios", | |
| 15 | iosMajorVersion: 18, | |
| 16 | appVersion: "1.2.3", | |
| 17 | notifications: { | |
| 18 | enabled: true, | |
| 19 | notifyOnApproval: true, | |
| 20 | notifyOnInput: false, | |
| 21 | notifyOnCompletion: true, | |
| 22 | notifyOnFailure: false, | |
| 23 | }, | |
| 24 | liveActivities: { enabled: true }, | |
| 25 | updatedAt: "2026-06-21T12:00:00.000Z", | |
| 26 | ...overrides, | |
| 27 | }; | |
| 28 | } | |
| 29 | ||
| 30 | describe("mobile client presentation", () => { | |
| 31 | it("describes the client platform and enabled notification events", () => { | |
| 32 | const client = device(); | |
| 33 | ||
| 34 | expect(mobileClientPlatformLabel(client)).toBe("iOS 18 · T3 Code 1.2.3"); | |
| 35 | expect(mobileClientNotificationDetail(client)).toBe( | |
| 36 | "Alerts enabled for approvals, completions.", | |
| 37 | ); | |
| 38 | }); | |
| 39 | ||
| 40 | it("distinguishes disabled notifications from an empty event selection", () => { | |
| 41 | expect( | |
| 42 | mobileClientNotificationDetail( | |
| 43 | device({ notifications: { ...device().notifications, enabled: false } }), | |
| 44 | ), | |
| 45 | ).toBe("Push notifications are disabled on this device."); | |
| 46 | expect( | |
| 47 | mobileClientNotificationDetail( | |
| 48 | device({ | |
| 49 | notifications: { | |
| 50 | enabled: true, | |
| 51 | notifyOnApproval: false, | |
| 52 | notifyOnInput: false, | |
| 53 | notifyOnCompletion: false, | |
| 54 | notifyOnFailure: false, | |
| 55 | }, | |
| 56 | }), | |
| 57 | ), | |
| 58 | ).toBe("Push notifications are enabled, but no alert types are selected."); | |
| 59 | }); | |
| 60 | ||
| 61 | it("handles missing app versions and invalid update timestamps", () => { | |
| 62 | expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18"); | |
| 63 | expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable"); | |
| 64 | }); | |
| 65 | }); |
apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; | |
| 2 | ||
| 3 | const mobileClientUpdatedAtFormatter = new Intl.DateTimeFormat(undefined, { | |
| 4 | dateStyle: "medium", | |
| 5 | timeStyle: "short", | |
| 6 | }); | |
| 7 | ||
| 8 | const NOTIFICATION_PREFERENCES = [ | |
| 9 | ["notifyOnApproval", "approvals"], | |
| 10 | ["notifyOnInput", "input requests"], | |
| 11 | ["notifyOnCompletion", "completions"], | |
| 12 | ["notifyOnFailure", "failures"], | |
| 13 | ] as const satisfies ReadonlyArray< | |
| 14 | readonly [keyof RelayClientDeviceRecord["notifications"], string] | |
| 15 | >; | |
| 16 | ||
| 17 | export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string { | |
| 18 | return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`; | |
| 19 | } | |
| 20 | ||
| 21 | export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string { | |
| 22 | if (!device.notifications.enabled) { | |
| 23 | return "Push notifications are disabled on this device."; | |
| 24 | } | |
| 25 | ||
| 26 | const enabledPreferences = NOTIFICATION_PREFERENCES.flatMap(([preference, label]) => | |
| 27 | device.notifications[preference] ? [label] : [], | |
| 28 | ); | |
| 29 | return enabledPreferences.length > 0 | |
| 30 | ? `Alerts enabled for ${enabledPreferences.join(", ")}.` | |
| 31 | : "Push notifications are enabled, but no alert types are selected."; | |
| 32 | } | |
| 33 | ||
| 34 | export function mobileClientUpdatedAtLabel(updatedAt: string): string { | |
| 35 | const date = new Date(updatedAt); | |
| 36 | return Number.isNaN(date.getTime()) | |
| 37 | ? "Update time unavailable" | |
| 38 | : `Updated ${mobileClientUpdatedAtFormatter.format(date)}`; | |
| 39 | } |
apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx created+166| ... | ... | @@ -0,0 +1,166 @@ |
| 1 | import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; | |
| 2 | import { RefreshCwIcon, SmartphoneIcon } from "lucide-react"; | |
| 3 | ||
| 4 | import { useManagedRelayDevices } from "../../cloud/managedRelayState"; | |
| 5 | import { cn } from "../../lib/utils"; | |
| 6 | import { Badge } from "../ui/badge"; | |
| 7 | import { Button } from "../ui/button"; | |
| 8 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | |
| 9 | import { Skeleton } from "../ui/skeleton"; | |
| 10 | import { | |
| 11 | mobileClientNotificationDetail, | |
| 12 | mobileClientPlatformLabel, | |
| 13 | mobileClientUpdatedAtLabel, | |
| 14 | } from "./MobileClientsUserProfilePage.logic"; | |
| 15 | ||
| 16 | const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; | |
| 17 | ||
| 18 | function MobileClientStatusBadge({ | |
| 19 | enabled, | |
| 20 | label, | |
| 21 | }: { | |
| 22 | readonly enabled: boolean; | |
| 23 | readonly label: string; | |
| 24 | }) { | |
| 25 | return ( | |
| 26 | <Badge variant={enabled ? "success" : "outline"}> | |
| 27 | {label}: {enabled ? "On" : "Off"} | |
| 28 | </Badge> | |
| 29 | ); | |
| 30 | } | |
| 31 | ||
| 32 | function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { | |
| 33 | return ( | |
| 34 | <li className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm/4"> | |
| 35 | <div className="flex items-start gap-3"> | |
| 36 | <div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/40 text-muted-foreground"> | |
| 37 | <SmartphoneIcon className="size-4" /> | |
| 38 | </div> | |
| 39 | <div className="min-w-0 flex-1"> | |
| 40 | <div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3"> | |
| 41 | <div className="min-w-0"> | |
| 42 | <h3 className="truncate text-sm font-semibold text-foreground">{device.label}</h3> | |
| 43 | <p className="text-xs text-muted-foreground">{mobileClientPlatformLabel(device)}</p> | |
| 44 | </div> | |
| 45 | <p className="shrink-0 text-[11px] text-muted-foreground/75"> | |
| 46 | {mobileClientUpdatedAtLabel(device.updatedAt)} | |
| 47 | </p> | |
| 48 | </div> | |
| 49 | <div className="mt-3 flex flex-wrap gap-1.5"> | |
| 50 | <MobileClientStatusBadge | |
| 51 | enabled={device.notifications.enabled} | |
| 52 | label="Push notifications" | |
| 53 | /> | |
| 54 | <MobileClientStatusBadge | |
| 55 | enabled={device.liveActivities.enabled} | |
| 56 | label="Live Activities" | |
| 57 | /> | |
| 58 | </div> | |
| 59 | <p className="mt-2 text-xs leading-relaxed text-muted-foreground/80"> | |
| 60 | {mobileClientNotificationDetail(device)} | |
| 61 | </p> | |
| 62 | </div> | |
| 63 | </div> | |
| 64 | </li> | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | function MobileClientsSkeleton() { | |
| 69 | return ( | |
| 70 | <div aria-label="Loading mobile clients" className="space-y-3" role="status"> | |
| 71 | {MOBILE_CLIENT_SKELETON_ROWS.map((row) => ( | |
| 72 | <div key={row} className="rounded-xl border p-4"> | |
| 73 | <div className="flex gap-3"> | |
| 74 | <Skeleton className="size-9 shrink-0 rounded-lg" /> | |
| 75 | <div className="flex-1 space-y-2"> | |
| 76 | <Skeleton className="h-4 w-36" /> | |
| 77 | <Skeleton className="h-3 w-28" /> | |
| 78 | <div className="flex gap-2 pt-1"> | |
| 79 | <Skeleton className="h-5 w-28" /> | |
| 80 | <Skeleton className="h-5 w-24" /> | |
| 81 | </div> | |
| 82 | </div> | |
| 83 | </div> | |
| 84 | </div> | |
| 85 | ))} | |
| 86 | </div> | |
| 87 | ); | |
| 88 | } | |
| 89 | ||
| 90 | function EmptyMobileClients() { | |
| 91 | return ( | |
| 92 | <Empty className="min-h-72 rounded-xl border border-dashed bg-muted/15"> | |
| 93 | <EmptyMedia variant="icon"> | |
| 94 | <SmartphoneIcon /> | |
| 95 | </EmptyMedia> | |
| 96 | <EmptyHeader> | |
| 97 | <EmptyTitle>No mobile clients</EmptyTitle> | |
| 98 | <EmptyDescription> | |
| 99 | Sign in to T3 Code on your iPhone to register it for push notifications and Live | |
| 100 | Activities. | |
| 101 | </EmptyDescription> | |
| 102 | </EmptyHeader> | |
| 103 | </Empty> | |
| 104 | ); | |
| 105 | } | |
| 106 | ||
| 107 | export function MobileClientsUserProfilePage() { | |
| 108 | const devicesState = useManagedRelayDevices(); | |
| 109 | const devices = devicesState.data ?? []; | |
| 110 | const isInitialLoad = | |
| 111 | !devicesState.accountId || (devicesState.data === null && !devicesState.error); | |
| 112 | const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null; | |
| 113 | ||
| 114 | return ( | |
| 115 | <div className="flex min-h-[30rem] w-full flex-col bg-background text-foreground"> | |
| 116 | <header className="flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between"> | |
| 117 | <div> | |
| 118 | <h2 className="text-base font-semibold tracking-[-0.01em]">Mobile clients</h2> | |
| 119 | <p className="mt-1 text-sm text-muted-foreground"> | |
| 120 | Devices registered to receive T3 Connect activity from your environments. | |
| 121 | </p> | |
| 122 | </div> | |
| 123 | <Button | |
| 124 | size="sm" | |
| 125 | variant="outline" | |
| 126 | disabled={devicesState.isPending} | |
| 127 | onClick={devicesState.refresh} | |
| 128 | > | |
| 129 | <RefreshCwIcon className={cn("size-3.5", devicesState.isPending && "animate-spin")} /> | |
| 130 | Refresh | |
| 131 | </Button> | |
| 132 | </header> | |
| 133 | ||
| 134 | <div className="flex-1 p-6"> | |
| 135 | {devicesState.error ? ( | |
| 136 | <div | |
| 137 | className="mb-4 flex flex-col gap-3 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm sm:flex-row sm:items-center sm:justify-between" | |
| 138 | role="alert" | |
| 139 | > | |
| 140 | <div> | |
| 141 | <p className="font-medium text-destructive-foreground"> | |
| 142 | Could not load mobile clients | |
| 143 | </p> | |
| 144 | <p className="mt-0.5 text-xs text-muted-foreground">{devicesState.error}</p> | |
| 145 | </div> | |
| 146 | <Button size="xs" variant="outline" onClick={devicesState.refresh}> | |
| 147 | Try again | |
| 148 | </Button> | |
| 149 | </div> | |
| 150 | ) : null} | |
| 151 | ||
| 152 | {isInitialLoad ? ( | |
| 153 | <MobileClientsSkeleton /> | |
| 154 | ) : hasErrorWithoutData ? null : devices.length > 0 ? ( | |
| 155 | <ul className="space-y-3"> | |
| 156 | {devices.map((device) => ( | |
| 157 | <MobileClientRow key={device.deviceId} device={device} /> | |
| 158 | ))} | |
| 159 | </ul> | |
| 160 | ) : ( | |
| 161 | <EmptyMobileClients /> | |
| 162 | )} | |
| 163 | </div> | |
| 164 | </div> | |
| 165 | ); | |
| 166 | } |
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx+11-2| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 | import { UserButton, useAuth } from "@clerk/react"; |
| 2 | import { LogInIcon } from "lucide-react"; | |
| 2 | import { LogInIcon, SmartphoneIcon } from "lucide-react"; | |
| 3 | 3 | |
| 4 | 4 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; |
| 5 | 5 | import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; |
| 6 | import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; | |
| 6 | 7 | import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; |
| 7 | 8 | |
| 8 | 9 | export function T3ConnectSidebarSignIn() { |
| ... | ... | @@ -30,7 +31,15 @@ function ConfiguredT3ConnectSidebarAvatar() { |
| 30 | 31 | userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent", |
| 31 | 32 | }, |
| 32 | 33 | }} |
| 33 | /> | |
| 34 | > | |
| 35 | <UserButton.UserProfilePage | |
| 36 | label="Mobile clients" | |
| 37 | labelIcon={<SmartphoneIcon className="size-4" />} | |
| 38 | url="mobile-clients" | |
| 39 | > | |
| 40 | <MobileClientsUserProfilePage /> | |
| 41 | </UserButton.UserProfilePage> | |
| 42 | </UserButton> | |
| 34 | 43 | ); |
| 35 | 44 | } |
| 36 | 45 |
apps/web/src/components/settings/ConnectionsSettings.tsx+94-33| ... | ... | @@ -118,6 +118,7 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 118 | 118 | import { |
| 119 | 119 | linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, |
| 120 | 120 | unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, |
| 121 | updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, | |
| 121 | 122 | } from "~/cloud/linkEnvironmentAtoms"; |
| 122 | 123 | import { authEnvironment } from "~/state/auth"; |
| 123 | 124 | import { environmentCatalog } from "~/connection/catalog"; |
| ... | ... | @@ -1457,7 +1458,7 @@ function SavedBackendListRow({ |
| 1457 | 1458 | : null; |
| 1458 | 1459 | const metadataBits = [ |
| 1459 | 1460 | sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, |
| 1460 | environment.relayManaged ? "T3 Cloud" : null, | |
| 1461 | environment.relayManaged ? "T3 Connect" : null, | |
| 1461 | 1462 | ].filter((value): value is string => value !== null); |
| 1462 | 1463 | |
| 1463 | 1464 | return ( |
| ... | ... | @@ -1575,7 +1576,7 @@ function CloudLinkSwitch({ |
| 1575 | 1576 | }) { |
| 1576 | 1577 | const control = ( |
| 1577 | 1578 | <Switch |
| 1578 | aria-label="Enable T3 Cloud" | |
| 1579 | aria-label="Enable T3 Connect" | |
| 1579 | 1580 | checked={checked} |
| 1580 | 1581 | disabled={disabled} |
| 1581 | 1582 | {...(onCheckedChange ? { onCheckedChange } : {})} |
| ... | ... | @@ -1602,18 +1603,23 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1602 | 1603 | const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { |
| 1603 | 1604 | reportFailure: false, |
| 1604 | 1605 | }); |
| 1606 | const updatePrimaryEnvironmentPreferences = useAtomCommand( | |
| 1607 | updatePrimaryEnvironmentPreferencesAtom, | |
| 1608 | { reportFailure: false }, | |
| 1609 | ); | |
| 1605 | 1610 | const primaryCloudLinkState = usePrimaryCloudLinkState(); |
| 1606 | 1611 | const [operationError, setOperationError] = useState<string | null>(null); |
| 1607 | 1612 | const [isUpdating, setIsUpdating] = useState(false); |
| 1613 | const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); | |
| 1608 | 1614 | |
| 1609 | 1615 | const reportUpdateFailure = (cause: unknown) => { |
| 1610 | const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; | |
| 1616 | const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; | |
| 1611 | 1617 | const traceId = findErrorTraceId(cause); |
| 1612 | console.error("[t3-cloud] Could not update T3 Cloud", { message, traceId, cause }); | |
| 1618 | console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); | |
| 1613 | 1619 | setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); |
| 1614 | 1620 | toastManager.add({ |
| 1615 | 1621 | type: "error", |
| 1616 | title: "Could not update T3 Cloud", | |
| 1622 | title: "Could not update T3 Connect", | |
| 1617 | 1623 | description: message, |
| 1618 | 1624 | data: traceId |
| 1619 | 1625 | ? { |
| ... | ... | @@ -1643,9 +1649,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1643 | 1649 | return; |
| 1644 | 1650 | } |
| 1645 | 1651 | if (enabled && !tokenResult.value) { |
| 1646 | reportUpdateFailure( | |
| 1647 | new Error("Sign in from T3 Cloud settings before linking this environment."), | |
| 1648 | ); | |
| 1652 | reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment.")); | |
| 1649 | 1653 | setIsUpdating(false); |
| 1650 | 1654 | return; |
| 1651 | 1655 | } |
| ... | ... | @@ -1680,38 +1684,95 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1680 | 1684 | |
| 1681 | 1685 | toastManager.add({ |
| 1682 | 1686 | type: "success", |
| 1683 | title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", | |
| 1687 | title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", | |
| 1684 | 1688 | description: enabled |
| 1685 | ? "This environment is available through T3 Cloud." | |
| 1686 | : "This environment is no longer available through T3 Cloud.", | |
| 1689 | ? "This environment is available through T3 Connect." | |
| 1690 | : "This environment is no longer available through T3 Connect.", | |
| 1687 | 1691 | }); |
| 1688 | 1692 | setIsUpdating(false); |
| 1689 | 1693 | }; |
| 1694 | ||
| 1695 | const updatePublishAgentActivity = async (enabled: boolean) => { | |
| 1696 | const target = primaryCloudLinkState.target; | |
| 1697 | if (!target) { | |
| 1698 | reportUpdateFailure(new Error("Local environment is not ready yet.")); | |
| 1699 | return; | |
| 1700 | } | |
| 1701 | ||
| 1702 | setIsUpdatingPreference(true); | |
| 1703 | setOperationError(null); | |
| 1704 | const updateResult = await updatePrimaryEnvironmentPreferences({ | |
| 1705 | target, | |
| 1706 | publishAgentActivity: enabled, | |
| 1707 | }); | |
| 1708 | if (updateResult._tag === "Failure") { | |
| 1709 | if (!isAtomCommandInterrupted(updateResult)) { | |
| 1710 | reportUpdateFailure(squashAtomCommandFailure(updateResult)); | |
| 1711 | } | |
| 1712 | setIsUpdatingPreference(false); | |
| 1713 | return; | |
| 1714 | } | |
| 1715 | ||
| 1716 | primaryCloudLinkState.refresh(); | |
| 1717 | toastManager.add({ | |
| 1718 | type: "success", | |
| 1719 | title: enabled ? "Agent activity enabled" : "Agent activity disabled", | |
| 1720 | description: enabled | |
| 1721 | ? "This environment can publish agent activity to your mobile clients." | |
| 1722 | : "This environment will stop publishing agent activity.", | |
| 1723 | }); | |
| 1724 | setIsUpdatingPreference(false); | |
| 1725 | }; | |
| 1690 | 1726 | const disabledReason = !isSignedIn |
| 1691 | ? "Sign in from T3 Cloud settings to manage this environment." | |
| 1727 | ? "Sign in to T3 Connect to manage this environment." | |
| 1692 | 1728 | : !canManageRelay |
| 1693 | ? "Your session does not have permission to manage T3 Cloud access." | |
| 1729 | ? "Your session does not have permission to manage T3 Connect access." | |
| 1694 | 1730 | : null; |
| 1695 | 1731 | const linked = primaryCloudLinkState.data?.linked ?? false; |
| 1696 | 1732 | |
| 1697 | 1733 | return ( |
| 1698 | <SettingsRow | |
| 1699 | title="T3 Cloud" | |
| 1700 | description={ | |
| 1701 | linked | |
| 1702 | ? "This environment is available to your other devices through T3 Cloud." | |
| 1703 | : "Make this environment available to your other devices through T3 Cloud." | |
| 1704 | } | |
| 1705 | status={operationError ?? primaryCloudLinkState.error} | |
| 1706 | control={ | |
| 1707 | <CloudLinkSwitch | |
| 1708 | checked={linked} | |
| 1709 | disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating} | |
| 1710 | disabledReason={disabledReason} | |
| 1711 | onCheckedChange={(enabled) => void updateLink(enabled)} | |
| 1734 | <> | |
| 1735 | <SettingsRow | |
| 1736 | title="T3 Connect" | |
| 1737 | description={ | |
| 1738 | linked | |
| 1739 | ? "This environment is available to your other devices through T3 Connect." | |
| 1740 | : "Make this environment available to your other devices through T3 Connect." | |
| 1741 | } | |
| 1742 | status={operationError ?? primaryCloudLinkState.error} | |
| 1743 | control={ | |
| 1744 | <CloudLinkSwitch | |
| 1745 | checked={linked} | |
| 1746 | disabled={ | |
| 1747 | !canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating | |
| 1748 | } | |
| 1749 | disabledReason={disabledReason} | |
| 1750 | onCheckedChange={(enabled) => void updateLink(enabled)} | |
| 1751 | /> | |
| 1752 | } | |
| 1753 | /> | |
| 1754 | {linked ? ( | |
| 1755 | <SettingsRow | |
| 1756 | title="Publish agent activity" | |
| 1757 | description="Send activity from this environment to your mobile clients for push notifications and Live Activities." | |
| 1758 | className="bg-muted/20 pl-7 sm:pl-8" | |
| 1759 | control={ | |
| 1760 | <Switch | |
| 1761 | aria-label="Publish agent activity to mobile clients" | |
| 1762 | checked={primaryCloudLinkState.data?.publishAgentActivity ?? false} | |
| 1763 | disabled={ | |
| 1764 | !canManageRelay || | |
| 1765 | !isSignedIn || | |
| 1766 | primaryCloudLinkState.isPending || | |
| 1767 | isUpdating || | |
| 1768 | isUpdatingPreference | |
| 1769 | } | |
| 1770 | onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)} | |
| 1771 | /> | |
| 1772 | } | |
| 1712 | 1773 | /> |
| 1713 | } | |
| 1714 | /> | |
| 1774 | ) : null} | |
| 1775 | </> | |
| 1715 | 1776 | ); |
| 1716 | 1777 | } |
| 1717 | 1778 | |
| ... | ... | @@ -1729,7 +1790,7 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable |
| 1729 | 1790 | <EmptyTitle>No saved remote environments</EmptyTitle> |
| 1730 | 1791 | <EmptyDescription> |
| 1731 | 1792 | {cloudEnabled |
| 1732 | ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." | |
| 1793 | ? "Click “Add environment” to pair another environment, or connect one from T3 Connect." | |
| 1733 | 1794 | : "Click “Add environment” to pair another environment."} |
| 1734 | 1795 | </EmptyDescription> |
| 1735 | 1796 | </EmptyHeader> |
| ... | ... | @@ -1794,7 +1855,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ |
| 1794 | 1855 | toastManager.add({ |
| 1795 | 1856 | type: "success", |
| 1796 | 1857 | title: "Environment connected", |
| 1797 | description: `${environment.label} is available through T3 Cloud.`, | |
| 1858 | description: `${environment.label} is available through T3 Connect.`, | |
| 1798 | 1859 | }); |
| 1799 | 1860 | return; |
| 1800 | 1861 | } |
| ... | ... | @@ -1803,9 +1864,9 @@ function ConfiguredCloudRemoteEnvironmentRows({ |
| 1803 | 1864 | } |
| 1804 | 1865 | const cause = squashAtomCommandFailure(result); |
| 1805 | 1866 | const message = |
| 1806 | cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment."; | |
| 1867 | cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment."; | |
| 1807 | 1868 | const traceId = findErrorTraceId(cause); |
| 1808 | console.error("[t3-cloud] Could not connect environment", { message, traceId, cause }); | |
| 1869 | console.error("[t3-connect] Could not connect environment", { message, traceId, cause }); | |
| 1809 | 1870 | toastManager.add({ |
| 1810 | 1871 | type: "error", |
| 1811 | 1872 | title: "Could not connect environment", |