diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 582c58fb27e6..eccec2b878d2 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -5,13 +5,16 @@ import * as NodeCrypto from "node:crypto"; import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import Constants from "expo-constants"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { FetchHttpClient } from "effect/unstable/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivitySnapshotResponse } from "@t3tools/contracts/relay"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import type { SavedRemoteConnection } from "../../lib/connection"; import { cryptoLayer } from "../cloud/dpop"; @@ -41,6 +44,7 @@ import { shouldRegisterAgentAwarenessDeviceForProvider, unregisterAgentAwarenessConnection, } from "./remoteRegistration"; +import { publishAgentActivityWidget } from "../../widgets/AgentActivity"; import * as Notifications from "expo-notifications"; const secureStore = vi.hoisted(() => new Map()); @@ -89,6 +93,7 @@ vi.mock("../../widgets/AgentActivity", () => ({ getInstances: widgetMocks.getInstances, start: widgetMocks.start, }, + publishAgentActivityWidget: vi.fn(), })); // The state modules pull the whole connection stack (and native expo modules) @@ -194,6 +199,57 @@ function proofIat(proof: string): number { return decoded.iat; } +const activeAgentActivitySnapshot = { + aggregate: { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: "2026-05-25T13:07:00.000Z", + activities: [ + { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-1" as ThreadId, + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running" as const, + status: "Working", + updatedAt: "2026-05-25T13:07:00.000Z", + deepLink: "/threads/env-1/thread-1", + }, + ], + }, +} satisfies RelayAgentActivitySnapshotResponse; + +function snapshotRelayLayer( + getAgentActivitySnapshot: () => Effect.Effect = () => + Effect.succeed(activeAgentActivitySnapshot), +) { + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + return Layer.succeed( + ManagedRelay.ManagedRelayClient, + ManagedRelay.ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => Effect.die("unused"), + listDevices: () => Effect.die("unused"), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + getEnvironmentStatus: () => Effect.die("unused"), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.succeed({ ok: true }), + getAgentActivitySnapshot, + resetTokenCache: Effect.void, + }), + ); +} + function savedConnection(): SavedRemoteConnection { return { environmentId: "env-1" as EnvironmentId, @@ -248,10 +304,85 @@ describe("makeRelayDeviceRegistrationRequest", () => { vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear(); vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear(); vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); + vi.mocked(loadPreferences).mockReset(); + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false } as Preferences); widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); - widgetMocks.start.mockClear(); + widgetMocks.start.mockReset(); + widgetMocks.start.mockReturnValue({}); environmentConfigsMock.configs.clear(); + vi.mocked(publishAgentActivityWidget).mockClear(); + }); + + it.each(["sign-out", "account switch"])( + "does not restore local-work widget data after %s during preference loading", + async (transition) => { + let finishPreferences!: (preferences: Preferences) => void; + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Previous account thread", + projectTitle: "Previous account project", + }); + expect(loadPreferences).toHaveBeenCalledTimes(1); + // Register the same catch/then depth after the production continuation. + // Its receipt settles after the already-registered preference callback, + // without a timer, polling, or executing queued relay operations. + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + + setAgentAwarenessRelayTokenProvider(null); + if (transition === "account switch") { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-b"), "user-b"); + } + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, activities: [] }), + ); + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + await preferenceCallbackDrained; + + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, activities: [] }), + ); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }, + ); + + it("still arms local work after a same-account token refresh during preference loading", async () => { + let finishPreferences!: (preferences: Preferences) => void; + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Current account thread", + projectTitle: "Current account project", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("refreshed-token-user-a"), "user-a"); + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + await preferenceCallbackDrained; + + expect(widgetMocks.start).toHaveBeenCalledTimes(1); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ threadTitle: "Current account thread" })], + }), + ); }); it("preserves disabled Live Activity preferences in relay registrations", () => { @@ -396,6 +527,33 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relayTestLayer)); }); + it.effect("refreshes the widget after a delayed Live Activity push token registers", () => { + let onPushToken: ((event: { pushToken: string }) => void) | undefined; + const activity = { + getPushToken: vi.fn(() => Promise.resolve(null)), + addPushTokenListener: vi.fn((listener: (event: { pushToken: string }) => void) => { + onPushToken = listener; + return { remove: vi.fn() }; + }), + }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); + expect(onPushToken).toBeDefined(); + + onPushToken?.({ pushToken: "delayed-activity-token" }); + yield* runBackgroundOperations(); + + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + it.effect("preserves Live Activity push-token lookup failures", () => { const cause = new Error("native token lookup failed"); const activity = { @@ -457,6 +615,48 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, ); + it.effect("publishes the home-screen widget when a Live Activity is already armed", () => { + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + start: vi.fn(), + update: vi.fn(), + end: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(activity.start).not.toHaveBeenCalled(); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("publishes the home-screen widget when Live Activities are disabled", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + }), + ); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + it.effect( "re-registers active Live Activity tokens when the app returns to the foreground", () => { @@ -487,7 +687,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, ); - it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => { + it("ends local Live Activities and clears the home-screen widget on cloud sign-out", () => { const end = vi.fn(() => Promise.resolve()); const activity = { getPushToken: vi.fn(() => Promise.resolve("activity-token")), @@ -501,6 +701,14 @@ describe("makeRelayDeviceRegistrationRequest", () => { setAgentAwarenessRelayTokenProvider(null); expect(end).toHaveBeenCalledWith("immediate"); + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + title: "T3 Code", + subtitle: "No active agents", + activeCount: 0, + activities: [], + }), + ); expect(appStateMock.listeners).toHaveLength(0); }); @@ -545,11 +753,13 @@ 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 deviceCall = fetchMock.mock.calls.find((call) => { + const request = call[0]; + const url = request instanceof Request ? request.url : String(request); + return url === "https://relay.example.test/v1/mobile/devices"; + }); + expect(deviceCall).toBeDefined(); + const [request, init] = deviceCall as unknown as [unknown, RequestInit | undefined]; const url = request instanceof Request ? request.url : String(request); const method = request instanceof Request ? request.method : init?.method; const headers = request instanceof Request ? request.headers : new Headers(init?.headers); @@ -795,7 +1005,9 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* runBackgroundOperations(); expect(backgroundRuntime.pending).toHaveLength(0); - expect(tokenProvider).toHaveBeenCalledTimes(2); + // Device registration retries after the first auth miss, and the + // home-screen widget refresh independently reads the relay token. + expect(tokenProvider).toHaveBeenCalledTimes(3); }).pipe(Effect.provide(relayTestLayer)); }); @@ -932,4 +1144,356 @@ describe("makeRelayDeviceRegistrationRequest", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(widgetMocks.start).toHaveBeenCalledTimes(1); }); + it.effect("refreshes the home-screen widget after arming local agent work", () => { + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + widgetMocks.start.mockReturnValueOnce(activity); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + const preferences = Promise.resolve({ + liveActivitiesEnabled: true, + } as Preferences); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + + return Effect.gen(function* () { + yield* Effect.promise(() => preferenceCallbackDrained); + yield* runBackgroundOperations(); + + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("refreshes the home-screen widget when local Live Activities are disabled", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + const preferences = Promise.resolve({ + liveActivitiesEnabled: false, + } as Preferences); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + + return Effect.gen(function* () { + yield* Effect.promise(() => preferenceCallbackDrained); + yield* runBackgroundOperations(); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(1); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("refreshes the home-screen widget when a local Live Activity is already armed", () => { + widgetMocks.getInstances.mockReturnValueOnce([{}] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + const preferences = Promise.resolve({ + liveActivitiesEnabled: true, + } as Preferences); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + + return Effect.gen(function* () { + yield* Effect.promise(() => preferenceCallbackDrained); + yield* runBackgroundOperations(); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(1); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("refreshes the home-screen widget when local Live Activity arming fails", () => { + widgetMocks.start.mockImplementationOnce(() => { + throw new Error("start failed"); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("discards an older widget snapshot when a newer refresh finishes first", () => + Effect.gen(function* () { + const firstReadStarted = yield* Deferred.make(); + const finishFirstRead = yield* Deferred.make(); + const olderSnapshot = { + aggregate: { + ...activeAgentActivitySnapshot.aggregate, + updatedAt: "2026-05-25T13:06:00.000Z", + activities: [ + { + ...activeAgentActivitySnapshot.aggregate.activities[0], + status: "Older status", + updatedAt: "2026-05-25T13:06:00.000Z", + }, + ], + }, + } satisfies RelayAgentActivitySnapshotResponse; + let readCount = 0; + const layer = snapshotRelayLayer(() => { + readCount++; + if (readCount === 1) { + return Deferred.succeed(firstReadStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishFirstRead)), + Effect.as(olderSnapshot), + ); + } + return Effect.succeed(activeAgentActivitySnapshot); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + + const olderRefresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(firstReadStarted); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + yield* Deferred.succeed(finishFirstRead, undefined); + yield* Fiber.join(olderRefresh); + + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(1); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect("does not overwrite a local work seed with an in-flight widget snapshot", () => + Effect.gen(function* () { + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + const layer = snapshotRelayLayer(() => + Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + Effect.as({ aggregate: null }), + ), + ); + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + widgetMocks.getInstances + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValue([activity] as never); + widgetMocks.start.mockReturnValueOnce(activity); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + const preferences = Promise.resolve({ + liveActivitiesEnabled: true, + } as Preferences); + vi.mocked(loadPreferences).mockReturnValue(preferences); + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(readStarted); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + yield* Effect.promise(() => preferenceCallbackDrained); + + yield* Deferred.succeed(finishRead, undefined); + yield* Fiber.join(refresh); + + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(1); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Connecting" })], + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect("does not prime a Live Activity after cloud sign-out", () => + Effect.gen(function* () { + let preferencesStarted!: () => void; + let finishPreferences!: (preferences: Preferences) => void; + const started = new Promise((resolve) => { + preferencesStarted = resolve; + }); + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockImplementationOnce(() => { + preferencesStarted(); + return preferences; + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(snapshotRelayLayer()), + Effect.forkChild, + ); + yield* Effect.promise(() => started); + + setAgentAwarenessRelayTokenProvider(null); + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + yield* Fiber.join(refresh); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + subtitle: "No active agents", + activeCount: 0, + activities: [], + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect("does not prime from a snapshot that became idle while preferences loaded", () => + Effect.gen(function* () { + let preferencesStarted!: () => void; + let finishPreferences!: (preferences: Preferences) => void; + const started = new Promise((resolve) => { + preferencesStarted = resolve; + }); + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockImplementationOnce(() => { + preferencesStarted(); + return preferences; + }); + let readCount = 0; + const layer = snapshotRelayLayer(() => { + readCount++; + return Effect.succeed(readCount === 1 ? activeAgentActivitySnapshot : { aggregate: null }); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Effect.promise(() => started); + + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + yield* Fiber.join(refresh); + + expect(readCount).toBe(2); + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + subtitle: "No active agents", + activeCount: 0, + activities: [], + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect("does not restore an in-flight widget snapshot after cloud sign-out", () => + Effect.gen(function* () { + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + const layer = snapshotRelayLayer(() => + Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + Effect.as(activeAgentActivitySnapshot), + ), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(readStarted); + setAgentAwarenessRelayTokenProvider(null); + yield* Deferred.succeed(finishRead, undefined); + yield* Fiber.join(refresh); + + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(1); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + subtitle: "No active agents", + activeCount: 0, + activities: [], + }), + ); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a2d4261de603..bfdd9865c30a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -31,7 +31,10 @@ import { loadPreferences, saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; -import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; +import AgentActivity, { + publishAgentActivityWidget, + type AgentActivityProps, +} from "../../widgets/AgentActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { supportsAgentAwarenessPush } from "./capabilities"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; @@ -115,6 +118,7 @@ let activeLiveActivityRegistrationRetry: ReturnType | null = let relayTokenProvider: (() => Promise) | null = null; let relayTokenProviderIdentity: string | null = null; let deviceRegistrationGeneration = 0; +let widgetRefreshGeneration = 0; let activeDeviceRegistration: { readonly input: DeviceRegistrationInput; operation: Promise; @@ -197,6 +201,7 @@ export function setAgentAwarenessRelayTokenProvider( // Without a signed-in user the relay can no longer update or end these // activities, so they would sit orphaned on the lock screen. endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); + publishAgentActivityWidget(idleWidgetProps()); setRegistrationStatus("unknown"); // Sign-out is the only thing that invalidates a stored registration, so the // next sign-in re-registers. @@ -461,6 +466,28 @@ function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolea ); } +function widgetPropsFromAggregate( + aggregate: NonNullable, +): AgentActivityProps { + return { + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + }; +} + +function idleWidgetProps(): AgentActivityProps { + return { + title: "T3 Code", + subtitle: "No active agents", + activeCount: 0, + updatedAt: new Date().toISOString(), + activities: [], + }; +} + // Arms the lock-screen card the moment the user starts agent work from this // phone, while the app is still foregrounded and the fresh activity's token // can be registered immediately. The seeded row is a best-effort placeholder; @@ -482,53 +509,85 @@ export function armAgentAwarenessLiveActivityForLocalWork(input: { }); return; } + const expectedDeviceGeneration = deviceRegistrationGeneration; void loadPreferences() .catch(() => null) .then((preferences) => { + if (deviceRegistrationGeneration !== expectedDeviceGeneration || !relayTokenProvider) { + return; + } if (preferences?.liveActivitiesEnabled === false) { + runRegistrationInBackground( + refreshAgentActivityWidget(), + "widget refresh after local task start failed", + ); return; } armAgentAwarenessLiveActivityForLocalWorkNow(input); }); } +function localWorkStartingWidgetProps(input: { + readonly threadTitle: string; + readonly projectTitle: string; +}): AgentActivityProps { + const nowIso = new Date(Date.now()).toISOString(); + return { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: nowIso, + activities: [ + { + environmentId: "", + threadId: "", + projectTitle: input.projectTitle, + threadTitle: input.threadTitle, + modelTitle: "", + phase: "starting", + status: "Connecting", + updatedAt: nowIso, + deepLink: "/", + }, + ], + }; +} + function armAgentAwarenessLiveActivityForLocalWorkNow(input: { readonly threadTitle: string; readonly projectTitle: string; }): void { try { if (AgentActivity.getInstances().length > 0) { + runRegistrationInBackground( + refreshAgentActivityWidget(), + "widget refresh after local task start failed", + ); return; } - const nowIso = new Date(Date.now()).toISOString(); - const activity = AgentActivity.start({ - title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: 1, - updatedAt: nowIso, - activities: [ - { - environmentId: "", - threadId: "", - projectTitle: input.projectTitle, - threadTitle: input.threadTitle, - modelTitle: "", - phase: "starting", - status: "Connecting", - updatedAt: nowIso, - deepLink: "/", - }, - ], - }); + const props = localWorkStartingWidgetProps(input); + // This local seed is newer than any relay snapshot already being read. + // Invalidate those reads before publishing so they cannot repaint the + // widget with stale idle or aggregate state when they eventually finish. + widgetRefreshGeneration++; + publishAgentActivityWidget(props); + const activity = AgentActivity.start(props); logRegistrationDebug("live activity card armed for local work", { threadTitle: input.threadTitle, }); runRegistrationInBackground( - registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid), + registerLiveActivityPushToken({ activity }).pipe( + Effect.ensuring(refreshAgentActivityWidget()), + Effect.asVoid, + ), "live activity arming after local task start failed", ); } catch (error) { logRegistrationError("live activity arming failed", error); + runRegistrationInBackground( + refreshAgentActivityWidget(), + "widget refresh after live activity arming failed", + ); } } @@ -555,6 +614,30 @@ function readAgentActivitySnapshot(): Effect.Effect< ); } +function refreshAgentActivityWidget(): Effect.Effect< + RelayAgentActivitySnapshotResponse | null, + never, + ManagedRelay.ManagedRelayClient +> { + return Effect.gen(function* () { + const expectedDeviceGeneration = deviceRegistrationGeneration; + const expectedRefreshGeneration = ++widgetRefreshGeneration; + const snapshot = yield* readAgentActivitySnapshot(); + if ( + expectedDeviceGeneration !== deviceRegistrationGeneration || + expectedRefreshGeneration !== widgetRefreshGeneration + ) { + return null; + } + if (snapshot) { + publishAgentActivityWidget( + snapshot.aggregate ? widgetPropsFromAggregate(snapshot.aggregate) : idleWidgetProps(), + ); + } + return snapshot; + }); +} + function registerLiveActivityWithRelay( body: RelayLiveActivityRegistrationRequest, ): Effect.Effect { @@ -875,6 +958,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { relayTokenProvider = null; relayTokenProviderIdentity = null; deviceRegistrationGeneration++; + widgetRefreshGeneration++; activeDeviceRegistration = null; pendingDeviceRegistration = null; registrationStatus = "unknown"; @@ -949,7 +1033,7 @@ export function registerLiveActivityPushToken(input: { runRegistrationInBackground( registerLiveActivityPushTokenValue({ activityPushToken: event.pushToken, - }), + }).pipe(Effect.ensuring(refreshAgentActivityWidget())), "live activity token listener registration failed", ); } @@ -1019,6 +1103,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { return; } + const expectedDeviceGeneration = deviceRegistrationGeneration; let activities = yield* Effect.try({ try: () => AgentActivity.getInstances(), @@ -1047,6 +1132,12 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< activities = activities.slice(0, 1); } + // Home-screen widgets are independent of the lock-screen card. Publish + // the latest aggregate even when a Live Activity already exists or the + // user has turned Live Activities off; otherwise the widget stays on the + // "Connecting" snapshot from local arming. + yield* refreshAgentActivityWidget(); + // Activities are only ever created here, in the foreground, where the // update token can be observed and registered immediately — the relay // never remote-starts one (background push-to-start wakes proved too @@ -1063,10 +1154,12 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< cause, }), }).pipe(Effect.orElseSucceed(() => null)); + if (expectedDeviceGeneration !== deviceRegistrationGeneration || !relayTokenProvider) { + return; + } // The toggle defaults to on: an unset preference (fresh install) must // prime, so only an explicit false blocks it. 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. const armedMeanwhile = yield* Effect.try({ @@ -1075,17 +1168,14 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray>)); if (armedMeanwhile.length > 0) { activities = [...armedMeanwhile]; - } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { - const aggregate = snapshot.aggregate; + } else { + const latestSnapshot = yield* refreshAgentActivityWidget(); + if (!latestSnapshot?.aggregate || latestSnapshot.aggregate.activeCount <= 0) { + return; + } + const aggregate = latestSnapshot.aggregate; const primed = yield* Effect.try({ - try: () => - AgentActivity.start({ - title: aggregate.title, - subtitle: aggregate.subtitle, - activeCount: aggregate.activeCount, - updatedAt: aggregate.updatedAt, - activities: aggregate.activities, - }), + try: () => AgentActivity.start(widgetPropsFromAggregate(aggregate)), catch: (cause) => new AgentAwarenessOperationError({ operation: "prime-live-activity", diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index dc9cd0e22117..280fba344fb0 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import type { WidgetEnvironment } from "expo-widgets"; vi.mock("@expo/ui/swift-ui", () => ({ + Divider: "Divider", HStack: "HStack", Image: "Image", Spacer: "Spacer", @@ -10,6 +12,10 @@ vi.mock("@expo/ui/swift-ui", () => ({ })); vi.mock("@expo/ui/swift-ui/modifiers", () => ({ + aspectRatio: (value: unknown) => ({ aspectRatio: value }), + containerBackground: (color: unknown, container: unknown) => ({ + containerBackground: { color, container }, + }), font: (value: unknown) => value, foregroundStyle: (value: unknown) => value, frame: (value: unknown) => value, @@ -22,6 +28,11 @@ vi.mock("@expo/ui/swift-ui/modifiers", () => ({ vi.mock("expo-widgets", () => ({ createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })), + createWidget: vi.fn((name: string, layout: unknown) => ({ + layout, + name, + updateSnapshot: vi.fn(), + })), })); import { @@ -63,6 +74,16 @@ const lightEnvironment = { isLuminanceReduced: false, } as const; +function widgetEnvironment(widgetFamily: WidgetEnvironment["widgetFamily"]): WidgetEnvironment { + return { + date: new Date(0), + widgetFamily, + colorScheme: "dark", + isLuminanceReduced: false, + configuration: undefined, + }; +} + describe("AgentActivity widget layout", () => { it("tints each row by its own phase using the web sidebar's dark palette", () => { const layout = AgentActivity( @@ -74,7 +95,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), ], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("#7dd3fc"); // sky-300: running @@ -93,7 +114,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), ], }, - lightEnvironment as never, + lightEnvironment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("#0284c7"); // sky-600: running @@ -117,7 +138,7 @@ describe("AgentActivity widget layout", () => { }), ], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner.indexOf("Blocked thread")).toBeGreaterThan(-1); @@ -134,7 +155,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }), ], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("3 active agents"); @@ -151,7 +172,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }), ], }, - environment as never, + environment, ); expect(JSON.stringify(layout.compactLeading)).toContain("#a5b4fc"); // indigo-300 expect(JSON.stringify(layout.compactTrailing)).toContain("Input"); @@ -173,7 +194,7 @@ describe("AgentActivity widget layout", () => { }), ], }, - environment as never, + environment, ); expect(JSON.stringify(layout.banner)).toContain( '"widgetURL":"t3code://threads/env-1/thread-2"', @@ -181,19 +202,19 @@ describe("AgentActivity widget layout", () => { }); it("deep links the banner to the first row when nothing needs attention", () => { - const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment as never); + const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment); expect(JSON.stringify(layout.banner)).toContain( '"widgetURL":"t3code://threads/env-1/thread-1"', ); }); it("omits the deep link for unsafe paths and empty aggregates", () => { - expect(JSON.stringify(AgentActivity(props, environment as never))).not.toContain("widgetURL"); + expect(JSON.stringify(AgentActivity(props, environment))).not.toContain("widgetURL"); expect( JSON.stringify( AgentActivity( { ...props, activities: [makeRow({ deepLink: "//evil.example" })] }, - environment as never, + environment, ), ), ).not.toContain("widgetURL"); @@ -207,7 +228,7 @@ describe("AgentActivity widget layout", () => { activeCount: 0, activities: [makeRow({ phase: "completed", status: "Done" })], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("Agent work completed"); @@ -228,7 +249,7 @@ describe("AgentActivity widget layout", () => { activeCount: 0, activities: [makeRow({ phase: "failed", status: "Failed" })], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("Agent work failed"); @@ -252,7 +273,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: "thread-2", phase: "failed", status: "Failed" }), ], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("Agent work failed"); @@ -263,6 +284,113 @@ describe("AgentActivity widget layout", () => { expect(JSON.stringify(layout.minimal)).toContain("xmark.octagon.fill"); }); + it("renders branded, top-aligned home-screen layouts with status icons", () => { + const medium = AgentActivity( + { + ...props, + activeCount: 3, + activities: [ + makeRow({ threadTitle: "First thread", projectTitle: "First project" }), + makeRow({ + threadId: "thread-2", + threadTitle: "Second thread", + projectTitle: "Second project", + phase: "completed", + status: "Done", + }), + makeRow({ + threadId: "thread-3", + threadTitle: "Overflow thread", + phase: "completed", + status: "Done", + }), + ], + }, + widgetEnvironment("systemMedium"), + ); + const mediumJson = JSON.stringify(medium); + expect(medium).not.toHaveProperty("banner"); + expect(mediumJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); + expect(mediumJson).toContain("T3Mark"); + expect(mediumJson).toContain("Code"); + expect(mediumJson).toContain("3 active agents"); + expect(mediumJson).toContain("arrow.up.right"); + expect(mediumJson).not.toContain("folder.fill"); + expect(mediumJson).toContain('"aspectRatio":{"contentMode":"fit"}'); + expect(mediumJson).toContain("arrow.triangle.2.circlepath"); + expect(mediumJson).toContain("checkmark.circle.fill"); + expect(mediumJson).toContain("First project"); + expect(mediumJson).toContain("Second project"); + expect(mediumJson.indexOf("T3Mark")).toBeLessThan(mediumJson.indexOf("First thread")); + expect(mediumJson.indexOf("First thread")).toBeLessThan(mediumJson.indexOf("Second thread")); + expect(mediumJson).not.toContain("Overflow thread"); + expect(mediumJson).not.toContain('"all":14'); + + const small = AgentActivity( + { ...props, activities: [makeRow({})] }, + widgetEnvironment("systemSmall"), + ); + const smallJson = JSON.stringify(small); + expect(small).not.toHaveProperty("banner"); + expect(smallJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); + expect(smallJson).toContain("T3Mark"); + expect(smallJson).toContain("Code"); + expect(smallJson).toContain("1 active agent"); + expect(smallJson).not.toContain("folder.fill"); + expect(smallJson).toContain("arrow.triangle.2.circlepath"); + expect(smallJson).toContain("Project"); + expect(smallJson.indexOf("T3Mark")).toBeLessThan(smallJson.indexOf("Thread")); + expect(smallJson).not.toContain('"all":10'); + expect(smallJson).not.toContain('"all":14'); + + const accessory = AgentActivity( + { ...props, activities: [makeRow({})] }, + widgetEnvironment("accessoryRectangular"), + ); + const accessoryJson = JSON.stringify(accessory); + expect(accessory).not.toHaveProperty("banner"); + expect(accessoryJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); + expect(accessoryJson).toContain('"widgetURL":"t3code://threads/env-1/thread-1"'); + expect(accessoryJson).toContain('"all":10'); + expect(accessoryJson).not.toContain('"all":14'); + }); + + it("deep links lock-screen accessory widgets", () => { + const accessory = AgentActivity( + { + ...props, + activeCount: 2, + activities: [ + makeRow({}), + makeRow({ + threadId: "thread-2", + phase: "waiting_for_approval", + status: "Approval", + deepLink: "/threads/env-1/thread-2", + }), + ], + }, + widgetEnvironment("accessoryCircular"), + ); + expect(JSON.stringify(accessory)).toContain('"widgetURL":"t3code://threads/env-1/thread-2"'); + }); + + it("does not apply containerBackground to the Live Activity layout", () => { + const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment); + expect(layout).toHaveProperty("banner"); + expect(JSON.stringify(layout)).not.toContain("containerBackground"); + }); + + it("renders an idle home-screen widget when props are missing", () => { + for (const family of ["systemSmall", "systemMedium"] as const) { + const view = AgentActivity({} as AgentActivityProps, widgetEnvironment(family)); + const json = JSON.stringify(view); + expect(json.match(/No active agents/g)).toHaveLength(1); + expect(json).toContain('"containerBackground":{"color":"clear","container":"widget"}'); + expect(json).not.toContain("0 active"); + } + }); + it("renders up to five rows in the banner", () => { const layout = AgentActivity( { @@ -272,7 +400,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: `t${n}`, threadTitle: `Thread ${n}` }), ), }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); for (const visible of [1, 2, 3, 4, 5]) { diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 88c85648271f..7078b88b583b 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -1,6 +1,8 @@ -import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; -import type { ComponentProps } from "react"; +import { Divider, HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; +import type { ComponentProps, JSX } from "react"; import { + aspectRatio, + containerBackground, font, foregroundStyle, frame, @@ -12,12 +14,12 @@ import { } from "@expo/ui/swift-ui/modifiers"; import { createLiveActivity, - type LiveActivityComponent, + createWidget, + type LiveActivityEnvironment, type LiveActivityLayout, + type WidgetEnvironment, } from "expo-widgets"; -type LiveActivityEnvironment = Parameters>[1]; - export type AgentActivityPhase = | "starting" | "running" @@ -50,12 +52,26 @@ export interface AgentActivityProps { // This function is serialized into the widget extension's JS bundle, so it // must stay self-contained: no references to module-scope helpers, only the // imported view/modifier factories. +export function AgentActivity( + props: AgentActivityProps, + environment: WidgetEnvironment, +): JSX.Element; export function AgentActivity( props: AgentActivityProps, environment: LiveActivityEnvironment, -): LiveActivityLayout { +): LiveActivityLayout; +export function AgentActivity( + props: AgentActivityProps, + environment: WidgetEnvironment | LiveActivityEnvironment, +): JSX.Element | LiveActivityLayout { "widget"; + // Placeholder / first-paint entries arrive with empty props. Treat missing + // fields as idle rather than throwing inside the widget JS runtime. + const activities = Array.isArray(props.activities) ? props.activities : []; + const activeCount = typeof props.activeCount === "number" ? props.activeCount : 0; + const widgetFamily = "widgetFamily" in environment ? environment.widgetFamily : undefined; + // Use SwiftUI's semantic label colors rather than fixed hex keyed off the // device color scheme. A Live Activity banner always renders over a dark // system material regardless of the device's light/dark setting, so @@ -100,20 +116,18 @@ export function AgentActivity( if (phase === "running" || phase === "starting") return 2; return 3; }; - const ordered = [...props.activities].sort( - (a, b) => phasePriority(a.phase) - phasePriority(b.phase), - ); + const ordered = [...activities].sort((a, b) => phasePriority(a.phase) - phasePriority(b.phase)); const row0 = ordered[0]; const row1 = ordered[1]; const row2 = ordered[2]; const row3 = ordered[3]; const row4 = ordered[4]; - const attentionRows = props.activities.filter( + const attentionRows = activities.filter( (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", ); const attentionRow = attentionRows[0]; - const failedRow = props.activities.find((row) => row.phase === "failed"); + const failedRow = activities.find((row) => row.phase === "failed"); const heroRow = attentionRow ?? failedRow ?? row0; const tint = phaseTint(heroRow?.phase); // Headline count leans on the accent when a human is actually blocked. @@ -130,20 +144,25 @@ export function AgentActivity( // terminal row): every presentation — header text, tint, count slots, // minimal glyph — must agree, and a failure anywhere should dominate a // newer success. - const allDone = props.activeCount === 0; - const doneLabel = failedRow ? "Failed" : "Done"; - const outcomeLabel = failedRow ? "Agent work failed" : "Agent work completed"; + const allDone = activeCount === 0; + const hasRows = activities.length > 0; + const doneLabel = failedRow ? "Failed" : hasRows ? "Done" : "Idle"; + const outcomeLabel = failedRow + ? "Agent work failed" + : hasRows + ? "Agent work completed" + : "No active agents"; // Header copy: "5 active agents" + (", 1 needs attention"). The banner renders // the two parts in-line so the attention half can carry the accent color; // `summary` is the short form for tight spots (expanded center, watch card). - const agentWord = props.activeCount === 1 ? "agent" : "agents"; - const agentsLabel = allDone ? outcomeLabel : `${props.activeCount} active ${agentWord}`; + const agentWord = activeCount === 1 ? "agent" : "agents"; + const agentsLabel = allDone ? outcomeLabel : `${activeCount} active ${agentWord}`; const attentionSuffix = attentionRows.length > 0 ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention` : ""; - const activeLabel = allDone ? doneLabel : `${props.activeCount} active`; + const activeLabel = allDone ? doneLabel : `${activeCount} active`; const summary = attentionSuffix || activeLabel; // Any registered scheme variant routes back to this app; taps are delivered @@ -178,10 +197,14 @@ export function AgentActivity( }; // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to - // the image; size + tint them through a container the resizable symbol fills. + // the image; size + tint them through a container. Preserve the symbol's + // intrinsic aspect ratio when the resizable image fills that frame. const renderGlyph = (systemName: SFName, size: number, color: string) => ( - + ); @@ -235,94 +258,250 @@ export function AgentActivity( ); - return { - banner: ( - - {/* Logo pinned to the leading edge; the status texts centered across the - full width (ZStack so the logo doesn't skew the centering). No footer — - overflow beyond the visible rows is inferable from the count. */} - - - {renderLogo(13, primaryForeground)} - + // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family) + // and lock-screen accessory widgets. + const renderCompactLayout = () => ( + + + {renderLogo(14, primaryForeground)} + + {attentionRows.length > 0 ? summary : activeLabel} + + + + {row0 ? ( + + + {row0.threadTitle} + + + + {row0.status} + + + ) : null} + + ); + + if ( + widgetFamily === "accessoryCircular" || + widgetFamily === "accessoryInline" || + widgetFamily === "accessoryRectangular" + ) { + return renderCompactLayout(); + } + + if (widgetFamily) { + // iOS supplies content margins for home-screen widgets, so adding explicit + // padding here would double-inset the layout. + const widgetModifiers = [ + ...(deepLink ? [widgetURL(deepLink)] : []), + containerBackground("clear", "widget"), + ]; + const homeSummaryTint = allDone && !hasRows ? secondaryForeground : headerTint; + const homeSummary = attentionSuffix || agentsLabel; + const renderHomeStatusIcon = (row: AgentActivityRowProps, size: number) => + renderGlyph(phaseSymbol(row.phase), size, phaseTint(row.phase)); + + if (widgetFamily === "systemSmall") { + return ( + + + {renderLogo(12, primaryForeground)} + + Code + + + {renderGlyph("arrow.up.right", 10, secondaryForeground)} - - + + {homeSummary} + + {heroRow ? ( - {agentsLabel} + {heroRow.threadTitle} - {attentionSuffix ? ( - · - ) : null} - {attentionSuffix ? ( + ) : null} + {heroRow ? ( + + {renderHomeStatusIcon(heroRow, 11)} + + {heroRow.projectTitle} + + - {attentionSuffix} + {heroRow.status} - ) : null} - - - - {row0 ? renderCompactRow(row0) : null} - {row1 ? renderCompactRow(row1) : null} - {row2 ? renderCompactRow(row2) : null} - {row3 ? renderCompactRow(row3) : null} - {row4 ? renderCompactRow(row4) : null} - - ), - // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family): - // brand + count, then the single most important agent with its status glyph. - bannerSmall: ( - - - {renderLogo(14, primaryForeground)} + + ) : null} + + + ); + } + + const renderHomeRow = (row: AgentActivityRowProps) => ( + + {renderHomeStatusIcon(row, 17)} + - {attentionRows.length > 0 ? summary : activeLabel} + {row.threadTitle} - + + {row.projectTitle} + + + + + {row.status} + + + ); + + return ( + + + {renderLogo(13, primaryForeground)} + + Code + + + + {homeSummary} + + {renderGlyph("arrow.up.right", 10, secondaryForeground)} - {row0 ? ( - + {row0 ? renderHomeRow(row0) : null} + {row1 ? : null} + {row1 ? renderHomeRow(row1) : null} + + + ); + } + + const banner = ( + + {/* Logo pinned to the leading edge; the status texts centered across the + full width (ZStack so the logo doesn't skew the centering). No footer — + overflow beyond the visible rows is inferable from the count. */} + + + {renderLogo(13, primaryForeground)} + + + + + + {agentsLabel} + + {attentionSuffix ? ( + · + ) : null} + {attentionSuffix ? ( - {row0.threadTitle} + {attentionSuffix} - - - {row0.status} - - - ) : null} - - ), + ) : null} + + + + {row0 ? renderCompactRow(row0) : null} + {row1 ? renderCompactRow(row1) : null} + {row2 ? renderCompactRow(row2) : null} + {row3 ? renderCompactRow(row3) : null} + {row4 ? renderCompactRow(row4) : null} + + ); + + return { + banner, + bannerSmall: renderCompactLayout(), compactLeading: renderLogo(14, tint), compactTrailing: ( @@ -344,7 +523,7 @@ export function AgentActivity( {renderLogo(15, tint)} - {allDone ? doneLabel : `${props.activeCount}`} + {allDone ? doneLabel : `${activeCount}`} ), @@ -378,4 +557,14 @@ export function AgentActivity( }; } +export const AgentActivityWidget = createWidget("AgentActivity", AgentActivity); + +export function publishAgentActivityWidget(props: AgentActivityProps): void { + try { + AgentActivityWidget.updateSnapshot(props); + } catch { + // Personal-team and Android builds have no widget extension. + } +} + export default createLiveActivity("AgentActivity", AgentActivity);