diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ea885f819984f..3c5abae3be93b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -189,7 +189,12 @@ const widgetsPlugin: NonNullable[number] = [ displayName: "Agent Activity", description: "Shows the current state of active T3 Code agents.", android: null, - ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] }, + ios: { + supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], + // Embeds the layout so the gallery preview and a new widget render + // before the app has run once; the app replaces it on publish. + initialLayout: "./src/widgets/AgentActivityWidget.tsx", + }, }, ], }, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d7e340d886ff2..c6b23f2dab15c 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -7,12 +7,15 @@ import { describe, expect, it } from "@effect/vitest"; import Constants from "expo-constants"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { Cookies, FetchHttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import * as Dpop from "../cloud/dpop"; import * as ManagedRelayLayer from "../cloud/managedRelayLayer"; @@ -20,12 +23,15 @@ import { clearAgentAwarenessRegistrationRecord, loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, + loadPreferences, saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; +import type { Preferences } from "../../persistence/mobile-preferences"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { __resetAgentAwarenessRemoteRegistrationForTest, getAgentAwarenessRegistrationStatus, + refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, @@ -48,6 +54,7 @@ const widgetMocks = vi.hoisted(() => ({ getInstances: vi.fn(() => []), start: vi.fn(() => ({})), })); +const publishWidgetMock = vi.hoisted(() => vi.fn((_source: unknown) => Promise.resolve())); const environmentConfigsMock = vi.hoisted(() => ({ configs: new Map< string, @@ -84,6 +91,10 @@ vi.mock("expo-widgets", () => ({ addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })), })); +vi.mock("../../widgets/publishAgentActivityWidget", () => ({ + publishAgentActivityWidget: publishWidgetMock, +})); + vi.mock("./agentLiveActivity", () => ({ dismissEndedAgentLiveActivities: widgetMocks.dismissEnded, getAgentLiveActivities: widgetMocks.getInstances, @@ -247,6 +258,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); widgetMocks.start.mockClear(); + publishWidgetMock.mockClear(); widgetMocks.dismissEnded.mockReset(); environmentConfigsMock.configs.clear(); }); @@ -511,12 +523,14 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { yield* runBackgroundOperations(); - expect(fetchMock).toHaveBeenCalledTimes(2); - const [request, init] = fetchMock.mock.calls[1] as unknown as [ - unknown, - RequestInit | undefined, - ]; - const url = request instanceof Request ? request.url : String(request); + const requestUrl = (request: unknown) => + request instanceof Request ? request.url : String(request); + const deviceCalls = fetchMock.mock.calls.filter(([request]) => + requestUrl(request).endsWith("/v1/mobile/devices"), + ); + expect(deviceCalls).toHaveLength(1); + const [request, init] = deviceCalls[0] as unknown as [unknown, RequestInit | undefined]; + const url = requestUrl(request); const method = request instanceof Request ? request.method : init?.method; const headers = request instanceof Request ? request.headers : new Headers(init?.headers); const dpop = headers.get("dpop"); @@ -588,9 +602,12 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, }; + // Sign-in first reads the widget snapshot, then registers the device; fail + // both lookups so the queued registration is the one that must recover. const tokenProvider = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error("auth unavailable")) + .mockRejectedValueOnce(new Error("auth unavailable")) .mockResolvedValue("clerk-token-user-a"); setAgentAwarenessRelayTokenProvider(tokenProvider); const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0]; @@ -601,7 +618,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* runBackgroundOperations(); expect(backgroundRuntime.pending).toHaveLength(0); - expect(tokenProvider).toHaveBeenCalledTimes(2); + expect(tokenProvider).toHaveBeenCalledTimes(3); }).pipe(Effect.provide(layerRelayTest)); }); @@ -719,4 +736,333 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(layerRelayTest)); }, ); + + describe("home-screen widget", () => { + // These cover the widget path only, so keep the Live Activity from priming. + beforeEach(() => { + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false } as Preferences); + }); + + const aggregate: RelayAgentActivityAggregateState = { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: "2026-10-18T10:00:00.000Z", + activities: [ + { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-1" as ThreadId, + projectTitle: "t3code", + threadTitle: "Fix the widget", + modelTitle: "GPT-5.5", + phase: "running", + status: "Working", + updatedAt: "2026-10-18T10:00:00.000Z", + deepLink: "/threads/env-1/thread-1", + }, + ], + }; + + // The shared fetch-backed relay layer keeps the first test's fetch stub, + // so these tests answer the snapshot read through a fake client instead. + function fakeRelay( + getAgentActivitySnapshot: () => Effect.Effect< + { readonly aggregate: typeof aggregate }, + ManagedRelay.ManagedRelayRequestFailedError + >, + ) { + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } }; + const unexpected = () => Effect.die("Unexpected relay request"); + return Layer.succeed( + ManagedRelay.ManagedRelayClient, + ManagedRelay.ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: unexpected, + listDevices: unexpected, + getT3ChatLink: unexpected, + createT3ChatLinkRequest: unexpected, + unlinkT3Chat: unexpected, + createEnvironmentLinkChallenge: unexpected, + linkEnvironment: unexpected, + unlinkEnvironment: unexpected, + getEnvironmentStatus: unexpected, + connectEnvironment: unexpected, + registerDevice: unexpected, + unregisterDevice: unexpected, + registerLiveActivity: unexpected, + getAgentActivitySnapshot, + resetTokenCache: Effect.void, + }), + ); + } + + it.effect("repaints from the relay snapshot when activity is reconciled", () => { + const relay = fakeRelay(() => Effect.succeed({ aggregate })); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate }, + "user-a", + ); + }).pipe(Effect.provide(relay)); + }); + + it.effect("delegates failed reads to the account-aware native publisher", () => { + const relayDown = new ManagedRelay.ManagedRelayRequestFailedError({ + action: "read relay agent activity snapshot", + transportFailed: true, + cause: new Error("relay down"), + }); + let relayUp = false; + const relay = fakeRelay(() => + relayUp ? Effect.succeed({ aggregate }) : Effect.fail(relayDown), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + return Effect.gen(function* () { + // After a cold start the persisted timeline may be a placeholder. + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "unavailable" }, "user-a"); + + relayUp = true; + yield* refreshActiveLiveActivityRemoteRegistration(); + relayUp = false; + publishWidgetMock.mockClear(); + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "unavailable" }, "user-a"); + }).pipe(Effect.provide(relay)); + }); + + it.effect("registers an existing Live Activity before waiting on the widget read", () => { + const order: Array = []; + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => { + order.push("push-token"); + return Promise.resolve(null); + }), + addPushTokenListener: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + const relay = fakeRelay(() => + Effect.sync(() => { + order.push("widget-snapshot"); + return { aggregate }; + }), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(order).toEqual(["push-token", "widget-snapshot"]); + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate }, + "user-a", + ); + }).pipe(Effect.provide(relay)); + }); + + it.effect("keeps the newest rows when an older overlapping read finishes last", () => + Effect.gen(function* () { + const newer = { ...aggregate, activeCount: 2 }; + const olderRequested = yield* Deferred.make(); + const olderResponse = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); + let reads = 0; + const relay = fakeRelay(() => { + reads++; + return reads === 1 + ? Deferred.succeed(olderRequested, undefined).pipe( + Effect.andThen(Deferred.await(olderResponse)), + ) + : Effect.succeed({ aggregate: newer }); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + const older = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Deferred.await(olderRequested); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)); + yield* Deferred.succeed(olderResponse, { aggregate }); + yield* Fiber.join(older); + + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate: newer }, + "user-a", + ); + }), + ); + + it("shows loading, not the sign-in prompt, while a switched account's first read runs", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-a"); + + // CloudAuthBridge deactivates the old account before activating the new one. + setAgentAwarenessRelayTokenProvider(null); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-b"); + }); + + it.effect("shows loading when another account signs in after a provider release", () => { + const relay = fakeRelay(() => Effect.succeed({ aggregate })); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + // The native publisher checks persisted ownership even on a same-account remount. + releaseAgentAwarenessRelayTokenProvider(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-a"); + + releaseAgentAwarenessRelayTokenProvider(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-b"); + }).pipe(Effect.provide(relay)); + }); + + it.effect("an obsolete refresh does not discard the next account's read", () => + Effect.gen(function* () { + let markTokenRequested = () => {}; + const tokenRequested = new Promise((resolve) => { + markTokenRequested = resolve; + }); + let releaseToken = () => {}; + widgetMocks.getInstances.mockReturnValueOnce([ + { + getId: () => "activity-a", + getPushToken: () => { + markTokenRequested(); + return new Promise((resolve) => { + releaseToken = () => resolve(null); + }); + }, + addPushTokenListener: vi.fn(), + }, + ] as never); + const requested = yield* Deferred.make(); + const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); + const relay = fakeRelay(() => + Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + + const refreshA = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Effect.promise(() => tokenRequested); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + const refreshB = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Deferred.await(requested); + releaseToken(); + yield* Fiber.join(refreshA); + yield* Deferred.succeed(response, { aggregate }); + yield* Fiber.join(refreshB); + + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate }, + "user-b", + ); + }), + ); + + it.effect("does not prime a Live Activity from a read a newer one replaced", () => + Effect.gen(function* () { + let markRequested = () => {}; + const requested = new Promise((resolve) => { + markRequested = resolve; + }); + let resolvePreferences = (_preferences: Preferences) => {}; + vi.mocked(loadPreferences).mockImplementationOnce(() => { + markRequested(); + return new Promise((resolve) => { + resolvePreferences = resolve; + }); + }); + const idle = { ...aggregate, activeCount: 0, activities: [] }; + let reads = 0; + const relay = fakeRelay(() => + Effect.succeed({ aggregate: ++reads === 1 ? aggregate : idle }), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + const older = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Effect.promise(() => requested); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)); + resolvePreferences({ liveActivitiesEnabled: true } as Preferences); + yield* Fiber.join(older); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + }), + ); + + it("shows the signed-out state after cloud sign-out", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + setAgentAwarenessRelayTokenProvider(null); + + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "signed-out" }, null); + }); + + it.effect("drops a snapshot that arrives after the account changed", () => + Effect.gen(function* () { + const requested = yield* Deferred.make(); + const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); + const relay = fakeRelay(() => + Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + + const refresh = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Deferred.await(requested); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + yield* Deferred.succeed(response, { aggregate }); + yield* Fiber.join(refresh); + + expect(publishWidgetMock).not.toHaveBeenCalledWith( + { kind: "snapshot", aggregate }, + "user-a", + ); + }), + ); + + it.effect("does not prime a Live Activity after sign-out during the preference read", () => + Effect.gen(function* () { + let markRequested = () => {}; + const requested = new Promise((resolve) => { + markRequested = resolve; + }); + let resolvePreferences = (_preferences: Preferences) => {}; + vi.mocked(loadPreferences).mockImplementationOnce(() => { + markRequested(); + return new Promise((resolve) => { + resolvePreferences = resolve; + }); + }); + const relay = fakeRelay(() => Effect.succeed({ aggregate })); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + const refresh = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Effect.promise(() => requested); + setAgentAwarenessRelayTokenProvider(null); + resolvePreferences({ liveActivitiesEnabled: true } as Preferences); + yield* Fiber.join(refresh); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + }), + ); + }); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index fc24d180a3a04..3427ab8baf19d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -36,6 +36,8 @@ import { saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import type { AgentActivityWidgetSource } from "../../widgets/agentActivityWidgetTimeline"; +import { publishAgentActivityWidget } from "../../widgets/publishAgentActivityWidget"; import { dismissEndedAgentLiveActivities, getAgentLiveActivities, @@ -83,6 +85,9 @@ const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000; const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS); const registeredActivityPushTokens = new Map(); +// Refreshes can overlap (sign-in, foreground, retry); only the newest read may +// repaint, so a slow older response never overwrites newer rows. +let widgetSnapshotReads = 0; let androidDeviceReplayedAt: number | null = null; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -175,6 +180,9 @@ export function setAgentAwarenessRelayTokenProvider( if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) { clearAndroidAgentNotifications(); } + if (provider) { + publishWidget({ kind: "loading" }, identity ?? null); + } androidDeviceReplayedAt = null; deviceRegistrationGeneration++; activeDeviceRegistration = null; @@ -195,6 +203,7 @@ export function setAgentAwarenessRelayTokenProvider( } // Signed-out activities would be orphaned because the relay can no longer update or end them. endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); + publishWidget({ kind: "signed-out" }); setRegistrationStatus("unknown"); void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { logRegistrationError("clear registration record on sign-out failed", error); @@ -729,6 +738,18 @@ function ensureAppStateListener(): void { }); } +function publishWidget( + source: AgentActivityWidgetSource, + accountId = relayTokenProviderIdentity, +): void { + if (!canRegisterRemoteLiveActivities()) { + return; + } + publishAgentActivityWidget(source, accountId).catch((error: unknown) => { + logRegistrationError("agent activity widget publish failed", error); + }); +} + // Binaries built before the native dismissEnded method throw here. function dismissEndedLocalLiveActivities(): void { try { @@ -805,6 +826,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); + widgetSnapshotReads = 0; } export const unregisterAgentAwarenessDeviceForCurrentUser: ( @@ -922,13 +944,40 @@ function scheduleActiveLiveActivityRegistrationRetry(): void { }, REMOTE_ACTIVITY_REGISTRATION_RETRY_MS); } -const refreshActiveLiveActivityRemoteRegistration = Effect.fn( +export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( "remoteRegistration.refreshActiveLiveActivityRemoteRegistration", )(function* (): Effect.fn.Return { if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { return; } + // The home-screen widget has no push path of its own, so every foreground + // refresh repaints it from one snapshot read. Yields undefined once the + // account changed or a newer read started, so a stale result is never + // shown or primed. + const identity = relayTokenProviderIdentity; + const isCurrentAccount = () => + relayTokenProvider !== null && relayTokenProviderIdentity === identity; + let read = 0; + const isNewestRead = () => isCurrentAccount() && read === widgetSnapshotReads; + const refreshWidget = Effect.gen(function* () { + // An obsolete refresh must not supersede the current account's read. + if (!isCurrentAccount()) { + return undefined; + } + read = ++widgetSnapshotReads; + const snapshot = yield* readAgentActivitySnapshot(); + if (!isNewestRead()) { + return undefined; + } + if (snapshot) { + publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate }); + } else { + publishWidget({ kind: "unavailable" }); + } + return { snapshot }; + }); + dismissEndedLocalLiveActivities(); let activities = yield* Effect.try({ try: () => getAgentLiveActivities(), @@ -948,6 +997,7 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn( // The relay tracks exactly one card per device; if concurrent arming ever // produced extras, end them so only one keeps receiving updates. + const hadActivities = activities.length > 0; if (activities.length > 1) { for (const extra of activities.slice(1)) { extra.end("immediate").catch((error: unknown) => { @@ -961,7 +1011,12 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn( // update token can be observed and registered immediately — the relay never // remote-starts one (background push-to-start wakes proved too unreliable // to hand the token over). - if (activities.length === 0) { + if (!hadActivities) { + const widgetRead = yield* refreshWidget; + if (!widgetRead) { + return; + } + const { snapshot } = widgetRead; const preferences = yield* Effect.tryPromise({ try: () => loadPreferences(), catch: (cause) => @@ -970,10 +1025,14 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn( cause, }), }).pipe(Effect.orElseSucceed(() => null)); + // A sign-out, account switch, or newer read during the preference read + // makes this snapshot stale; priming now could bring old rows back. + if (!isNewestRead()) { + return; + } if (preferences?.liveActivitiesEnabled !== false) { - const snapshot = yield* readAgentActivitySnapshot(); - // The snapshot request yields; an arm-on-send may have created the - // card in the meantime. Re-check so two cards are never started. + // The snapshot and preference reads yield; an arm-on-send may have + // created the card in the meantime. Re-check so two cards are never started. const armedMeanwhile = yield* Effect.try({ try: () => getAgentLiveActivities(), catch: () => [] as ReadonlyArray>, @@ -1029,4 +1088,10 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn( if (registrationResults.some(Boolean)) { scheduleActiveLiveActivityRegistrationRetry(); } + + // An existing card keeps its push token registered without waiting on the + // relay; the widget still repaints afterwards. + if (hadActivities) { + yield* refreshWidget; + } }); diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx new file mode 100644 index 0000000000000..688a7bc8c3b02 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -0,0 +1,202 @@ +import { HStack, Image, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { + containerBackground, + font, + foregroundStyle, + frame, + layoutPriority, + lineLimit, + resizable, + widgetURL, +} from "@expo/ui/swift-ui/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; + +import type { + AgentActivityPhase, + AgentActivityProps, + AgentActivityRowProps, +} from "./AgentActivity"; + +// Home-screen and Lock Screen counterpart of the AgentActivity Live Activity. +// It shares the Live Activity's name, but expo-widgets stores widget and Live +// Activity layouts under different keys, so each needs its own registration. +export function AgentActivityWidget( + publishedProps: (Partial & { readonly accountId?: string }) | undefined, + environment: WidgetEnvironment, +) { + "widget"; + // The extension evaluates this function without the app's module scope. + // Before the app's first publish, the embedded layout renders with no props. + const props = publishedProps ?? {}; + const family = environment.widgetFamily; + const accessory = family === "accessoryRectangular"; + const small = family === "systemSmall"; + const monochrome = + environment.widgetRenderingMode === "accented" || + environment.widgetRenderingMode === "vibrant" || + environment.isLuminanceReduced === true; + const isLightScheme = environment.colorScheme === "light"; + + type Foreground = Parameters[0]; + const primaryForeground = { type: "hierarchical", style: "primary" } as const; + const secondaryForeground = { type: "hierarchical", style: "secondary" } as const; + const phaseTint = (phase: AgentActivityPhase): Foreground => { + if (monochrome) return primaryForeground; + switch (phase) { + case "waiting_for_approval": + return isLightScheme ? "#d97706" : "#fcd34d"; + case "waiting_for_input": + return isLightScheme ? "#4f46e5" : "#a5b4fc"; + case "failed": + return isLightScheme ? "#dc2626" : "#fca5a5"; + case "completed": + return isLightScheme ? "#059669" : "#6ee7b7"; + case "stale": + return secondaryForeground; + case "starting": + case "running": + default: + return isLightScheme ? "#0284c7" : "#7dd3fc"; + } + }; + const phasePriority = (phase: AgentActivityPhase): number => { + if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; + if (phase === "failed") return 1; + if (phase === "running" || phase === "starting") return 2; + return 3; + }; + + const loaded = props.activities !== undefined; + const rows = [...(props.activities ?? [])].sort( + (a, b) => phasePriority(a.phase) - phasePriority(b.phase), + ); + const shown = rows.slice(0, accessory || small ? 2 : 4); + const attentionCount = rows.filter( + (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + ).length; + const workingCount = rows.filter( + (row) => row.phase === "running" || row.phase === "starting", + ).length; + const summary = + attentionCount > 0 + ? `${attentionCount} need${attentionCount === 1 ? "s" : ""} ${small ? "you" : "attention"}` + : workingCount > 0 + ? `${props.activeCount ?? workingCount} active` + : rows.some((row) => row.phase === "stale") + ? "Out of date" + : rows.some((row) => row.phase === "failed") + ? "Failed" + : rows.length > 0 + ? "Done" + : ""; + const summaryTint = rows[0] ? phaseTint(rows[0].phase) : secondaryForeground; + const emptyMessage = loaded + ? (props.subtitle ?? "No active agents") + : "Open T3 Code to load agent activity"; + + // Widget taps open the containing app, and every build accepts the prod + // scheme as a link prefix, so dev and preview builds route this too. + const linkRow = rows[0]; + const deepLink = + linkRow && linkRow.deepLink.startsWith("/") && !linkRow.deepLink.startsWith("//") + ? `t3code://${linkRow.deepLink.slice(1)}` + : null; + + const background = accessory ? "clear" : isLightScheme ? "#ffffff" : "#1c1c1e"; + const rootModifiers = [ + containerBackground(background, "widget"), + ...(deepLink ? [widgetURL(deepLink)] : []), + ]; + + // The template image ignores frame/foregroundStyle applied to itself; size + // and tint it through a container the resizable image fills. + const renderLogo = (height: number) => ( + + + + ); + + const renderRow = (row: AgentActivityRowProps, key: string) => + small || accessory ? ( + + + {row.threadTitle} + + + {row.status} + + + ) : ( + + + {row.threadTitle} + + + {row.projectTitle} + + + + {row.status} + + + ); + + return ( + + + {accessory ? null : renderLogo(13)} + + {summary || (accessory ? "T3 Code" : "")} + + + + {shown.length > 0 ? ( + + {shown.map((row, index) => renderRow(row, `${row.threadId}:${index}`))} + + ) : ( + + {emptyMessage} + + )} + {accessory ? null : } + + ); +} + +export default createWidget & { readonly accountId?: string }>( + "AgentActivity", + AgentActivityWidget, +); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts new file mode 100644 index 0000000000000..9d832fd4bce46 --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; + +import { + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, + agentActivityWidgetTimeline, +} from "./agentActivityWidgetTimeline"; +import { publishAgentActivityWidget } from "./publishAgentActivityWidget.ios"; + +const nativeWidget = vi.hoisted(() => ({ getTimeline: vi.fn(), updateTimeline: vi.fn() })); +vi.mock("expo", () => ({ requireOptionalNativeModule: () => ({}) })); +vi.mock("./AgentActivityWidget", () => ({ default: nativeWidget })); + +const now = Date.parse("2026-10-18T10:00:00.000Z"); + +type Row = RelayAgentActivityAggregateState["activities"][number]; + +function row(phase: Row["phase"], status: string): Row { + return { + environmentId: "env-1" as EnvironmentId, + threadId: `thread-${phase}` as ThreadId, + projectTitle: "t3code", + threadTitle: `Thread ${phase}`, + modelTitle: "GPT-5.5", + phase, + status, + updatedAt: "2026-10-18T09:59:00.000Z", + deepLink: `/threads/env-1/thread-${phase}`, + }; +} + +function aggregate(activities: ReadonlyArray): RelayAgentActivityAggregateState { + return { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: activities.length, + updatedAt: "2026-10-18T09:59:00.000Z", + activities, + }; +} + +describe("agentActivityWidgetTimeline", () => { + it("persists the snapshot owner and clears rows on sign-out", async () => { + nativeWidget.updateTimeline.mockClear(); + await publishAgentActivityWidget( + { kind: "snapshot", aggregate: aggregate([row("running", "Working")]) }, + "user-a", + ); + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ props: expect.objectContaining({ accountId: "user-a" }) }), + expect.objectContaining({ props: expect.objectContaining({ accountId: "user-a" }) }), + ]); + + await publishAgentActivityWidget({ kind: "signed-out" }, null); + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ + props: expect.objectContaining({ activities: [] }), + }), + ]); + expect(nativeWidget.updateTimeline.mock.lastCall?.[0][0].props).not.toHaveProperty("accountId"); + }); + + it("keeps sign-out last when an earlier cached timeline read is delayed", async () => { + let release = (_entries: unknown[]) => {}; + let requested = () => {}; + const started = new Promise((resolve) => { + requested = resolve; + }); + nativeWidget.getTimeline.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + requested(); + }), + ); + nativeWidget.updateTimeline.mockClear(); + + const loading = publishAgentActivityWidget({ kind: "loading" }, "user-a"); + await started; + const signOut = publishAgentActivityWidget({ kind: "signed-out" }, null); + release([]); + await Promise.all([loading, signOut]); + + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ + props: expect.objectContaining({ + subtitle: "Sign in to T3 Connect to see agents", + activities: [], + }), + }), + ]); + }); + + it("keeps persisted account rows and their stale deadline after a cold-start read failure", async () => { + const cached = agentActivityWidgetTimeline( + { kind: "snapshot", aggregate: aggregate([row("running", "Working")]) }, + now, + ).map((entry) => ({ ...entry, props: { ...entry.props, accountId: "user-a" } })); + nativeWidget.getTimeline.mockResolvedValue(cached); + nativeWidget.updateTimeline.mockClear(); + + await publishAgentActivityWidget({ kind: "loading" }, "user-a"); + await publishAgentActivityWidget({ kind: "unavailable" }, "user-a"); + + expect(nativeWidget.updateTimeline).not.toHaveBeenCalled(); + }); + + it("replaces another account's persisted rows before its first relay read", async () => { + nativeWidget.getTimeline.mockResolvedValue( + agentActivityWidgetTimeline( + { kind: "snapshot", aggregate: aggregate([row("running", "Working")]) }, + now, + ).map((entry) => ({ ...entry, props: { ...entry.props, accountId: "user-a" } })), + ); + nativeWidget.updateTimeline.mockClear(); + + await publishAgentActivityWidget({ kind: "loading" }, "user-b"); + + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ + props: expect.objectContaining({ subtitle: "Loading agents…", activities: [] }), + }), + ]); + }); + + it("replaces persisted placeholders after a failed relay read", async () => { + nativeWidget.getTimeline.mockResolvedValue( + agentActivityWidgetTimeline({ kind: "signed-out" }, now), + ); + nativeWidget.updateTimeline.mockClear(); + + await publishAgentActivityWidget({ kind: "unavailable" }, "user-a"); + + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ + props: expect.objectContaining({ subtitle: "Couldn't reach T3 Connect", activities: [] }), + }), + ]); + }); + + it("marks unfinished rows out of date once the widget can no longer vouch for them", () => { + const timeline = agentActivityWidgetTimeline( + { + kind: "snapshot", + aggregate: aggregate([row("running", "Working"), row("completed", "Done")]), + }, + now, + ); + + expect(timeline.map((entry) => entry.date.getTime())).toEqual([ + now, + now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, + ]); + expect(timeline[0]?.props.activities.map((entry) => entry.phase)).toEqual([ + "running", + "completed", + ]); + expect(timeline[1]?.props.activities.map((entry) => [entry.phase, entry.status])).toEqual([ + ["stale", "Out of date"], + ["completed", "Done"], + ]); + }); + + it("keeps finished outcomes on a single entry", () => { + const timeline = agentActivityWidgetTimeline( + { kind: "snapshot", aggregate: aggregate([row("failed", "Failed")]) }, + now, + ); + + expect(timeline).toHaveLength(1); + expect(timeline[0]?.props.activities[0]?.phase).toBe("failed"); + }); + + it("says no agents are active when the relay has no activity", () => { + const [entry] = agentActivityWidgetTimeline({ kind: "snapshot", aggregate: null }, now); + + expect(entry?.props).toMatchObject({ subtitle: "No active agents", activities: [] }); + }); + + it("asks the user to sign in instead of showing a previous account's agents", () => { + const [entry] = agentActivityWidgetTimeline({ kind: "signed-out" }, now); + + expect(entry?.props).toMatchObject({ + subtitle: "Sign in to T3 Connect to see agents", + activities: [], + }); + }); + + it("says T3 Connect is unreachable instead of asking a signed-in user to sign in", () => { + const [entry] = agentActivityWidgetTimeline({ kind: "unavailable" }, now); + + expect(entry?.props).toMatchObject({ subtitle: "Couldn't reach T3 Connect", activities: [] }); + }); +}); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts new file mode 100644 index 0000000000000..7a00789cc9b5a --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts @@ -0,0 +1,61 @@ +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; + +import type { AgentActivityProps } from "./AgentActivity"; + +// The widget only refreshes while the app is open, so after this long it stops +// claiming an agent is still working. Matches the Live Activity's stale date. +export const AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS = 10 * 60_000; + +export type AgentActivityWidgetSource = + | { readonly kind: "signed-out" } + | { readonly kind: "unavailable" } + | { readonly kind: "loading" } + | { readonly kind: "snapshot"; readonly aggregate: RelayAgentActivityAggregateState | null }; + +export function agentActivityWidgetTimeline(source: AgentActivityWidgetSource, now: number) { + const updatedAt = new Date(now).toISOString(); + if (source.kind !== "snapshot" || !source.aggregate) { + const props: AgentActivityProps = { + title: "T3 Code", + subtitle: + source.kind === "signed-out" + ? "Sign in to T3 Connect to see agents" + : source.kind === "unavailable" + ? "Couldn't reach T3 Connect" + : source.kind === "loading" + ? "Loading agents…" + : "No active agents", + activeCount: 0, + updatedAt, + activities: [], + }; + return [{ date: new Date(now), props }]; + } + + const { aggregate } = source; + const props: AgentActivityProps = { + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + }; + const hasInFlightRows = props.activities.some( + (row) => row.phase !== "completed" && row.phase !== "failed", + ); + if (!hasInFlightRows) { + return [{ date: new Date(now), props }]; + } + const staleProps: AgentActivityProps = { + ...props, + activities: props.activities.map((row) => + row.phase === "completed" || row.phase === "failed" + ? row + : { ...row, phase: "stale", status: "Out of date" }, + ), + }; + return [ + { date: new Date(now), props }, + { date: new Date(now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS), props: staleProps }, + ]; +} diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts new file mode 100644 index 0000000000000..8f30fa5cda4ad --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts @@ -0,0 +1,36 @@ +import { requireOptionalNativeModule } from "expo"; + +import { + agentActivityWidgetTimeline, + type AgentActivityWidgetSource, +} from "./agentActivityWidgetTimeline"; + +let publication = Promise.resolve(); + +export function publishAgentActivityWidget( + source: AgentActivityWidgetSource, + accountId: string | null, +) { + const next = publication.then(async () => { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const { default: widget } = await import("./AgentActivityWidget"); + if (accountId && (source.kind === "loading" || source.kind === "unavailable")) { + const cached = await widget.getTimeline().catch(() => []); + if (cached.some(({ props }) => props?.accountId === accountId && props.activities?.length)) { + return; + } + } + widget.updateTimeline( + agentActivityWidgetTimeline(source, Date.now()).map((entry) => ({ + ...entry, + props: { + ...entry.props, + ...(source.kind === "snapshot" && accountId ? { accountId } : {}), + }, + })), + ); + }); + // Failed native writes must not prevent a later sign-out from clearing the timeline. + publication = next.catch(() => {}); + return next; +} diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ts new file mode 100644 index 0000000000000..840357356c780 --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ts @@ -0,0 +1,6 @@ +import type { AgentActivityWidgetSource } from "./agentActivityWidgetTimeline"; + +export async function publishAgentActivityWidget( + _source: AgentActivityWidgetSource, + _accountId: string | null, +) {} diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 9c5e30ef76074..b8b7626627083 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -4,6 +4,8 @@ Sign in to T3 Connect, link your environments, and enable **Device Notifications Enable **Ongoing Agent Activity** on Android or **Live Activity Updates** on iOS to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off ongoing activity in Settings to stop future cards. +On iOS you can also add the **Agent Activity** widget to your Home Screen or Lock Screen. It shows the same agents as the Live Activity, using your T3 Connect sign-in. The widget refreshes when you open T3 and shows unfinished work as out of date after 10 minutes. + Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer can promote ongoing activity to a Live Update, subject to system settings and device support. Other devices show a regular ongoing notification. Android 7's battery-saving modes can delay removal of expired cards.