diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index f823016ddf0..7e6f2365e50 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -33,6 +33,7 @@ import { readPrimaryCloudLinkState, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "./linkEnvironment"; const TARGET: CloudLinkTarget = { @@ -252,6 +253,38 @@ describe("web cloud link environment client", () => { }), ); + it.effect("updates agent activity publishing for the explicit primary target", () => + Effect.gen(function* () { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + linked: true, + cloudUserId: "user-1", + relayUrl: "https://relay.example.test", + relayIssuer: "https://relay.example.test", + publishAgentActivity: true, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const state = yield* withServices( + updatePrimaryCloudPreferences({ + target: TARGET, + publishAgentActivity: true, + }), + ); + + expect(state.publishAgentActivity).toBe(true); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/preferences", + ); + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + publishAgentActivity: true, + }); + }), + ); + it.effect("links an available primary environment without invoking installation", () => Effect.gen(function* () { const fetchMock = vi diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts index ea924cae234..4cb62271a48 100644 --- a/apps/web/src/cloud/linkEnvironmentAtoms.ts +++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts @@ -8,6 +8,7 @@ import { linkPrimaryEnvironmentToCloud, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "./linkEnvironment"; const cloudLinkScheduler = createAtomCommandScheduler(); @@ -31,3 +32,11 @@ export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRunti execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) => unlinkPrimaryEnvironmentFromCloud(input), }); + +export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connectionAtomRuntime, { + label: "web:cloud:update-primary-environment-preferences", + scheduler: cloudLinkScheduler, + concurrency: cloudLinkConcurrency, + execute: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean }) => + updatePrimaryCloudPreferences(input), +}); diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts new file mode 100644 index 00000000000..fcc660e8305 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts @@ -0,0 +1,65 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; + +import { + mobileClientNotificationDetail, + mobileClientPlatformLabel, + mobileClientUpdatedAtLabel, +} from "./MobileClientsUserProfilePage.logic"; + +function device(overrides: Partial = {}): RelayClientDeviceRecord { + return { + deviceId: "device-1", + label: "Julius’s iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.2.3", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: false, + notifyOnCompletion: true, + notifyOnFailure: false, + }, + liveActivities: { enabled: true }, + updatedAt: "2026-06-21T12:00:00.000Z", + ...overrides, + }; +} + +describe("mobile client presentation", () => { + it("describes the client platform and enabled notification events", () => { + const client = device(); + + expect(mobileClientPlatformLabel(client)).toBe("iOS 18 · T3 Code 1.2.3"); + expect(mobileClientNotificationDetail(client)).toBe( + "Alerts enabled for approvals, completions.", + ); + }); + + it("distinguishes disabled notifications from an empty event selection", () => { + expect( + mobileClientNotificationDetail( + device({ notifications: { ...device().notifications, enabled: false } }), + ), + ).toBe("Push notifications are disabled on this device."); + expect( + mobileClientNotificationDetail( + device({ + notifications: { + enabled: true, + notifyOnApproval: false, + notifyOnInput: false, + notifyOnCompletion: false, + notifyOnFailure: false, + }, + }), + ), + ).toBe("Push notifications are enabled, but no alert types are selected."); + }); + + it("handles missing app versions and invalid update timestamps", () => { + expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18"); + expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable"); + }); +}); diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts new file mode 100644 index 00000000000..5ca9595bef4 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -0,0 +1,39 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; + +const mobileClientUpdatedAtFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +const NOTIFICATION_PREFERENCES = [ + ["notifyOnApproval", "approvals"], + ["notifyOnInput", "input requests"], + ["notifyOnCompletion", "completions"], + ["notifyOnFailure", "failures"], +] as const satisfies ReadonlyArray< + readonly [keyof RelayClientDeviceRecord["notifications"], string] +>; + +export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string { + return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`; +} + +export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string { + if (!device.notifications.enabled) { + return "Push notifications are disabled on this device."; + } + + const enabledPreferences = NOTIFICATION_PREFERENCES.flatMap(([preference, label]) => + device.notifications[preference] ? [label] : [], + ); + return enabledPreferences.length > 0 + ? `Alerts enabled for ${enabledPreferences.join(", ")}.` + : "Push notifications are enabled, but no alert types are selected."; +} + +export function mobileClientUpdatedAtLabel(updatedAt: string): string { + const date = new Date(updatedAt); + return Number.isNaN(date.getTime()) + ? "Update time unavailable" + : `Updated ${mobileClientUpdatedAtFormatter.format(date)}`; +} diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx new file mode 100644 index 00000000000..26af10ba5b8 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx @@ -0,0 +1,166 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; +import { RefreshCwIcon, SmartphoneIcon } from "lucide-react"; + +import { useManagedRelayDevices } from "../../cloud/managedRelayState"; +import { cn } from "../../lib/utils"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { Skeleton } from "../ui/skeleton"; +import { + mobileClientNotificationDetail, + mobileClientPlatformLabel, + mobileClientUpdatedAtLabel, +} from "./MobileClientsUserProfilePage.logic"; + +const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; + +function MobileClientStatusBadge({ + enabled, + label, +}: { + readonly enabled: boolean; + readonly label: string; +}) { + return ( + + {label}: {enabled ? "On" : "Off"} + + ); +} + +function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { + return ( +
  • +
    +
    + +
    +
    +
    +
    +

    {device.label}

    +

    {mobileClientPlatformLabel(device)}

    +
    +

    + {mobileClientUpdatedAtLabel(device.updatedAt)} +

    +
    +
    + + +
    +

    + {mobileClientNotificationDetail(device)} +

    +
    +
    +
  • + ); +} + +function MobileClientsSkeleton() { + return ( +
    + {MOBILE_CLIENT_SKELETON_ROWS.map((row) => ( +
    +
    + +
    + + +
    + + +
    +
    +
    +
    + ))} +
    + ); +} + +function EmptyMobileClients() { + return ( + + + + + + No mobile clients + + Sign in to T3 Code on your iPhone to register it for push notifications and Live + Activities. + + + + ); +} + +export function MobileClientsUserProfilePage() { + const devicesState = useManagedRelayDevices(); + const devices = devicesState.data ?? []; + const isInitialLoad = + !devicesState.accountId || (devicesState.data === null && !devicesState.error); + const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null; + + return ( +
    +
    +
    +

    Mobile clients

    +

    + Devices registered to receive T3 Connect activity from your environments. +

    +
    + +
    + +
    + {devicesState.error ? ( +
    +
    +

    + Could not load mobile clients +

    +

    {devicesState.error}

    +
    + +
    + ) : null} + + {isInitialLoad ? ( + + ) : hasErrorWithoutData ? null : devices.length > 0 ? ( +
      + {devices.map((device) => ( + + ))} +
    + ) : ( + + )} +
    +
    + ); +} diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index d3f906ef414..45477ee1b7e 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,8 +1,9 @@ import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon } from "lucide-react"; +import { LogInIcon, SmartphoneIcon } from "lucide-react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; export function T3ConnectSidebarSignIn() { @@ -30,7 +31,15 @@ function ConfiguredT3ConnectSidebarAvatar() { userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent", }, }} - /> + > + } + url="mobile-clients" + > + + + ); } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 96d9dd4510f..934f45405d9 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -118,6 +118,7 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, + updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, } from "~/cloud/linkEnvironmentAtoms"; import { authEnvironment } from "~/state/auth"; import { environmentCatalog } from "~/connection/catalog"; @@ -1432,7 +1433,7 @@ function SavedBackendListRow({ : null; const metadataBits = [ sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, - environment.relayManaged ? "T3 Cloud" : null, + environment.relayManaged ? "T3 Connect" : null, ].filter((value): value is string => value !== null); return ( @@ -1550,7 +1551,7 @@ function CloudLinkSwitch({ }) { const control = ( (null); const [isUpdating, setIsUpdating] = useState(false); + const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); const reportUpdateFailure = (cause: unknown) => { - const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; + const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; const traceId = findErrorTraceId(cause); - console.error("[t3-cloud] Could not update T3 Cloud", { message, traceId, cause }); + console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); toastManager.add({ type: "error", - title: "Could not update T3 Cloud", + title: "Could not update T3 Connect", description: message, data: traceId ? { @@ -1618,9 +1624,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b return; } if (enabled && !tokenResult.value) { - reportUpdateFailure( - new Error("Sign in from T3 Cloud settings before linking this environment."), - ); + reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment.")); setIsUpdating(false); return; } @@ -1655,38 +1659,95 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b toastManager.add({ type: "success", - title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", + title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", description: enabled - ? "This environment is available through T3 Cloud." - : "This environment is no longer available through T3 Cloud.", + ? "This environment is available through T3 Connect." + : "This environment is no longer available through T3 Connect.", }); setIsUpdating(false); }; + + const updatePublishAgentActivity = async (enabled: boolean) => { + const target = primaryCloudLinkState.target; + if (!target) { + reportUpdateFailure(new Error("Local environment is not ready yet.")); + return; + } + + setIsUpdatingPreference(true); + setOperationError(null); + const updateResult = await updatePrimaryEnvironmentPreferences({ + target, + publishAgentActivity: enabled, + }); + if (updateResult._tag === "Failure") { + if (!isAtomCommandInterrupted(updateResult)) { + reportUpdateFailure(squashAtomCommandFailure(updateResult)); + } + setIsUpdatingPreference(false); + return; + } + + primaryCloudLinkState.refresh(); + toastManager.add({ + type: "success", + title: enabled ? "Agent activity enabled" : "Agent activity disabled", + description: enabled + ? "This environment can publish agent activity to your mobile clients." + : "This environment will stop publishing agent activity.", + }); + setIsUpdatingPreference(false); + }; const disabledReason = !isSignedIn - ? "Sign in from T3 Cloud settings to manage this environment." + ? "Sign in to T3 Connect to manage this environment." : !canManageRelay - ? "Your session does not have permission to manage T3 Cloud access." + ? "Your session does not have permission to manage T3 Connect access." : null; const linked = primaryCloudLinkState.data?.linked ?? false; return ( - void updateLink(enabled)} + <> + void updateLink(enabled)} + /> + } + /> + {linked ? ( + void updatePublishAgentActivity(enabled)} + /> + } /> - } - /> + ) : null} + ); } @@ -1704,7 +1765,7 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable No saved remote environments {cloudEnabled - ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." + ? "Click “Add environment” to pair another environment, or connect one from T3 Connect." : "Click “Add environment” to pair another environment."} @@ -1769,7 +1830,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ toastManager.add({ type: "success", title: "Environment connected", - description: `${environment.label} is available through T3 Cloud.`, + description: `${environment.label} is available through T3 Connect.`, }); return; } @@ -1778,9 +1839,9 @@ function ConfiguredCloudRemoteEnvironmentRows({ } const cause = squashAtomCommandFailure(result); const message = - cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment."; + cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment."; const traceId = findErrorTraceId(cause); - console.error("[t3-cloud] Could not connect environment", { message, traceId, cause }); + console.error("[t3-connect] Could not connect environment", { message, traceId, cause }); toastManager.add({ type: "error", title: "Could not connect environment",