| 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,6 +33,7 @@ import { |
| 33 | readPrimaryCloudLinkState, | 33 | readPrimaryCloudLinkState, |
| 34 | type CloudLinkTarget, | 34 | type CloudLinkTarget, |
| 35 | unlinkPrimaryEnvironmentFromCloud, | 35 | unlinkPrimaryEnvironmentFromCloud, |
| 36 | updatePrimaryCloudPreferences, | ||
| 36 | } from "./linkEnvironment"; | 37 | } from "./linkEnvironment"; |
| 37 | 38 | ||
| 38 | const TARGET: CloudLinkTarget = { | 39 | const TARGET: CloudLinkTarget = { |
| ... | @@ -252,6 +253,38 @@ describe("web cloud link environment client", () => { | ... | @@ -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 | it.effect("links an available primary environment without invoking installation", () => | 288 | it.effect("links an available primary environment without invoking installation", () => |
| 256 | Effect.gen(function* () { | 289 | Effect.gen(function* () { |
| 257 | const fetchMock = vi | 290 | const fetchMock = vi |
apps/web/src/cloud/linkEnvironmentAtoms.ts+9| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | linkPrimaryEnvironmentToCloud, | 8 | linkPrimaryEnvironmentToCloud, |
| 9 | type CloudLinkTarget, | 9 | type CloudLinkTarget, |
| 10 | unlinkPrimaryEnvironmentFromCloud, | 10 | unlinkPrimaryEnvironmentFromCloud, |
| 11 | updatePrimaryCloudPreferences, | ||
| 11 | } from "./linkEnvironment"; | 12 | } from "./linkEnvironment"; |
| 12 | 13 | ||
| 13 | const cloudLinkScheduler = createAtomCommandScheduler(); | 14 | const cloudLinkScheduler = createAtomCommandScheduler(); |
| ... | @@ -31,3 +32,11 @@ export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRunti | ... | @@ -31,3 +32,11 @@ export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRunti |
| 31 | execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) => | 32 | execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) => |
| 32 | unlinkPrimaryEnvironmentFromCloud(input), | 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,8 +1,9 @@ |
| 1 | import { UserButton, useAuth } from "@clerk/react"; | 1 | import { UserButton, useAuth } from "@clerk/react"; |
| 2 | import { LogInIcon } from "lucide-react"; | 2 | import { LogInIcon, SmartphoneIcon } from "lucide-react"; |
| 3 | 3 | ||
| 4 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; | 4 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; |
| 5 | import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; | 5 | import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; |
| 6 | import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; | ||
| 6 | import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; | 7 | import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; |
| 7 | 8 | ||
| 8 | export function T3ConnectSidebarSignIn() { | 9 | export function T3ConnectSidebarSignIn() { |
| ... | @@ -30,7 +31,15 @@ function ConfiguredT3ConnectSidebarAvatar() { | ... | @@ -30,7 +31,15 @@ function ConfiguredT3ConnectSidebarAvatar() { |
| 30 | userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent", | 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,6 +118,7 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 118 | import { | 118 | import { |
| 119 | linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, | 119 | linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, |
| 120 | unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, | 120 | unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, |
| 121 | updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, | ||
| 121 | } from "~/cloud/linkEnvironmentAtoms"; | 122 | } from "~/cloud/linkEnvironmentAtoms"; |
| 122 | import { authEnvironment } from "~/state/auth"; | 123 | import { authEnvironment } from "~/state/auth"; |
| 123 | import { environmentCatalog } from "~/connection/catalog"; | 124 | import { environmentCatalog } from "~/connection/catalog"; |
| ... | @@ -1457,7 +1458,7 @@ function SavedBackendListRow({ | ... | @@ -1457,7 +1458,7 @@ function SavedBackendListRow({ |
| 1457 | : null; | 1458 | : null; |
| 1458 | const metadataBits = [ | 1459 | const metadataBits = [ |
| 1459 | sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, | 1460 | sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, |
| 1460 | environment.relayManaged ? "T3 Cloud" : null, | 1461 | environment.relayManaged ? "T3 Connect" : null, |
| 1461 | ].filter((value): value is string => value !== null); | 1462 | ].filter((value): value is string => value !== null); |
| 1462 | 1463 | ||
| 1463 | return ( | 1464 | return ( |
| ... | @@ -1575,7 +1576,7 @@ function CloudLinkSwitch({ | ... | @@ -1575,7 +1576,7 @@ function CloudLinkSwitch({ |
| 1575 | }) { | 1576 | }) { |
| 1576 | const control = ( | 1577 | const control = ( |
| 1577 | <Switch | 1578 | <Switch |
| 1578 | aria-label="Enable T3 Cloud" | 1579 | aria-label="Enable T3 Connect" |
| 1579 | checked={checked} | 1580 | checked={checked} |
| 1580 | disabled={disabled} | 1581 | disabled={disabled} |
| 1581 | {...(onCheckedChange ? { onCheckedChange } : {})} | 1582 | {...(onCheckedChange ? { onCheckedChange } : {})} |
| ... | @@ -1602,18 +1603,23 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b | ... | @@ -1602,18 +1603,23 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1602 | const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { | 1603 | const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { |
| 1603 | reportFailure: false, | 1604 | reportFailure: false, |
| 1604 | }); | 1605 | }); |
| 1606 | const updatePrimaryEnvironmentPreferences = useAtomCommand( | ||
| 1607 | updatePrimaryEnvironmentPreferencesAtom, | ||
| 1608 | { reportFailure: false }, | ||
| 1609 | ); | ||
| 1605 | const primaryCloudLinkState = usePrimaryCloudLinkState(); | 1610 | const primaryCloudLinkState = usePrimaryCloudLinkState(); |
| 1606 | const [operationError, setOperationError] = useState<string | null>(null); | 1611 | const [operationError, setOperationError] = useState<string | null>(null); |
| 1607 | const [isUpdating, setIsUpdating] = useState(false); | 1612 | const [isUpdating, setIsUpdating] = useState(false); |
| 1613 | const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); | ||
| 1608 | 1614 | ||
| 1609 | const reportUpdateFailure = (cause: unknown) => { | 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 | const traceId = findErrorTraceId(cause); | 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 | setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); | 1619 | setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); |
| 1614 | toastManager.add({ | 1620 | toastManager.add({ |
| 1615 | type: "error", | 1621 | type: "error", |
| 1616 | title: "Could not update T3 Cloud", | 1622 | title: "Could not update T3 Connect", |
| 1617 | description: message, | 1623 | description: message, |
| 1618 | data: traceId | 1624 | data: traceId |
| 1619 | ? { | 1625 | ? { |
| ... | @@ -1643,9 +1649,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b | ... | @@ -1643,9 +1649,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1643 | return; | 1649 | return; |
| 1644 | } | 1650 | } |
| 1645 | if (enabled && !tokenResult.value) { | 1651 | if (enabled && !tokenResult.value) { |
| 1646 | reportUpdateFailure( | 1652 | reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment.")); |
| 1647 | new Error("Sign in from T3 Cloud settings before linking this environment."), | ||
| 1648 | ); | ||
| 1649 | setIsUpdating(false); | 1653 | setIsUpdating(false); |
| 1650 | return; | 1654 | return; |
| 1651 | } | 1655 | } |
| ... | @@ -1680,38 +1684,95 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b | ... | @@ -1680,38 +1684,95 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1680 | 1684 | ||
| 1681 | toastManager.add({ | 1685 | toastManager.add({ |
| 1682 | type: "success", | 1686 | type: "success", |
| 1683 | title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", | 1687 | title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", |
| 1684 | description: enabled | 1688 | description: enabled |
| 1685 | ? "This environment is available through T3 Cloud." | 1689 | ? "This environment is available through T3 Connect." |
| 1686 | : "This environment is no longer available through T3 Cloud.", | 1690 | : "This environment is no longer available through T3 Connect.", |
| 1687 | }); | 1691 | }); |
| 1688 | setIsUpdating(false); | 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 | const disabledReason = !isSignedIn | 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 | : !canManageRelay | 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 | : null; | 1730 | : null; |
| 1695 | const linked = primaryCloudLinkState.data?.linked ?? false; | 1731 | const linked = primaryCloudLinkState.data?.linked ?? false; |
| 1696 | 1732 | ||
| 1697 | return ( | 1733 | return ( |
| 1698 | <SettingsRow | 1734 | <> |
| 1699 | title="T3 Cloud" | 1735 | <SettingsRow |
| 1700 | description={ | 1736 | title="T3 Connect" |
| 1701 | linked | 1737 | description={ |
| 1702 | ? "This environment is available to your other devices through T3 Cloud." | 1738 | linked |
| 1703 | : "Make this environment available to your other devices through T3 Cloud." | 1739 | ? "This environment is available to your other devices through T3 Connect." |
| 1704 | } | 1740 | : "Make this environment available to your other devices through T3 Connect." |
| 1705 | status={operationError ?? primaryCloudLinkState.error} | 1741 | } |
| 1706 | control={ | 1742 | status={operationError ?? primaryCloudLinkState.error} |
| 1707 | <CloudLinkSwitch | 1743 | control={ |
| 1708 | checked={linked} | 1744 | <CloudLinkSwitch |
| 1709 | disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating} | 1745 | checked={linked} |
| 1710 | disabledReason={disabledReason} | 1746 | disabled={ |
| 1711 | onCheckedChange={(enabled) => void updateLink(enabled)} | 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 | } | 1774 | ) : null} |
| 1714 | /> | 1775 | </> |
| 1715 | ); | 1776 | ); |
| 1716 | } | 1777 | } |
| 1717 | 1778 | ||
| ... | @@ -1729,7 +1790,7 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable | ... | @@ -1729,7 +1790,7 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable |
| 1729 | <EmptyTitle>No saved remote environments</EmptyTitle> | 1790 | <EmptyTitle>No saved remote environments</EmptyTitle> |
| 1730 | <EmptyDescription> | 1791 | <EmptyDescription> |
| 1731 | {cloudEnabled | 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 | : "Click “Add environment” to pair another environment."} | 1794 | : "Click “Add environment” to pair another environment."} |
| 1734 | </EmptyDescription> | 1795 | </EmptyDescription> |
| 1735 | </EmptyHeader> | 1796 | </EmptyHeader> |
| ... | @@ -1794,7 +1855,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ | ... | @@ -1794,7 +1855,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ |
| 1794 | toastManager.add({ | 1855 | toastManager.add({ |
| 1795 | type: "success", | 1856 | type: "success", |
| 1796 | title: "Environment connected", | 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 | return; | 1860 | return; |
| 1800 | } | 1861 | } |
| ... | @@ -1803,9 +1864,9 @@ function ConfiguredCloudRemoteEnvironmentRows({ | ... | @@ -1803,9 +1864,9 @@ function ConfiguredCloudRemoteEnvironmentRows({ |
| 1803 | } | 1864 | } |
| 1804 | const cause = squashAtomCommandFailure(result); | 1865 | const cause = squashAtomCommandFailure(result); |
| 1805 | const message = | 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 | const traceId = findErrorTraceId(cause); | 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 | toastManager.add({ | 1870 | toastManager.add({ |
| 1810 | type: "error", | 1871 | type: "error", |
| 1811 | title: "Could not connect environment", | 1872 | title: "Could not connect environment", |