Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/web/src/cloud/linkEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
readPrimaryCloudLinkState,
type CloudLinkTarget,
unlinkPrimaryEnvironmentFromCloud,
updatePrimaryCloudPreferences,
} from "./linkEnvironment";

const TARGET: CloudLinkTarget = {
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/cloud/linkEnvironmentAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
linkPrimaryEnvironmentToCloud,
type CloudLinkTarget,
unlinkPrimaryEnvironmentFromCloud,
updatePrimaryCloudPreferences,
} from "./linkEnvironment";

const cloudLinkScheduler = createAtomCommandScheduler();
Expand All @@ -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),
});
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
Original file line number Diff line number Diff line change
@@ -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)}`;
}
166 changes: 166 additions & 0 deletions apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Badge variant={enabled ? "success" : "outline"}>
{label}: {enabled ? "On" : "Off"}
</Badge>
);
}

function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) {
return (
<li className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm/4">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/40 text-muted-foreground">
<SmartphoneIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-foreground">{device.label}</h3>
<p className="text-xs text-muted-foreground">{mobileClientPlatformLabel(device)}</p>
</div>
<p className="shrink-0 text-[11px] text-muted-foreground/75">
{mobileClientUpdatedAtLabel(device.updatedAt)}
</p>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
<MobileClientStatusBadge
enabled={device.notifications.enabled}
label="Push notifications"
/>
<MobileClientStatusBadge
enabled={device.liveActivities.enabled}
label="Live Activities"
/>
</div>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground/80">
{mobileClientNotificationDetail(device)}
</p>
</div>
</div>
</li>
);
}

function MobileClientsSkeleton() {
return (
<div aria-label="Loading mobile clients" className="space-y-3" role="status">
{MOBILE_CLIENT_SKELETON_ROWS.map((row) => (
<div key={row} className="rounded-xl border p-4">
<div className="flex gap-3">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-28" />
<div className="flex gap-2 pt-1">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-5 w-24" />
</div>
</div>
</div>
</div>
))}
</div>
);
}

function EmptyMobileClients() {
return (
<Empty className="min-h-72 rounded-xl border border-dashed bg-muted/15">
<EmptyMedia variant="icon">
<SmartphoneIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>No mobile clients</EmptyTitle>
<EmptyDescription>
Sign in to T3 Code on your iPhone to register it for push notifications and Live
Activities.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

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 (
<div className="flex min-h-[30rem] w-full flex-col bg-background text-foreground">
<header className="flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-base font-semibold tracking-[-0.01em]">Mobile clients</h2>
<p className="mt-1 text-sm text-muted-foreground">
Devices registered to receive T3 Connect activity from your environments.
</p>
</div>
<Button
size="sm"
variant="outline"
disabled={devicesState.isPending}
onClick={devicesState.refresh}
>
<RefreshCwIcon className={cn("size-3.5", devicesState.isPending && "animate-spin")} />
Refresh
</Button>
</header>

<div className="flex-1 p-6">
{devicesState.error ? (
<div
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"
role="alert"
>
<div>
<p className="font-medium text-destructive-foreground">
Could not load mobile clients
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{devicesState.error}</p>
</div>
<Button size="xs" variant="outline" onClick={devicesState.refresh}>
Try again
</Button>
</div>
) : null}

{isInitialLoad ? (
<MobileClientsSkeleton />
) : hasErrorWithoutData ? null : devices.length > 0 ? (
<ul className="space-y-3">
{devices.map((device) => (
<MobileClientRow key={device.deviceId} device={device} />
))}
</ul>
) : (
<EmptyMobileClients />
)}
Comment thread
cursor[bot] marked this conversation as resolved.
</div>
</div>
);
}
13 changes: 11 additions & 2 deletions apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -30,7 +31,15 @@ function ConfiguredT3ConnectSidebarAvatar() {
userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent",
},
}}
/>
>
<UserButton.UserProfilePage
label="Mobile clients"
labelIcon={<SmartphoneIcon className="size-4" />}
url="mobile-clients"
>
<MobileClientsUserProfilePage />
</UserButton.UserProfilePage>
</UserButton>
);
}

Expand Down
Loading
Loading