diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 97eb9b0cadcab..fad355f698a48 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -195,7 +195,10 @@ 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", "systemLarge", "accessoryRectangular"], + initialLayout: "./src/widgets/AgentActivityWidget.tsx", + }, }, ], }, diff --git a/apps/mobile/fingerprint.config.js b/apps/mobile/fingerprint.config.js index 7e684229320a2..4c06611d4fc00 100644 --- a/apps/mobile/fingerprint.config.js +++ b/apps/mobile/fingerprint.config.js @@ -14,5 +14,9 @@ if (!majorVersion) { module.exports = { nativeModuleSourceType: "files", - extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }], + extraSources: [ + { type: "contents", id: "appMajorVersion", contents: majorVersion }, + // Expo embeds this layout in the extension, outside its native module sources. + { type: "file", filePath: "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 e9f5a3b1e5394..45d1948392fdd 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, @@ -49,6 +55,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, @@ -85,6 +92,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, @@ -252,6 +263,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); widgetMocks.start.mockClear(); + publishWidgetMock.mockClear(); widgetMocks.dismissEnded.mockReset(); environmentConfigsMock.configs.clear(); }); @@ -520,12 +532,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"); @@ -597,9 +611,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]; @@ -610,7 +627,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* runBackgroundOperations(); expect(backgroundRuntime.pending).toHaveLength(0); - expect(tokenProvider).toHaveBeenCalledTimes(2); + expect(tokenProvider).toHaveBeenCalledTimes(3); }).pipe(Effect.provide(layerRelayTest)); }); @@ -728,4 +745,339 @@ 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("publishes the widget snapshot before token registration finishes", () => + Effect.gen(function* () { + let markRequested = () => {}; + const requested = new Promise((resolve) => { + markRequested = resolve; + }); + let releaseToken = () => {}; + const response = new Promise((resolve) => { + releaseToken = () => resolve(null); + }); + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => { + markRequested(); + return response; + }), + addPushTokenListener: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + 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); + const publishedBeforeRegistration = publishWidgetMock.mock.lastCall; + releaseToken(); + yield* Fiber.join(refresh); + + expect(publishedBeforeRegistration).toEqual([{ kind: "snapshot", aggregate }, "user-a"]); + }), + ); + + 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* () { + const activity = { + getId: () => "activity-a", + getPushToken: vi.fn(() => Promise.resolve(null)), + addPushTokenListener: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValueOnce([activity] as never); + const requestedA = yield* Deferred.make(); + const responseA = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); + const requestedB = yield* Deferred.make(); + const responseB = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); + let reads = 0; + const relay = fakeRelay(() => + ++reads === 1 + ? Deferred.succeed(requestedA, undefined).pipe( + Effect.andThen(Deferred.await(responseA)), + ) + : Deferred.succeed(requestedB, undefined).pipe( + Effect.andThen(Deferred.await(responseB)), + ), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + + const refreshA = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Deferred.await(requestedA); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + const refreshB = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Deferred.await(requestedB); + yield* Deferred.succeed(responseA, { aggregate }); + yield* Fiber.join(refreshA); + yield* Deferred.succeed(responseB, { aggregate }); + yield* Fiber.join(refreshB); + + expect(activity.getPushToken).not.toHaveBeenCalled(); + 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.each(["user-a", "user-b"])( + "drops a snapshot after signing back in as %s", + (accountId) => + 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(null); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), accountId); + 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 437a00be9eaf1..f4328074b7286 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -37,6 +37,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, @@ -89,6 +91,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; @@ -181,6 +186,9 @@ export function setAgentAwarenessRelayTokenProvider( if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) { clearAndroidAgentNotifications(); } + if (provider) { + publishWidget({ kind: "loading" }, identity ?? null); + } androidDeviceReplayedAt = null; deviceRegistrationGeneration++; activeDeviceRegistration = null; @@ -201,6 +209,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); @@ -737,6 +746,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 { @@ -813,6 +834,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); + widgetSnapshotReads = 0; } export const unregisterAgentAwarenessDeviceForCurrentUser: ( @@ -933,13 +955,43 @@ 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 generation = deviceRegistrationGeneration; + const isCurrentAccount = () => + relayTokenProvider !== null && + relayTokenProviderIdentity === identity && + deviceRegistrationGeneration === generation; + 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(), @@ -959,6 +1011,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) => { @@ -972,7 +1025,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) => @@ -981,10 +1039,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>, @@ -1025,6 +1087,11 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn( } } + if (hadActivities) { + yield* refreshWidget; + if (!isCurrentAccount()) return; + } + const registrationResults = yield* Effect.forEach(activities, (activity) => registerLiveActivityPushToken({ activity }).pipe( Effect.map((registered) => !registered), diff --git a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs new file mode 100644 index 0000000000000..d0714da1394b9 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs @@ -0,0 +1,201 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeVM from "node:vm"; +import { expect, it } from "vite-plus/test"; + +const require = NodeModule.createRequire(import.meta.url); +const { getConfig } = require("expo/config"); + +it("embeds an Agent Activity Home Screen layout and initial data independently of Live Activities", () => { + const projectRoot = NodePath.resolve(import.meta.dirname, "../.."); + const { exp } = getConfig(projectRoot, { skipSDKVersionRequirement: true }); + const plugin = exp.plugins.find((entry) => Array.isArray(entry) && entry[0] === "expo-widgets"); + const widget = plugin[1].widgets.find((entry) => entry.name === "AgentActivity"); + expect(widget.ios.initialLayout).toBeDefined(); + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-activity-layout-")); + try { + const configPath = NodePath.join(directory, "config.json"); + const outputPath = NodePath.join(directory, "registry.json"); + NodeFS.writeFileSync( + configPath, + JSON.stringify({ widgets: [{ name: widget.name, initialLayout: widget.ios.initialLayout }] }), + ); + NodeChildProcess.execFileSync( + process.execPath, + [ + require.resolve("expo-widgets/scripts/build-layout-registry.mjs"), + projectRoot, + "ios", + outputPath, + configPath, + ], + { cwd: projectRoot, stdio: "pipe" }, + ); + const { widgets } = JSON.parse(NodeFS.readFileSync(outputPath, "utf8")); + expect(widgets.AgentActivity.initialProps).toMatchObject({ activeCount: 0, activities: [] }); + const runtimePath = NodePath.join(directory, "runtime.js"); + NodeChildProcess.execFileSync( + process.execPath, + [require.resolve("expo-widgets/scripts/build-bundle.mjs"), projectRoot, "ios", runtimePath], + { cwd: projectRoot, stdio: "pipe" }, + ); + const context = NodeVM.createContext({ console, process: { env: {} } }); + NodeVM.runInContext("Date.now = () => 2000", context); + NodeVM.runInContext(NodeFS.readFileSync(runtimePath, "utf8"), context); + context.__expoWidgetLayout = NodeVM.runInContext(`(${widgets.AgentActivity.layout})`, context); + const props = { + ...widgets.AgentActivity.initialProps, + activeCount: 1, + freshUntil: 3_000, + source: "snapshot", + baseUrl: "t3code-dev:///", + activities: [ + { + environmentId: "environment", + threadId: "thread", + threadTitle: "Review the task", + projectTitle: "Project", + status: "Input", + phase: "waiting_for_input", + deepLink: "/threads/environment/mcp%3Atask", + }, + ], + }; + for (const widgetFamily of widget.ios.supportedFamilies) { + const environment = { widgetFamily, colorScheme: "dark", widgetRenderingMode: "fullColor" }; + const initial = JSON.stringify( + context.__expoWidgetRender(widgets.AgentActivity.initialProps, environment), + ); + expect(initial).toContain("Open T3 Code"); + const active = JSON.stringify(context.__expoWidgetRender(props, environment)); + expect(active).toContain("1 active"); + expect(active).toContain("Review the task"); + expect(active).toContain("Input"); + expect(active).toContain("t3code-dev:///threads/environment/mcp%3Atask"); + for (const [source, subtitle] of [ + ["loading", "Loading agents…"], + ["signed-out", "Sign in to T3 Connect to see agents"], + ["unavailable", "Couldn't reach T3 Connect"], + ["snapshot", "No active agents"], + ]) { + const empty = JSON.stringify( + context.__expoWidgetRender( + { activeCount: 0, activities: [], source, subtitle }, + environment, + ), + ); + expect(empty).toContain(subtitle); + expect(empty).not.toContain("Review the task"); + } + const expired = JSON.stringify( + context.__expoWidgetRender({ ...props, freshUntil: 1_000 }, environment), + ); + expect(expired).toContain("Out of date"); + expect(expired).not.toContain("Input"); + const scheduled = JSON.stringify( + context.__expoWidgetRender(props, { ...environment, timestamp: 3_000 }), + ); + expect(scheduled).toContain("Out of date"); + expect(scheduled).not.toContain("Input"); + } + const aggregate = { + ...props, + activeCount: 10, + activities: Array.from({ length: 5 }, (_, index) => ({ + ...props.activities[0], + threadId: `thread-${index}`, + threadTitle: `Relay task ${index + 1}`, + deepLink: `/threads/environment/task%3A${index + 1}`, + phase: "running", + status: "Working", + })), + }; + for (const widgetFamily of ["systemSmall", "systemMedium", "systemLarge"]) { + const supplemented = JSON.stringify( + context.__expoWidgetRender( + { + ...aggregate, + supplementalActivities: [ + aggregate.activities[0], + ...Array.from({ length: 7 }, (_, i) => ({ + ...props.activities[0], + threadId: `local-${i}`, + threadTitle: `Local task ${i + 1}`, + phase: "completed", + status: "Done", + deepLink: `/threads/environment/local%3A${i + 1}`, + })), + ], + }, + { widgetFamily, colorScheme: "light", widgetRenderingMode: "fullColor" }, + ), + ); + expect(supplemented).toContain("10 active"); + if (widgetFamily === "systemLarge") { + expect(supplemented).toContain("Local task 2"); + expect(supplemented).not.toContain("Local task 3"); + expect(supplemented).toContain("t3code-dev:///threads/environment/local%3A2"); + } else { + expect(supplemented).not.toContain("Local task"); + } + } + const localOnly = JSON.stringify( + context.__expoWidgetRender( + { + ...props, + activeCount: 0, + activities: [], + supplementalActivities: [props.activities[0]], + }, + { widgetFamily: "systemLarge", colorScheme: "light", widgetRenderingMode: "fullColor" }, + ), + ); + expect(localOnly).toContain("Review the task"); + expect(localOnly).toContain("0 active"); + for (const [widgetFamily, dynamicTypeSize, visibleRows] of [ + ["systemMedium", "large", 5], + ["systemMedium", "xxLarge", 4], + ["systemMedium", "xxxLarge", 3], + ["systemMedium", "accessibility5", 1], + ["systemLarge", "large", 5], + ["systemLarge", "xLarge", 4], + ["systemLarge", "xxxLarge", 3], + ["systemLarge", "accessibility5", 2], + ]) { + const aggregateLayout = JSON.stringify( + context.__expoWidgetRender(aggregate, { + widgetFamily, + dynamicTypeSize, + colorScheme: "light", + widgetRenderingMode: "fullColor", + }), + ); + expect(aggregateLayout).toContain(`Relay task ${visibleRows}`); + if (visibleRows < 5) { + expect(aggregateLayout).not.toContain(`Relay task ${visibleRows + 1}`); + } + expect(aggregateLayout).toContain("10 active"); + expect(aggregateLayout).toContain(`t3code-dev:///threads/environment/task%3A${visibleRows}`); + expect(aggregateLayout).toContain("LinkView"); + const waitingLayout = JSON.stringify( + context.__expoWidgetRender( + { + ...aggregate, + activities: aggregate.activities.map((row, index) => + index === 0 ? { ...row, phase: "stale", status: "Waiting" } : row, + ), + }, + { widgetFamily, dynamicTypeSize, colorScheme: "light", widgetRenderingMode: "fullColor" }, + ), + ); + expect(waitingLayout).toContain("Waiting"); + expect(waitingLayout).toContain("10 active"); + expect(waitingLayout).not.toContain("Out of date"); + } + } finally { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx new file mode 100644 index 0000000000000..029f50eca7f68 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -0,0 +1,298 @@ +import { Divider, HStack, Image, Link, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { + fixedSize, + font, + foregroundStyle, + frame, + lineLimit, + layoutPriority, + resizable, + widgetURL, +} from "@expo/ui/swift-ui/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; + +import type { AgentActivityRowProps } from "./AgentActivity"; + +export interface AgentActivityWidgetProps { + readonly activeCount: number; + readonly activities: ReadonlyArray< + Pick< + AgentActivityRowProps, + | "environmentId" + | "threadId" + | "projectTitle" + | "threadTitle" + | "phase" + | "status" + | "deepLink" + > + >; + readonly supplementalActivities?: AgentActivityWidgetProps["activities"]; + readonly baseUrl?: string; + readonly freshUntil?: number; + readonly updatedAt?: string; + readonly subtitle?: string; + readonly source?: "snapshot" | "loading" | "signed-out" | "unavailable"; + readonly accountId?: string; +} + +function AgentActivityWidget(props: AgentActivityWidgetProps, environment: WidgetEnvironment) { + "widget"; + const now = Math.max(environment.date?.getTime() ?? 0, Date.now()); + const expired = (props.freshUntil ?? Infinity) <= now; + const large = environment.widgetFamily === "systemLarge"; + const activityRows = [...(props.activities ?? [])]; + if (large && props.source === "snapshot") { + for (const row of props.supplementalActivities ?? []) { + if ( + !activityRows.some( + (existing) => + existing.environmentId === row.environmentId && existing.threadId === row.threadId, + ) + ) { + activityRows.push(row); + } + } + } + const rows = activityRows.map((row) => + expired && row.phase !== "completed" && row.phase !== "failed" + ? { ...row, phase: "stale" as const, status: "Out of date" } + : row, + ); + const outOfDate = expired && rows.some((row) => row.phase === "stale"); + const activeCount = props.activeCount ?? 0; + const accessory = environment.widgetFamily === "accessoryRectangular"; + const small = environment.widgetFamily === "systemSmall"; + const listLayout = !small && !accessory; + const textSize = environment.dynamicTypeSize ?? "large"; + const accessibility = textSize.startsWith("accessibility"); + const enlarged = textSize === "xxxLarge"; + const rowLimit = large + ? accessibility + ? 2 + : enlarged + ? 3 + : textSize === "xLarge" || textSize === "xxLarge" + ? 4 + : 7 + : accessibility + ? 1 + : enlarged + ? 3 + : textSize === "xLarge" || textSize === "xxLarge" + ? 4 + : 5; + const hero = rows[0]; + const showRows = listLayout && rows.length > 0; + const monochrome = + environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced; + const statusColor = (row: AgentActivityWidgetProps["activities"][number]) => { + if (row.phase === "stale") return "secondary"; + if (monochrome) return "primary"; + const dark = environment.colorScheme === "dark"; + switch (row.phase) { + case "waiting_for_approval": + return dark ? "#FCD34D" : "#B45309"; + case "waiting_for_input": + return dark ? "#C4B5FD" : "#6D28D9"; + case "completed": + return dark ? "#6EE7B7" : "#047857"; + case "failed": + return dark ? "#FCA5A5" : "#B91C1C"; + default: + return dark ? "#7DD3FC" : "#0369A1"; + } + }; + const statusSymbols = { + starting: "arrow.trianglehead.2.clockwise.rotate.90", + running: "arrow.trianglehead.2.clockwise.rotate.90", + waiting_for_approval: "hand.raised.circle", + waiting_for_input: "questionmark.circle", + completed: "checkmark.circle.fill", + failed: "exclamationmark.circle.fill", + stale: "clock", + } as const; + const renderStatus = ( + row: AgentActivityWidgetProps["activities"][number], + horizontal = false, + ) => { + const color = statusColor(row); + const text = ( + + {row.status} + + ); + return horizontal ? ( + text + ) : ( + + + + + {text} + + ); + }; + + return ( + + {showRows ? ( + rows.slice(0, rowLimit).flatMap((row, index) => { + const content = ( + + {!accessibility ? ( + + ) : null} + + + {row.threadTitle} + + {large ? ( + + {row.projectTitle} + + ) : null} + + + {renderStatus(row, true)} + + ); + const linked = props.baseUrl ? ( + + {content} + + ) : ( + content + ); + return large && index > 0 && index === (props.activities?.length ?? 0) + ? [, linked] + : [linked]; + }) + ) : hero ? ( + + + {hero.threadTitle} + + {renderStatus(hero)} + {!accessory ? ( + + {hero.projectTitle} + + ) : null} + + ) : ( + + + {props.source === "loading" + ? "Loading activity" + : props.source === "unavailable" + ? "Couldn’t refresh" + : props.source === "signed-out" + ? "Connect your account" + : props.source === "snapshot" + ? "Ready when you are" + : "Open T3 Code"} + + {!(listLayout && accessibility && !large) ? ( + + {props.subtitle ?? "Load agent activity"} + + ) : null} + + )} + {!accessory && !(showRows && !large) ? : null} + + + + {outOfDate + ? "Out of date" + : activeCount > 0 || (large && (props.supplementalActivities?.length ?? 0) > 0) + ? `${activeCount} active` + : rows.some((row) => row.phase === "failed") + ? "Failed" + : rows.length > 0 + ? "Done" + : "Agents"} + + + + ); +} + +export default createWidget("AgentActivity", AgentActivityWidget, { + activeCount: 0, + activities: [], +}); diff --git a/apps/mobile/src/widgets/agentActivityWidgetThreads.test.ts b/apps/mobile/src/widgets/agentActivityWidgetThreads.test.ts new file mode 100644 index 0000000000000..cffeb4478dd9d --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityWidgetThreads.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from "vite-plus/test"; +import { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; +import { makeThreadShellFixture } from "../test-fixtures"; +import { buildWidgetUnsettledRows } from "./agentActivityWidgetThreads"; + +it("fills from the app's Unsettled list, excluding relay duplicates and hidden threads", () => { + const thread = makeThreadShellFixture({ + id: ThreadId.make("local:done"), + title: "Review the result", + runtime: null, + }); + const other = { + ...thread, + environmentId: EnvironmentId.make("other"), + pinnedAt: "2026-01-01T00:00:00Z", + }; + const rows = buildWidgetUnsettledRows({ + threads: [ + thread, + other, + { ...thread, id: ThreadId.make("settled"), settledOverride: "settled" }, + { ...thread, id: ThreadId.make("archived"), archivedAt: "2026-01-01T00:00:00Z" }, + { ...thread, id: ThreadId.make("deleted"), deletedAt: "2026-01-01T00:00:00Z" }, + { ...thread, id: ThreadId.make("snoozed"), snoozedUntil: "2027-01-01T00:00:00Z" }, + ...Array.from({ length: 8 }, (_, i) => ({ + ...thread, + id: ThreadId.make(`extra-${i}`), + ...(i === 0 + ? { + pinnedAt: "2026-01-01T00:00:00Z", + latestRun: { + runId: RunId.make("done"), + status: "completed" as const, + requestedAt: null, + startedAt: null, + completedAt: "2026-01-01T00:00:00Z", + assistantMessageId: null, + }, + } + : {}), + })), + ], + projects: [{ environmentId: other.environmentId, id: other.projectId, title: "Other project" }], + excluded: [ + { + environmentId: thread.environmentId, + threadId: thread.id, + projectTitle: "Project", + threadTitle: thread.title, + phase: "running", + status: "Working", + deepLink: "/", + }, + ], + now: "2026-11-03T00:00:00Z", + settlementEnvironmentIds: new Set([thread.environmentId, other.environmentId]), + queuedThreadKeys: new Set(), + }); + expect(rows).toHaveLength(7); + expect(rows).toContainEqual( + expect.objectContaining({ threadId: "extra-0", phase: "completed", status: "Done" }), + ); + for (const id of ["settled", "archived", "deleted", "snoozed"]) { + expect(rows.map((row) => row.threadId)).not.toContain(id); + } + expect(rows.filter((row) => row.threadId === thread.id)).toEqual([ + expect.objectContaining({ + environmentId: other.environmentId, + projectTitle: "Other project", + status: "Ready", + deepLink: "/threads/other/local%3Adone", + }), + ]); +}); diff --git a/apps/mobile/src/widgets/agentActivityWidgetThreads.ts b/apps/mobile/src/widgets/agentActivityWidgetThreads.ts new file mode 100644 index 0000000000000..ab89cfa32117e --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityWidgetThreads.ts @@ -0,0 +1,80 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/models"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + buildThreadListV2Items, + resolveThreadListV2Status, +} from "../features/threads/threadListV2"; +import type { AgentActivityWidgetProps } from "./AgentActivityWidget"; + +type Row = AgentActivityWidgetProps["activities"][number]; + +function activityStatus(thread: EnvironmentThreadShell): Pick { + switch (resolveThreadListV2Status(thread)) { + case "approval": + return { phase: "waiting_for_approval", status: "Approval" }; + case "input": + return { phase: "waiting_for_input", status: "Input" }; + case "request": + return { phase: "waiting_for_input", status: "Request" }; + case "working": + return { phase: "running", status: "Working" }; + case "failed": + return { phase: "failed", status: "Failed" }; + case "limited": + return { phase: "failed", status: "Limited" }; + case "waiting": + return { phase: "stale", status: "Waiting" }; + case "ready": + return thread.latestRun?.status === "completed" + ? { phase: "completed", status: "Done" } + : { phase: "stale", status: "Ready" }; + } +} + +export function buildWidgetUnsettledRows(input: { + readonly threads: ReadonlyArray; + readonly projects: ReadonlyArray>; + readonly excluded: AgentActivityWidgetProps["activities"]; + readonly now: string; + readonly settlementEnvironmentIds: ReadonlySet; + readonly queuedThreadKeys: ReadonlySet; +}): AgentActivityWidgetProps["activities"] { + const seen = new Set( + input.excluded.map((row) => JSON.stringify([row.environmentId, row.threadId])), + ); + const projects = new Map( + input.projects.map((p) => [JSON.stringify([p.environmentId, p.id]), p.title]), + ); + const layout = buildThreadListV2Items({ + threads: input.threads.filter((thread) => thread.deletedAt === null), + environmentId: null, + searchQuery: "", + now: input.now, + settlementEnvironmentIds: input.settlementEnvironmentIds, + snoozeEnvironmentIds: input.settlementEnvironmentIds, + queuedThreadKeys: input.queuedThreadKeys, + settledLimit: 0, + }); + return layout.items + .filter(({ variant, snoozed }) => variant === "card" && !snoozed) + .flatMap(({ thread }) => { + const key = JSON.stringify([thread.environmentId, thread.id]); + if (seen.has(key)) return []; + seen.add(key); + return [ + { + environmentId: thread.environmentId, + threadId: thread.id, + projectTitle: + projects.get(JSON.stringify([thread.environmentId, thread.projectId])) ?? "Project", + threadTitle: thread.title, + ...activityStatus(thread), + deepLink: `/threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.id)}`, + }, + ]; + }) + .slice(0, 7); +} diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts new file mode 100644 index 0000000000000..4a51ac8ec67d7 --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -0,0 +1,263 @@ +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 type { AgentActivityWidgetProps } from "./AgentActivityWidget"; +import { publishAgentActivityWidget } from "./publishAgentActivityWidget.ios"; + +const nativeWidget = vi.hoisted(() => ({ + getTimeline: vi.fn(), + updateTimeline: vi.fn(), + readUnsettled: vi.fn(() => [] as AgentActivityWidgetProps["activities"]), +})); +vi.mock("./readWidgetUnsettledRows.ios", () => ({ + readWidgetUnsettledRows: nativeWidget.readUnsettled, +})); +vi.mock("expo", () => ({ requireOptionalNativeModule: () => ({}) })); +vi.mock("expo-linking", () => ({ createURL: () => "t3code-dev:///" })); +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(); + const supplemental = [{ ...row("completed", "Done"), threadId: "local" }]; + nativeWidget.readUnsettled.mockReturnValueOnce(supplemental); + await publishAgentActivityWidget( + { kind: "snapshot", aggregate: aggregate([row("running", "Working")]) }, + "user-a", + ); + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ + props: expect.objectContaining({ + accountId: "user-a", + supplementalActivities: supplemental, + activeCount: 1, + }), + }), + expect.objectContaining({ + props: expect.objectContaining({ + accountId: "user-a", + supplementalActivities: supplemental, + activeCount: 1, + }), + }), + ]); + + await publishAgentActivityWidget({ kind: "signed-out" }, null); + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ + props: expect.objectContaining({ activities: [], supplementalActivities: [] }), + }), + ]); + expect(nativeWidget.updateTimeline.mock.lastCall?.[0][0].props).not.toHaveProperty("accountId"); + }); + + it("discards obsolete account publications when sign-out clearing fails after a delayed read", 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 obsolete = publishAgentActivityWidget( + { kind: "snapshot", aggregate: aggregate([row("running", "Working")]) }, + "user-a", + ); + nativeWidget.updateTimeline.mockImplementationOnce(() => { + throw new Error("Native clearing failed"); + }); + const signOut = publishAgentActivityWidget({ kind: "signed-out" }, null); + release([]); + await Promise.all([loading, obsolete]); + await expect(signOut).rejects.toThrow("Native clearing failed"); + + expect(nativeWidget.updateTimeline).toHaveBeenCalledTimes(1); + + 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", + supplementalActivities: entry.props.activities, + }, + })); + nativeWidget.getTimeline.mockResolvedValue(cached); + nativeWidget.updateTimeline.mockClear(); + + await publishAgentActivityWidget({ kind: "loading" }, "user-a"); + await publishAgentActivityWidget({ kind: "unavailable" }, "user-a"); + + const refreshed = cached.map((entry) => ({ + ...entry, + props: { ...entry.props, supplementalActivities: [] }, + })); + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith(refreshed); + nativeWidget.getTimeline.mockResolvedValue(refreshed); + nativeWidget.updateTimeline.mockClear(); + + await Promise.all([ + publishAgentActivityWidget( + { kind: "snapshot", aggregate: aggregate([row("running", "Working")]) }, + "user-a", + ), + publishAgentActivityWidget({ kind: "loading" }, "user-a"), + publishAgentActivityWidget({ kind: "unavailable" }, "user-a"), + ]); + expect(nativeWidget.updateTimeline).toHaveBeenCalledTimes(1); + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ props: expect.objectContaining({ accountId: "user-a" }) }), + expect.objectContaining({ props: expect.objectContaining({ accountId: "user-a" }) }), + ]); + }); + + 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: [] }); + const local = agentActivityWidgetTimeline({ kind: "snapshot", aggregate: null }, now, [ + row("stale", "Ready"), + ]); + expect(local).toHaveLength(2); + expect(local[0]?.props.activeCount).toBe(0); + expect(local[1]?.date.getTime()).toBe(now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS); + expect(local[1]?.props.supplementalActivities).toMatchObject([{ status: "Out of date" }]); + }); + + 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..53f31d6fe95f7 --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts @@ -0,0 +1,77 @@ +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; + +import type { AgentActivityWidgetProps } from "./AgentActivityWidget"; +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, + supplementalActivities: AgentActivityWidgetProps["activities"] = [], +) { + const updatedAt = new Date(now).toISOString(); + if (source.kind !== "snapshot" || (!source.aggregate && supplementalActivities.length === 0)) { + const props: AgentActivityProps & Pick = { + 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: [], + supplementalActivities: [], + }; + return [{ date: new Date(now), props }]; + } + + const { aggregate } = source; + const props: AgentActivityProps & { + readonly freshUntil: number; + readonly supplementalActivities: AgentActivityWidgetProps["activities"]; + } = { + title: aggregate?.title ?? "T3 Code", + subtitle: aggregate?.subtitle ?? "No active agents", + activeCount: aggregate?.activeCount ?? 0, + updatedAt: aggregate?.updatedAt ?? updatedAt, + activities: aggregate?.activities ?? [], + supplementalActivities, + freshUntil: now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, + }; + const hasInFlightRows = [...props.activities, ...supplementalActivities].some( + (row) => row.phase !== "completed" && row.phase !== "failed", + ); + if (!hasInFlightRows) { + return [{ date: new Date(now), props }]; + } + const staleProps = { + ...props, + supplementalActivities: supplementalActivities.map((row) => + row.phase === "completed" || row.phase === "failed" + ? row + : { ...row, phase: "stale" as const, status: "Out of date" }, + ), + activities: props.activities.map((row) => + row.phase === "completed" || row.phase === "failed" + ? row + : { ...row, phase: "stale" as const, 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..4168f76b636aa --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts @@ -0,0 +1,79 @@ +import { requireOptionalNativeModule } from "expo"; +import * as Linking from "expo-linking"; + +import { + agentActivityWidgetTimeline, + type AgentActivityWidgetSource, +} from "./agentActivityWidgetTimeline"; + +let publication = Promise.resolve(); +let latestAccountId: string | null = null; +let accountGeneration = 0; + +export function publishAgentActivityWidget( + source: AgentActivityWidgetSource, + accountId: string | null, +) { + if (latestAccountId !== accountId) { + latestAccountId = accountId; + accountGeneration++; + } + const generation = accountGeneration; + const next = publication.then(async () => { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const { default: widget } = await import("./AgentActivityWidget"); + if (generation !== accountGeneration) return; + if (accountId && (source.kind === "loading" || source.kind === "unavailable")) { + const cached = await widget.getTimeline().catch(() => []); + if (generation !== accountGeneration) return; + if ( + cached.some( + ({ props }) => + props?.accountId === accountId && + (props.activities?.length || props.supplementalActivities?.length), + ) + ) { + const { readWidgetUnsettledRows } = await import("./readWidgetUnsettledRows.ios"); + if (generation !== accountGeneration) return; + const supplementalActivities = readWidgetUnsettledRows(cached[0]?.props.activities ?? []); + if ( + cached.some( + ({ props }) => + JSON.stringify(props.supplementalActivities ?? []) !== + JSON.stringify(supplementalActivities), + ) + ) { + widget.updateTimeline( + cached.map((entry) => ({ + ...entry, + props: { ...entry.props, supplementalActivities }, + })), + ); + } + return; + } + } + const supplementalActivities = + source.kind === "snapshot" && accountId + ? (await import("./readWidgetUnsettledRows.ios")).readWidgetUnsettledRows( + source.aggregate?.activities ?? [], + ) + : []; + if (generation !== accountGeneration) return; + widget.updateTimeline( + agentActivityWidgetTimeline(source, Date.now(), supplementalActivities).map((entry) => ({ + ...entry, + props: { + ...entry.props, + source: source.kind, + supplementalActivities: entry.props.supplementalActivities ?? [], + baseUrl: Linking.createURL("/"), + ...(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/apps/mobile/src/widgets/readWidgetUnsettledRows.ios.ts b/apps/mobile/src/widgets/readWidgetUnsettledRows.ios.ts new file mode 100644 index 0000000000000..ad25cf39f282d --- /dev/null +++ b/apps/mobile/src/widgets/readWidgetUnsettledRows.ios.ts @@ -0,0 +1,22 @@ +import { appAtomRegistry } from "../state/atom-registry"; +import { environmentProjects } from "../state/projects"; +import { environmentServerConfigsAtom } from "../state/server"; +import { threadOutboxManager } from "../state/thread-outbox"; +import { environmentThreadShells } from "../state/threads"; +import type { AgentActivityWidgetProps } from "./AgentActivityWidget"; +import { buildWidgetUnsettledRows } from "./agentActivityWidgetThreads"; + +export function readWidgetUnsettledRows(excluded: AgentActivityWidgetProps["activities"]) { + return buildWidgetUnsettledRows({ + threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + projects: appAtomRegistry.get(environmentProjects.projectsAtom), + settlementEnvironmentIds: new Set(appAtomRegistry.get(environmentServerConfigsAtom).keys()), + queuedThreadKeys: new Set( + Object.entries(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)) + .filter(([, messages]) => messages.length > 0) + .map(([key]) => key), + ), + now: new Date().toISOString(), + excluded, + }); +} diff --git a/docs/user/usage.md b/docs/user/usage.md index d7e0dad901bef..a9c6811ede0a9 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -187,6 +187,14 @@ Claude quotas. Tap it to open **Usage → Limits**; on Android this works while the background, otherwise open the app from the launcher. On iOS, use **Edit Widget** to choose Session, Weekly, or both for each provider. Reopen T3 to refresh expired readings. +## Agent Activity widget + +Add **Agent Activity** from the iOS widget gallery to see agents from your T3 Connect account. +The large widget fills spare rows with Unsettled threads loaded in the app. Its active count +still comes from T3 Connect. Open T3 Code to refresh both sources. After 10 minutes, +unfinished tasks show **Out of date**. +Live Activities continue to receive updates separately. + ## Keyboard shortcuts On web and desktop, open Usage from the command palette. While on Usage, diff --git a/patches/expo-widgets@58.0.20.patch b/patches/expo-widgets@58.0.20.patch index 21252dcd848aa..1961c5ec014df 100644 --- a/patches/expo-widgets@58.0.20.patch +++ b/patches/expo-widgets@58.0.20.patch @@ -1,5 +1,5 @@ diff --git a/build/ExpoWidgets.js b/build/ExpoWidgets.js -index 85f3fbd..05b8dd8 100644 +index 85f3fbdddade637ed6a96fd0c532a22c675d45c5..05b8dd87f58bfc4acf66652ea04cec777f9be7ca 100644 --- a/build/ExpoWidgets.js +++ b/build/ExpoWidgets.js @@ -34,6 +34,7 @@ class LiveActivityFactoryStub { @@ -11,7 +11,7 @@ index 85f3fbd..05b8dd8 100644 class CustomNotificationStub { getId() { diff --git a/build/Widgets.d.ts b/build/Widgets.d.ts -index fb37d5d..47a97af 100644 +index fb37d5dd61d34768907ac397d4a6ddfce675bccb..47a97af1b8138caa10ca6f67bad6798ef1ea4a72 100644 --- a/build/Widgets.d.ts +++ b/build/Widgets.d.ts @@ -100,6 +100,8 @@ export declare class LiveActivityFactory { @@ -24,7 +24,7 @@ index fb37d5d..47a97af 100644 /** * Creates a dismissal policy that removes the Live Activity at the specified time within a four-hour window. diff --git a/build/Widgets.js b/build/Widgets.js -index cc1efce..3ce8897 100644 +index cc1efce0ce87337ac55d41a66659048c26698870..3ce8897445a1f4d4be3d4ae69ef6231f1ed604f4 100644 --- a/build/Widgets.js +++ b/build/Widgets.js @@ -134,6 +134,9 @@ import ExpoWidgetsModule from './ExpoWidgets'; @@ -38,10 +38,19 @@ index cc1efce..3ce8897 100644 /** * Creates a dismissal policy that removes the Live Activity at the specified time within a four-hour window. diff --git a/build/Widgets.types.d.ts b/build/Widgets.types.d.ts -index 4c474bb..dbfb8b1 100644 +index 4c474bb2730376c8e182159c096a6031da4864de..6a824208125eaa6a4b9c35ea0490a57181fd9967 100644 --- a/build/Widgets.types.d.ts +++ b/build/Widgets.types.d.ts -@@ -98,6 +98,10 @@ export type LiveActivityEnvironment = { +@@ -50,6 +50,8 @@ export type WidgetEnvironment = { + * The color scheme of the widget's environment. + */ + colorScheme?: 'light' | 'dark'; ++ /** The system Dynamic Type size, for adapting the widget's layout. */ ++ dynamicTypeSize?: 'xSmall' | 'small' | 'medium' | 'large' | 'xLarge' | 'xxLarge' | 'xxxLarge' | 'accessibility1' | 'accessibility2' | 'accessibility3' | 'accessibility4' | 'accessibility5'; + /** + * A Boolean value that indicates whether the display or environment currently requires reduced luminance. + * +@@ -98,6 +100,10 @@ export type LiveActivityEnvironment = { * @platform ios */ colorScheme: 'light' | 'dark'; @@ -52,7 +61,7 @@ index 4c474bb..dbfb8b1 100644 /** * Whether the activity is displayed in a context with reduced luminance. * @platform iOS 16+ -@@ -307,6 +311,7 @@ export declare class NativeLiveActivityFactory extends SharedObject { +@@ -307,6 +313,7 @@ export declare class NativeLiveActivityFactory extends SharedObject { constructor(name: string, layout: string); start(props?: string, url?: string, staleDate?: number): NativeLiveActivity; getInstances(): NativeLiveActivity[]; @@ -61,10 +70,10 @@ index 4c474bb..dbfb8b1 100644 export declare class NativeLiveActivity extends SharedObject { getId(): string; diff --git a/ios/LiveActivityFactory.swift b/ios/LiveActivityFactory.swift -index 29e4f15..629e547 100644 +index 29e4f1566d188665e301d32bc1ad85640f83e68c..629e547d8604757e89dd571eab7ad43f7c649cdb 100644 --- a/ios/LiveActivityFactory.swift +++ b/ios/LiveActivityFactory.swift -@@ -14,7 +14,16 @@ final class LiveActivityFactory: SharedObject { +@@ -14,11 +14,23 @@ final class LiveActivityFactory: SharedObject { WidgetsStorage.set(layout, forKey: "__expo_widgets_live_activity_\(name)_layout") } @@ -81,7 +90,7 @@ index 29e4f15..629e547 100644 guard ActivityAuthorizationInfo().areActivitiesEnabled else { throw LiveActivitiesNotSupportedException() } -@@ -22,3 +31,6 @@ final class LiveActivityFactory: SharedObject { + + // A replacement started beside an ended card would leave both visible. + dismissEnded() + @@ -89,10 +98,40 @@ index 29e4f15..629e547 100644 let initialState = LiveActivityAttributes.ContentState(name: name, props: props) let activity = try Activity.request( diff --git a/ios/Widgets/Utils.swift b/ios/Widgets/Utils.swift -index 76b2199..6cb52c3 100644 +index 76b2199c2d3f879463fed5d2bd49038b0428a2e7..52580b107217eff32adb64e105a7133b3b0b1b11 100644 --- a/ios/Widgets/Utils.swift +++ b/ios/Widgets/Utils.swift -@@ -125,6 +125,10 @@ func getLiveActivityEnvironment(for environment: EnvironmentValues, in context: +@@ -93,10 +93,29 @@ func getLiveActivityNodes(forName name: String, props: String? = nil, environmen + } + } + ++private func dynamicTypeSizeName(_ size: DynamicTypeSize) -> String { ++ switch size { ++ case .xSmall: return "xSmall" ++ case .small: return "small" ++ case .medium: return "medium" ++ case .large: return "large" ++ case .xLarge: return "xLarge" ++ case .xxLarge: return "xxLarge" ++ case .xxxLarge: return "xxxLarge" ++ case .accessibility1: return "accessibility1" ++ case .accessibility2: return "accessibility2" ++ case .accessibility3: return "accessibility3" ++ case .accessibility4: return "accessibility4" ++ case .accessibility5: return "accessibility5" ++ default: return "large" ++ } ++} ++ + public func getWidgetEnvironment(environment: EnvironmentValues) -> [String: Any] { + var env: [String: Any] = [ + "showsContainerBackground": environment.showsWidgetContainerBackground, + "widgetFamily": environment.widgetFamily.description, ++ "dynamicTypeSize": dynamicTypeSizeName(environment.dynamicTypeSize), + "colorScheme": "\(environment.colorScheme)", + "isLuminanceReduced": environment.isLuminanceReduced, + "widgetRenderingMode": environment.widgetRenderingMode.description, +@@ -125,6 +144,10 @@ func getLiveActivityEnvironment(for environment: EnvironmentValues, in context: "isStale": context.isStale ] @@ -104,7 +143,7 @@ index 76b2199..6cb52c3 100644 env["isActivityUpdateReduced"] = environment.isActivityUpdateReduced env["activityFamily"] = "\(environment.activityFamily)" diff --git a/ios/Widgets/WidgetLiveActivity.swift b/ios/Widgets/WidgetLiveActivity.swift -index 4d135c4..dae4744 100644 +index 4d135c4e4eb1f846315654ed9d835a935d120d42..dae47445b27516fa2464a25b68bddc8f6dec3e1e 100644 --- a/ios/Widgets/WidgetLiveActivity.swift +++ b/ios/Widgets/WidgetLiveActivity.swift @@ -19,53 +19,41 @@ struct LiveActivityAttributes: ActivityAttributes { @@ -207,10 +246,10 @@ index 4d135c4..dae4744 100644 LiveActivityBanner(context: context, nodes: nodes) } else if let node = nodes["banner"] as? [String: Any] { diff --git a/ios/WidgetsModule.swift b/ios/WidgetsModule.swift -index 285136b..d36eac4 100644 +index 285136b3774e531b35c1a3e832506f93f61654be..d36eac46f435a78afda1724d92d685f3fb19406b 100644 --- a/ios/WidgetsModule.swift +++ b/ios/WidgetsModule.swift -@@ -97,4 +97,8 @@ public final class WidgetsModule: Module { +@@ -97,6 +97,10 @@ public final class WidgetsModule: Module { Function("getInstances") { (liveActivity: LiveActivityFactory) in try liveActivity.getInstances() } @@ -219,21 +258,25 @@ index 285136b..d36eac4 100644 + liveActivity.dismissEnded() + } } + + Class("LiveActivity", LiveActivity.self) { diff --git a/src/ExpoWidgets.ts b/src/ExpoWidgets.ts -index db15000..6863dc9 100644 +index db1500090738cca841ded2967078b8c594bac738..6863dc94a3bb61196920489e3f10f21b77c0cce9 100644 --- a/src/ExpoWidgets.ts +++ b/src/ExpoWidgets.ts -@@ -59,4 +59,5 @@ class LiveActivityFactoryStub { +@@ -59,6 +59,7 @@ class LiveActivityFactoryStub { getInstances(): NativeLiveActivity[] { return []; } + dismissEnded(): void {} } + + class CustomNotificationStub { diff --git a/src/Widgets.ts b/src/Widgets.ts -index 814b312..9818907 100644 +index 814b3120b254aaea5ccefab0ab52804ca48ecaf8..98189070a124481a0f366c86116552ca0945fcd7 100644 --- a/src/Widgets.ts +++ b/src/Widgets.ts -@@ -213,4 +213,9 @@ export class LiveActivityFactory { +@@ -213,6 +213,11 @@ export class LiveActivityFactory { .getInstances() .map((instance) => new LiveActivity(instance)); } @@ -243,11 +286,22 @@ index 814b312..9818907 100644 + this.nativeLiveActivityFactory.dismissEnded(); + } } + + /** diff --git a/src/Widgets.types.ts b/src/Widgets.types.ts -index 690d069..c60dd79 100644 +index 690d0695c726dd4f260ba3aeb4edb82b9a72cd39..d24ea67b99a9cf1fcef383c9460e5130f7875685 100644 --- a/src/Widgets.types.ts +++ b/src/Widgets.types.ts -@@ -112,6 +112,10 @@ export type LiveActivityEnvironment = { +@@ -63,6 +63,8 @@ export type WidgetEnvironment = { + * The color scheme of the widget's environment. + */ + colorScheme?: 'light' | 'dark'; ++ /** The system Dynamic Type size, for adapting the widget's layout. */ ++ dynamicTypeSize?: 'xSmall' | 'small' | 'medium' | 'large' | 'xLarge' | 'xxLarge' | 'xxxLarge' | 'accessibility1' | 'accessibility2' | 'accessibility3' | 'accessibility4' | 'accessibility5'; + /** + * A Boolean value that indicates whether the display or environment currently requires reduced luminance. + * +@@ -112,6 +114,10 @@ export type LiveActivityEnvironment = { * @platform ios */ colorScheme: 'light' | 'dark'; @@ -258,9 +312,11 @@ index 690d069..c60dd79 100644 /** * Whether the activity is displayed in a context with reduced luminance. * @platform iOS 16+ -@@ -337,4 +341,5 @@ export declare class NativeLiveActivityFactory extends SharedObject { +@@ -337,6 +343,7 @@ export declare class NativeLiveActivityFactory extends SharedObject { constructor(name: string, layout: string); start(props?: string, url?: string, staleDate?: number): NativeLiveActivity; getInstances(): NativeLiveActivity[]; + dismissEnded(): void; } + + export declare class NativeLiveActivity extends SharedObject { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa8574ccfee76..e739ea66aaf6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,7 +378,7 @@ patchedDependencies: expo-network@58.0.3: b79a46db8484fa3b5fc9f509fb78b71380219d2865d261c4d82170b5393e8419 expo-sharing@58.0.19: 321a86e1471e34c53f1f2937fbb4304ea3f7b7a78ad9e4d35febc2b9e73e9688 expo-video@58.0.11: e80ec3ef8c4af50a607beea1c0917e71ba679844efdb702b45d17a5f03cf466c - expo-widgets@58.0.20: 8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171 + expo-widgets@58.0.20: 3d2d7b18c777bcc3130641dcae3894b3dd25a04b9915aa1a7c18125272aacec5 mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c mdast-util-to-hast@13.2.1: 57ea46c73aea3c876ab8d351fe0482ddd081dcb4a0610576e399dbe5eb59d2b1 micromark-extension-gfm-table@2.1.1: 9ad23a530e45d9f83cba1da8654fe16d5af8b59d5c73d5c7a7c91fd25b9be1d8 @@ -724,7 +724,7 @@ importers: version: 58.0.6(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6)) expo-widgets: specifier: 58.0.20 - version: 58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.20(patch_hash=3d2d7b18c777bcc3130641dcae3894b3dd25a04b9915aa1a7c18125272aacec5)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) pele: specifier: 'catalog:' version: 1.8.0 @@ -17488,7 +17488,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 58.0.14(ecd3689b2bda1172f8915f190f5c7383) - expo-widgets: 58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + expo-widgets: 58.0.20(patch_hash=3d2d7b18c777bcc3130641dcae3894b3dd25a04b9915aa1a7c18125272aacec5)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - supports-color @@ -18728,7 +18728,7 @@ snapshots: expo: 58.0.14(ecd3689b2bda1172f8915f190f5c7383) react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) - ? expo-widgets@58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + ? expo-widgets@58.0.20(patch_hash=3d2d7b18c777bcc3130641dcae3894b3dd25a04b9915aa1a7c18125272aacec5)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) : dependencies: '@expo/plist': 0.10.1 '@expo/ui': 58.0.20(patch_hash=64559cb60723e19a8a72256d4c78d1315d937a7af3b32d19860ea596f88736ce)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)