From 625eef14a943bf7a3e6c860a8f91c90ab3e860bb Mon Sep 17 00:00:00 2001 From: Josh Tomaino Date: Tue, 3 Nov 2026 10:33:08 -0500 Subject: [PATCH 1/8] fix(mobile): agent activity home screen widget shows active tasks --- apps/mobile/app.config.ts | 5 +- apps/mobile/src/App.tsx | 2 + .../widgets/AgentActivityCoordinator.ios.tsx | 90 ++++++++++ .../src/widgets/AgentActivityCoordinator.tsx | 3 + .../src/widgets/AgentActivityWidget.test.mjs | 84 ++++++++++ .../src/widgets/AgentActivityWidget.tsx | 156 ++++++++++++++++++ docs/user/usage.md | 6 + 7 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/widgets/AgentActivityCoordinator.ios.tsx create mode 100644 apps/mobile/src/widgets/AgentActivityCoordinator.tsx create mode 100644 apps/mobile/src/widgets/AgentActivityWidget.test.mjs create mode 100644 apps/mobile/src/widgets/AgentActivityWidget.tsx diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e6d388b9a9856..c90e9c2d214f3 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -191,7 +191,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", "accessoryRectangular"], + initialLayout: "./src/widgets/AgentActivityWidget.tsx", + }, }, ], }, diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 225496bb7a407..b667ab81a26a5 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -73,6 +73,7 @@ import { import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; +import { AgentActivityCoordinator } from "./widgets/AgentActivityCoordinator"; import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; @@ -226,6 +227,7 @@ function AppContent() { <> + diff --git a/apps/mobile/src/widgets/AgentActivityCoordinator.ios.tsx b/apps/mobile/src/widgets/AgentActivityCoordinator.ios.tsx new file mode 100644 index 0000000000000..89e9b5b92ce35 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivityCoordinator.ios.tsx @@ -0,0 +1,90 @@ +import { useAtomValue } from "@effect/atom-react"; +import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; +import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections"; +import { Atom } from "effect/reactivity"; +import * as Linking from "expo-linking"; +import { requireOptionalNativeModule } from "expo"; +import { useEffect } from "react"; +import { AppState } from "react-native"; + +import { environmentCatalog } from "../connection/catalog"; +import { environmentShell, environmentSnapshotAtom } from "../state/shell"; +import type { AgentActivityWidgetProps } from "./AgentActivityWidget"; + +const loadWidget = () => import("./AgentActivityWidget"); + +const snapshotAtom = Atom.make((get) => { + const activities: AgentActivityWidgetProps["activities"][number][] = []; + let connected = false; + for (const environmentId of enabledEnvironmentIds(get(environmentCatalog.catalogValueAtom))) { + if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") continue; + connected = true; + const snapshot = get(environmentSnapshotAtom(environmentId)); + if (!snapshot) continue; + const projects = new Map(snapshot.projects.map((project) => [project.id, project])); + for (const thread of snapshot.threads) { + if (thread.archivedAt !== null || thread.deletedAt !== null) continue; + const project = projects.get(thread.projectId); + if (!project) continue; + const state = projectThreadAwarenessV2({ environmentId, project, thread }); + if (!state || state.phase === "completed" || state.phase === "failed") continue; + activities.push({ + environmentId: state.environmentId, + threadId: state.threadId, + projectTitle: state.projectTitle, + threadTitle: state.threadTitle, + phase: state.phase, + status: state.headline, + deepLink: state.deepLink, + }); + } + } + activities.sort((a, b) => { + const priority = (phase: string) => + phase === "waiting_for_approval" || phase === "waiting_for_input" ? 0 : 1; + return ( + priority(a.phase) - priority(b.phase) || + a.environmentId.localeCompare(b.environmentId) || + a.threadId.localeCompare(b.threadId) + ); + }); + return { connected, activeCount: activities.length, activities: activities.slice(0, 3) }; +}).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); + +export function AgentActivityCoordinator() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const snapshot = useAtomValue(snapshotAtom); + const hasEnvironments = !enabledEnvironmentIds(catalog).next().done; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes agent state to the native Home Screen widget + useEffect(() => { + if (!catalog.isReady || !requireOptionalNativeModule("ExpoWidgets")) return; + if (hasEnvironments && !snapshot.connected) return; + let disposed = false; + const publish = () => + void loadWidget() + .then(({ default: widget }) => { + if (disposed || AppState.currentState !== "active") return; + const now = Date.now(); + const { connected, ...activity } = snapshot; + const props = { + ...activity, + expiresAt: connected ? now + 15 * 60_000 : 0, + url: `${Linking.createURL("/")}${snapshot.activities[0]?.deepLink.slice(1) ?? ""}`, + }; + widget.updateTimeline([ + { date: new Date(now), props }, + ...(connected ? [{ date: new Date(props.expiresAt), props }] : []), + ]); + }) + .catch((error: unknown) => console.warn("Could not update agent activity widget", error)); + publish(); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") publish(); + }); + return () => { + disposed = true; + subscription.remove(); + }; + }, [catalog.isReady, hasEnvironments, snapshot]); + return null; +} diff --git a/apps/mobile/src/widgets/AgentActivityCoordinator.tsx b/apps/mobile/src/widgets/AgentActivityCoordinator.tsx new file mode 100644 index 0000000000000..c8a6ab85f892e --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivityCoordinator.tsx @@ -0,0 +1,3 @@ +export function AgentActivityCoordinator() { + return null; +} diff --git a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs new file mode 100644 index 0000000000000..ba2b06867cfa8 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs @@ -0,0 +1,84 @@ +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.layout).toContain("Tap to refresh"); + 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, + expiresAt: 1_000, + activities: [ + { + environmentId: "environment", + threadId: "thread", + threadTitle: "Review the task", + projectTitle: "Project", + status: "Waiting for input", + phase: "waiting_for_input", + }, + ], + }; + for (const widgetFamily of ["systemSmall", "systemMedium", "accessoryRectangular"]) { + const environment = { widgetFamily, colorScheme: "dark", widgetRenderingMode: "fullColor" }; + const initial = JSON.stringify( + context.__expoWidgetRender(widgets.AgentActivity.initialProps, environment), + ); + expect(initial).toContain("Tap to refresh"); + const active = JSON.stringify( + context.__expoWidgetRender({ ...props, expiresAt: 3_000 }, environment), + ); + expect(active).toContain("1 active"); + expect(active).toContain("Review the task"); + expect(active).toContain("Needs input"); + const expired = JSON.stringify(context.__expoWidgetRender(props, environment)); + expect(expired).toContain("Tap to refresh"); + expect(expired).not.toContain("Review the task"); + } + } 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..8ba2d334bdedb --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -0,0 +1,156 @@ +import { HStack, Image, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { + fixedSize, + font, + foregroundStyle, + frame, + lineLimit, + 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 url?: string; + readonly expiresAt?: number; +} + +function AgentActivityWidget(props: AgentActivityWidgetProps, environment: WidgetEnvironment) { + "widget"; + const stale = (props.expiresAt ?? 0) <= Date.now(); + const rows = stale ? [] : (props.activities ?? []); + const activeCount = stale ? 0 : (props.activeCount ?? 0); + const accessory = environment.widgetFamily === "accessoryRectangular"; + const small = environment.widgetFamily === "systemSmall"; + const hero = rows[0]; + const multiple = !small && !accessory && rows.length > 1; + const monochrome = + environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced; + const status = (row: AgentActivityWidgetProps["activities"][number]) => { + switch (row.phase) { + case "waiting_for_approval": + return { label: "Approval needed", color: "#B45309" }; + case "waiting_for_input": + return { label: "Needs input", color: "#6D28D9" }; + case "starting": + return { label: "Starting", color: "#0369A1" }; + case "running": + return { label: "Working", color: "#0369A1" }; + default: + return { label: row.status, color: "primary" }; + } + }; + const renderStatus = (row: AgentActivityWidgetProps["activities"][number]) => { + const presentation = status(row); + const color = monochrome + ? "primary" + : environment.colorScheme === "dark" + ? row.phase === "waiting_for_approval" + ? "#FCD34D" + : row.phase === "waiting_for_input" + ? "#C4B5FD" + : "#7DD3FC" + : presentation.color; + return ( + + + + + + {presentation.label} + + + ); + }; + + return ( + + + + + + + + {stale ? "Out of date" : activeCount > 0 ? `${activeCount} active` : "Agents"} + + + {!multiple && !accessory ? : null} + {multiple ? ( + rows.slice(0, 2).map((row) => ( + + + {row.threadTitle} + + + {renderStatus(row)} + + · {row.projectTitle} + + + + )) + ) : hero ? ( + + + {hero.threadTitle} + + {renderStatus(hero)} + {!accessory ? ( + + {hero.projectTitle} + + ) : null} + + ) : ( + + + {stale ? "Catch up in T3" : "Ready when you are"} + + + {stale ? "Tap to refresh" : "No active agents"} + + + )} + {!accessory ? : null} + + ); +} + +export default createWidget("AgentActivity", AgentActivityWidget, { + activeCount: 0, + activities: [], +}); diff --git a/docs/user/usage.md b/docs/user/usage.md index d25bdba81f00a..5aa5088618e71 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -192,6 +192,12 @@ 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 active agents from your connected +environments. Open T3 Code to refresh the snapshot. After 15 minutes, the widget asks for a refresh. +Live Activities continue to receive updates separately. + ## Keyboard shortcuts On web and desktop, open Usage from the command palette. While on Usage, From 43dd21a70754931c98925158d2105977cf01e9bc Mon Sep 17 00:00:00 2001 From: Josh Tomaino Date: Tue, 3 Nov 2026 11:04:11 -0500 Subject: [PATCH 2/8] fix(mobile): use account-aware snapshots in the home widget Adapt relay publication and account-state tests from [the existing widget PR](https://github.com/pingdotgg/t3code/pull/21163). Retain the revised layout, add gallery expiry and account re-entry guards, and track the embedded layout in the native fingerprint. --- apps/mobile/fingerprint.config.js | 6 +- apps/mobile/src/App.tsx | 2 - .../remoteRegistration.test.ts | 363 +++++++++++++++++- .../agent-awareness/remoteRegistration.ts | 78 +++- .../widgets/AgentActivityCoordinator.ios.tsx | 90 ----- .../src/widgets/AgentActivityCoordinator.tsx | 3 - .../src/widgets/AgentActivityWidget.test.mjs | 38 +- .../src/widgets/AgentActivityWidget.tsx | 77 +++- .../agentActivityWidgetTimeline.test.ts | 197 ++++++++++ .../widgets/agentActivityWidgetTimeline.ts | 62 +++ .../widgets/publishAgentActivityWidget.ios.ts | 39 ++ .../src/widgets/publishAgentActivityWidget.ts | 6 + docs/user/usage.md | 4 +- 13 files changed, 829 insertions(+), 136 deletions(-) delete mode 100644 apps/mobile/src/widgets/AgentActivityCoordinator.ios.tsx delete mode 100644 apps/mobile/src/widgets/AgentActivityCoordinator.tsx create mode 100644 apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts create mode 100644 apps/mobile/src/widgets/agentActivityWidgetTimeline.ts create mode 100644 apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts create mode 100644 apps/mobile/src/widgets/publishAgentActivityWidget.ts 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/App.tsx b/apps/mobile/src/App.tsx index b667ab81a26a5..225496bb7a407 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -73,7 +73,6 @@ import { import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; -import { AgentActivityCoordinator } from "./widgets/AgentActivityCoordinator"; import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; @@ -227,7 +226,6 @@ function AppContent() { <> - diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d7e340d886ff2..061443d60d53a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -7,12 +7,15 @@ import { describe, expect, it } from "@effect/vitest"; import Constants from "expo-constants"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { Cookies, FetchHttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import * as Dpop from "../cloud/dpop"; import * as ManagedRelayLayer from "../cloud/managedRelayLayer"; @@ -20,12 +23,15 @@ import { clearAgentAwarenessRegistrationRecord, loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, + loadPreferences, saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; +import type { Preferences } from "../../persistence/mobile-preferences"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { __resetAgentAwarenessRemoteRegistrationForTest, getAgentAwarenessRegistrationStatus, + refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, @@ -48,6 +54,7 @@ const widgetMocks = vi.hoisted(() => ({ getInstances: vi.fn(() => []), start: vi.fn(() => ({})), })); +const publishWidgetMock = vi.hoisted(() => vi.fn((_source: unknown) => Promise.resolve())); const environmentConfigsMock = vi.hoisted(() => ({ configs: new Map< string, @@ -84,6 +91,10 @@ vi.mock("expo-widgets", () => ({ addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })), })); +vi.mock("../../widgets/publishAgentActivityWidget", () => ({ + publishAgentActivityWidget: publishWidgetMock, +})); + vi.mock("./agentLiveActivity", () => ({ dismissEndedAgentLiveActivities: widgetMocks.dismissEnded, getAgentLiveActivities: widgetMocks.getInstances, @@ -247,6 +258,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); widgetMocks.start.mockClear(); + publishWidgetMock.mockClear(); widgetMocks.dismissEnded.mockReset(); environmentConfigsMock.configs.clear(); }); @@ -511,12 +523,14 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { yield* runBackgroundOperations(); - expect(fetchMock).toHaveBeenCalledTimes(2); - const [request, init] = fetchMock.mock.calls[1] as unknown as [ - unknown, - RequestInit | undefined, - ]; - const url = request instanceof Request ? request.url : String(request); + const requestUrl = (request: unknown) => + request instanceof Request ? request.url : String(request); + const deviceCalls = fetchMock.mock.calls.filter(([request]) => + requestUrl(request).endsWith("/v1/mobile/devices"), + ); + expect(deviceCalls).toHaveLength(1); + const [request, init] = deviceCalls[0] as unknown as [unknown, RequestInit | undefined]; + const url = requestUrl(request); const method = request instanceof Request ? request.method : init?.method; const headers = request instanceof Request ? request.headers : new Headers(init?.headers); const dpop = headers.get("dpop"); @@ -588,9 +602,12 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, }; + // Sign-in first reads the widget snapshot, then registers the device; fail + // both lookups so the queued registration is the one that must recover. const tokenProvider = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error("auth unavailable")) + .mockRejectedValueOnce(new Error("auth unavailable")) .mockResolvedValue("clerk-token-user-a"); setAgentAwarenessRelayTokenProvider(tokenProvider); const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0]; @@ -601,7 +618,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* runBackgroundOperations(); expect(backgroundRuntime.pending).toHaveLength(0); - expect(tokenProvider).toHaveBeenCalledTimes(2); + expect(tokenProvider).toHaveBeenCalledTimes(3); }).pipe(Effect.provide(layerRelayTest)); }); @@ -719,4 +736,336 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(layerRelayTest)); }, ); + + describe("home-screen widget", () => { + // These cover the widget path only, so keep the Live Activity from priming. + beforeEach(() => { + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false } as Preferences); + }); + + const aggregate: RelayAgentActivityAggregateState = { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: "2026-10-18T10:00:00.000Z", + activities: [ + { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-1" as ThreadId, + projectTitle: "t3code", + threadTitle: "Fix the widget", + modelTitle: "GPT-5.5", + phase: "running", + status: "Working", + updatedAt: "2026-10-18T10:00:00.000Z", + deepLink: "/threads/env-1/thread-1", + }, + ], + }; + + // The shared fetch-backed relay layer keeps the first test's fetch stub, + // so these tests answer the snapshot read through a fake client instead. + function fakeRelay( + getAgentActivitySnapshot: () => Effect.Effect< + { readonly aggregate: typeof aggregate }, + ManagedRelay.ManagedRelayRequestFailedError + >, + ) { + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } }; + const unexpected = () => Effect.die("Unexpected relay request"); + return Layer.succeed( + ManagedRelay.ManagedRelayClient, + ManagedRelay.ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: unexpected, + listDevices: unexpected, + getT3ChatLink: unexpected, + createT3ChatLinkRequest: unexpected, + unlinkT3Chat: unexpected, + createEnvironmentLinkChallenge: unexpected, + linkEnvironment: unexpected, + unlinkEnvironment: unexpected, + getEnvironmentStatus: unexpected, + connectEnvironment: unexpected, + registerDevice: unexpected, + unregisterDevice: unexpected, + registerLiveActivity: unexpected, + getAgentActivitySnapshot, + resetTokenCache: Effect.void, + }), + ); + } + + it.effect("repaints from the relay snapshot when activity is reconciled", () => { + const relay = fakeRelay(() => Effect.succeed({ aggregate })); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate }, + "user-a", + ); + }).pipe(Effect.provide(relay)); + }); + + it.effect("delegates failed reads to the account-aware native publisher", () => { + const relayDown = new ManagedRelay.ManagedRelayRequestFailedError({ + action: "read relay agent activity snapshot", + transportFailed: true, + cause: new Error("relay down"), + }); + let relayUp = false; + const relay = fakeRelay(() => + relayUp ? Effect.succeed({ aggregate }) : Effect.fail(relayDown), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + return Effect.gen(function* () { + // After a cold start the persisted timeline may be a placeholder. + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "unavailable" }, "user-a"); + + relayUp = true; + yield* refreshActiveLiveActivityRemoteRegistration(); + relayUp = false; + publishWidgetMock.mockClear(); + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "unavailable" }, "user-a"); + }).pipe(Effect.provide(relay)); + }); + + it.effect("registers an existing Live Activity before waiting on the widget read", () => { + const order: Array = []; + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => { + order.push("push-token"); + return Promise.resolve(null); + }), + addPushTokenListener: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + const relay = fakeRelay(() => + Effect.sync(() => { + order.push("widget-snapshot"); + return { aggregate }; + }), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(order).toEqual(["push-token", "widget-snapshot"]); + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate }, + "user-a", + ); + }).pipe(Effect.provide(relay)); + }); + + it.effect("keeps the newest rows when an older overlapping read finishes last", () => + Effect.gen(function* () { + const newer = { ...aggregate, activeCount: 2 }; + const olderRequested = yield* Deferred.make(); + const olderResponse = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); + let reads = 0; + const relay = fakeRelay(() => { + reads++; + return reads === 1 + ? Deferred.succeed(olderRequested, undefined).pipe( + Effect.andThen(Deferred.await(olderResponse)), + ) + : Effect.succeed({ aggregate: newer }); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + const older = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Deferred.await(olderRequested); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)); + yield* Deferred.succeed(olderResponse, { aggregate }); + yield* Fiber.join(older); + + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate: newer }, + "user-a", + ); + }), + ); + + it("shows loading, not the sign-in prompt, while a switched account's first read runs", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-a"); + + // CloudAuthBridge deactivates the old account before activating the new one. + setAgentAwarenessRelayTokenProvider(null); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-b"); + }); + + it.effect("shows loading when another account signs in after a provider release", () => { + const relay = fakeRelay(() => Effect.succeed({ aggregate })); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + // The native publisher checks persisted ownership even on a same-account remount. + releaseAgentAwarenessRelayTokenProvider(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-a"); + + releaseAgentAwarenessRelayTokenProvider(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-b"); + }).pipe(Effect.provide(relay)); + }); + + it.effect("an obsolete refresh does not discard the next account's read", () => + Effect.gen(function* () { + let markTokenRequested = () => {}; + const tokenRequested = new Promise((resolve) => { + markTokenRequested = resolve; + }); + let releaseToken = () => {}; + widgetMocks.getInstances.mockReturnValueOnce([ + { + getId: () => "activity-a", + getPushToken: () => { + markTokenRequested(); + return new Promise((resolve) => { + releaseToken = () => resolve(null); + }); + }, + addPushTokenListener: vi.fn(), + }, + ] as never); + const requested = yield* Deferred.make(); + const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); + const relay = fakeRelay(() => + Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + + const refreshA = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Effect.promise(() => tokenRequested); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + const refreshB = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Deferred.await(requested); + releaseToken(); + yield* Fiber.join(refreshA); + yield* Deferred.succeed(response, { aggregate }); + yield* Fiber.join(refreshB); + + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate }, + "user-b", + ); + }), + ); + + it.effect("does not prime a Live Activity from a read a newer one replaced", () => + Effect.gen(function* () { + let markRequested = () => {}; + const requested = new Promise((resolve) => { + markRequested = resolve; + }); + let resolvePreferences = (_preferences: Preferences) => {}; + vi.mocked(loadPreferences).mockImplementationOnce(() => { + markRequested(); + return new Promise((resolve) => { + resolvePreferences = resolve; + }); + }); + const idle = { ...aggregate, activeCount: 0, activities: [] }; + let reads = 0; + const relay = fakeRelay(() => + Effect.succeed({ aggregate: ++reads === 1 ? aggregate : idle }), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + const older = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Effect.promise(() => requested); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)); + resolvePreferences({ liveActivitiesEnabled: true } as Preferences); + yield* Fiber.join(older); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + }), + ); + + it("shows the signed-out state after cloud sign-out", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + + setAgentAwarenessRelayTokenProvider(null); + + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "signed-out" }, null); + }); + + it.effect.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 fc24d180a3a04..62fc911ab4498 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -36,6 +36,8 @@ import { saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import type { AgentActivityWidgetSource } from "../../widgets/agentActivityWidgetTimeline"; +import { publishAgentActivityWidget } from "../../widgets/publishAgentActivityWidget"; import { dismissEndedAgentLiveActivities, getAgentLiveActivities, @@ -83,6 +85,9 @@ const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000; const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS); const registeredActivityPushTokens = new Map(); +// Refreshes can overlap (sign-in, foreground, retry); only the newest read may +// repaint, so a slow older response never overwrites newer rows. +let widgetSnapshotReads = 0; let androidDeviceReplayedAt: number | null = null; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -175,6 +180,9 @@ export function setAgentAwarenessRelayTokenProvider( if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) { clearAndroidAgentNotifications(); } + if (provider) { + publishWidget({ kind: "loading" }, identity ?? null); + } androidDeviceReplayedAt = null; deviceRegistrationGeneration++; activeDeviceRegistration = null; @@ -195,6 +203,7 @@ export function setAgentAwarenessRelayTokenProvider( } // Signed-out activities would be orphaned because the relay can no longer update or end them. endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); + publishWidget({ kind: "signed-out" }); setRegistrationStatus("unknown"); void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { logRegistrationError("clear registration record on sign-out failed", error); @@ -729,6 +738,18 @@ function ensureAppStateListener(): void { }); } +function publishWidget( + source: AgentActivityWidgetSource, + accountId = relayTokenProviderIdentity, +): void { + if (!canRegisterRemoteLiveActivities()) { + return; + } + publishAgentActivityWidget(source, accountId).catch((error: unknown) => { + logRegistrationError("agent activity widget publish failed", error); + }); +} + // Binaries built before the native dismissEnded method throw here. function dismissEndedLocalLiveActivities(): void { try { @@ -805,6 +826,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); + widgetSnapshotReads = 0; } export const unregisterAgentAwarenessDeviceForCurrentUser: ( @@ -922,13 +944,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(), @@ -948,6 +1000,7 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn( // The relay tracks exactly one card per device; if concurrent arming ever // produced extras, end them so only one keeps receiving updates. + const hadActivities = activities.length > 0; if (activities.length > 1) { for (const extra of activities.slice(1)) { extra.end("immediate").catch((error: unknown) => { @@ -961,7 +1014,12 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn( // update token can be observed and registered immediately — the relay never // remote-starts one (background push-to-start wakes proved too unreliable // to hand the token over). - if (activities.length === 0) { + if (!hadActivities) { + const widgetRead = yield* refreshWidget; + if (!widgetRead) { + return; + } + const { snapshot } = widgetRead; const preferences = yield* Effect.tryPromise({ try: () => loadPreferences(), catch: (cause) => @@ -970,10 +1028,14 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn( cause, }), }).pipe(Effect.orElseSucceed(() => null)); + // A sign-out, account switch, or newer read during the preference read + // makes this snapshot stale; priming now could bring old rows back. + if (!isNewestRead()) { + return; + } if (preferences?.liveActivitiesEnabled !== false) { - const snapshot = yield* readAgentActivitySnapshot(); - // The snapshot request yields; an arm-on-send may have created the - // card in the meantime. Re-check so two cards are never started. + // The snapshot and preference reads yield; an arm-on-send may have + // created the card in the meantime. Re-check so two cards are never started. const armedMeanwhile = yield* Effect.try({ try: () => getAgentLiveActivities(), catch: () => [] as ReadonlyArray>, @@ -1029,4 +1091,10 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn( if (registrationResults.some(Boolean)) { scheduleActiveLiveActivityRegistrationRetry(); } + + // An existing card keeps its push token registered without waiting on the + // relay; the widget still repaints afterwards. + if (hadActivities) { + yield* refreshWidget; + } }); diff --git a/apps/mobile/src/widgets/AgentActivityCoordinator.ios.tsx b/apps/mobile/src/widgets/AgentActivityCoordinator.ios.tsx deleted file mode 100644 index 89e9b5b92ce35..0000000000000 --- a/apps/mobile/src/widgets/AgentActivityCoordinator.ios.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; -import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections"; -import { Atom } from "effect/reactivity"; -import * as Linking from "expo-linking"; -import { requireOptionalNativeModule } from "expo"; -import { useEffect } from "react"; -import { AppState } from "react-native"; - -import { environmentCatalog } from "../connection/catalog"; -import { environmentShell, environmentSnapshotAtom } from "../state/shell"; -import type { AgentActivityWidgetProps } from "./AgentActivityWidget"; - -const loadWidget = () => import("./AgentActivityWidget"); - -const snapshotAtom = Atom.make((get) => { - const activities: AgentActivityWidgetProps["activities"][number][] = []; - let connected = false; - for (const environmentId of enabledEnvironmentIds(get(environmentCatalog.catalogValueAtom))) { - if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") continue; - connected = true; - const snapshot = get(environmentSnapshotAtom(environmentId)); - if (!snapshot) continue; - const projects = new Map(snapshot.projects.map((project) => [project.id, project])); - for (const thread of snapshot.threads) { - if (thread.archivedAt !== null || thread.deletedAt !== null) continue; - const project = projects.get(thread.projectId); - if (!project) continue; - const state = projectThreadAwarenessV2({ environmentId, project, thread }); - if (!state || state.phase === "completed" || state.phase === "failed") continue; - activities.push({ - environmentId: state.environmentId, - threadId: state.threadId, - projectTitle: state.projectTitle, - threadTitle: state.threadTitle, - phase: state.phase, - status: state.headline, - deepLink: state.deepLink, - }); - } - } - activities.sort((a, b) => { - const priority = (phase: string) => - phase === "waiting_for_approval" || phase === "waiting_for_input" ? 0 : 1; - return ( - priority(a.phase) - priority(b.phase) || - a.environmentId.localeCompare(b.environmentId) || - a.threadId.localeCompare(b.threadId) - ); - }); - return { connected, activeCount: activities.length, activities: activities.slice(0, 3) }; -}).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); - -export function AgentActivityCoordinator() { - const catalog = useAtomValue(environmentCatalog.catalogValueAtom); - const snapshot = useAtomValue(snapshotAtom); - const hasEnvironments = !enabledEnvironmentIds(catalog).next().done; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes agent state to the native Home Screen widget - useEffect(() => { - if (!catalog.isReady || !requireOptionalNativeModule("ExpoWidgets")) return; - if (hasEnvironments && !snapshot.connected) return; - let disposed = false; - const publish = () => - void loadWidget() - .then(({ default: widget }) => { - if (disposed || AppState.currentState !== "active") return; - const now = Date.now(); - const { connected, ...activity } = snapshot; - const props = { - ...activity, - expiresAt: connected ? now + 15 * 60_000 : 0, - url: `${Linking.createURL("/")}${snapshot.activities[0]?.deepLink.slice(1) ?? ""}`, - }; - widget.updateTimeline([ - { date: new Date(now), props }, - ...(connected ? [{ date: new Date(props.expiresAt), props }] : []), - ]); - }) - .catch((error: unknown) => console.warn("Could not update agent activity widget", error)); - publish(); - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") publish(); - }); - return () => { - disposed = true; - subscription.remove(); - }; - }, [catalog.isReady, hasEnvironments, snapshot]); - return null; -} diff --git a/apps/mobile/src/widgets/AgentActivityCoordinator.tsx b/apps/mobile/src/widgets/AgentActivityCoordinator.tsx deleted file mode 100644 index c8a6ab85f892e..0000000000000 --- a/apps/mobile/src/widgets/AgentActivityCoordinator.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function AgentActivityCoordinator() { - return null; -} diff --git a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs index ba2b06867cfa8..65968a09ecbc8 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs +++ b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs @@ -50,7 +50,9 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o const props = { ...widgets.AgentActivity.initialProps, activeCount: 1, - expiresAt: 1_000, + freshUntil: 3_000, + source: "snapshot", + baseUrl: "t3code-dev:///", activities: [ { environmentId: "environment", @@ -59,6 +61,7 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o projectTitle: "Project", status: "Waiting for input", phase: "waiting_for_input", + deepLink: "/threads/environment/mcp%3Atask", }, ], }; @@ -67,16 +70,37 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o const initial = JSON.stringify( context.__expoWidgetRender(widgets.AgentActivity.initialProps, environment), ); - expect(initial).toContain("Tap to refresh"); - const active = JSON.stringify( - context.__expoWidgetRender({ ...props, expiresAt: 3_000 }, 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("Needs input"); - const expired = JSON.stringify(context.__expoWidgetRender(props, environment)); + 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("Tap to refresh"); - expect(expired).not.toContain("Review the task"); + expect(expired).not.toContain("Needs input"); + const scheduled = JSON.stringify( + context.__expoWidgetRender(props, { ...environment, timestamp: 3_000 }), + ); + expect(scheduled).toContain("Tap to refresh"); + expect(scheduled).not.toContain("Needs input"); } } finally { NodeFS.rmSync(directory, { recursive: true, force: true }); diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx index 8ba2d334bdedb..bd80e22179f4c 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -26,15 +26,25 @@ export interface AgentActivityWidgetProps { | "deepLink" > >; - readonly url?: string; - readonly expiresAt?: number; + 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 stale = (props.expiresAt ?? 0) <= Date.now(); - const rows = stale ? [] : (props.activities ?? []); - const activeCount = stale ? 0 : (props.activeCount ?? 0); + const now = Math.max(environment.date?.getTime() ?? 0, Date.now()); + const expired = (props.freshUntil ?? Infinity) <= now; + const rows = (props.activities ?? []).map((row) => + expired && row.phase !== "completed" && row.phase !== "failed" + ? { ...row, phase: "stale" as const } + : row, + ); + const stale = rows.some((row) => row.phase === "stale"); + const activeCount = props.activeCount ?? 0; const accessory = environment.widgetFamily === "accessoryRectangular"; const small = environment.widgetFamily === "systemSmall"; const hero = rows[0]; @@ -51,21 +61,34 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge return { label: "Starting", color: "#0369A1" }; case "running": return { label: "Working", color: "#0369A1" }; + case "completed": + return { label: "Done", color: "#047857" }; + case "failed": + return { label: "Failed", color: "#B91C1C" }; + case "stale": + return { label: "Tap to refresh", color: "secondary" }; default: return { label: row.status, color: "primary" }; } }; const renderStatus = (row: AgentActivityWidgetProps["activities"][number]) => { const presentation = status(row); - const color = monochrome - ? "primary" - : environment.colorScheme === "dark" - ? row.phase === "waiting_for_approval" - ? "#FCD34D" - : row.phase === "waiting_for_input" - ? "#C4B5FD" - : "#7DD3FC" - : presentation.color; + const color = + row.phase === "stale" + ? "secondary" + : monochrome + ? "primary" + : environment.colorScheme === "dark" + ? row.phase === "waiting_for_approval" + ? "#FCD34D" + : row.phase === "waiting_for_input" + ? "#C4B5FD" + : row.phase === "completed" + ? "#6EE7B7" + : row.phase === "failed" + ? "#FCA5A5" + : "#7DD3FC" + : presentation.color; return ( @@ -84,7 +107,7 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge spacing={accessory ? 5 : 10} modifiers={[ frame({ maxWidth: Infinity, maxHeight: Infinity, alignment: "topLeading" }), - widgetURL(props.url ?? "t3code://"), + ...(props.baseUrl ? [widgetURL(`${props.baseUrl}${hero?.deepLink.slice(1) ?? ""}`)] : []), ]} > @@ -99,7 +122,15 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge lineLimit(1), ]} > - {stale ? "Out of date" : activeCount > 0 ? `${activeCount} active` : "Agents"} + {stale + ? "Out of date" + : activeCount > 0 + ? `${activeCount} active` + : rows.some((row) => row.phase === "failed") + ? "Failed" + : rows.length > 0 + ? "Done" + : "Agents"} {!multiple && !accessory ? : null} @@ -138,10 +169,18 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge ) : ( - {stale ? "Catch up in T3" : "Ready when you are"} + {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"} - - {stale ? "Tap to refresh" : "No active agents"} + + {props.subtitle ?? "Load agent activity"} )} diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts new file mode 100644 index 0000000000000..1fac0e5cb58ce --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; + +import { + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, + agentActivityWidgetTimeline, +} from "./agentActivityWidgetTimeline"; +import { publishAgentActivityWidget } from "./publishAgentActivityWidget.ios"; + +const nativeWidget = vi.hoisted(() => ({ getTimeline: vi.fn(), updateTimeline: vi.fn() })); +vi.mock("expo", () => ({ requireOptionalNativeModule: () => ({}) })); +vi.mock("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(); + await publishAgentActivityWidget( + { kind: "snapshot", aggregate: aggregate([row("running", "Working")]) }, + "user-a", + ); + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ props: expect.objectContaining({ accountId: "user-a" }) }), + expect.objectContaining({ props: expect.objectContaining({ accountId: "user-a" }) }), + ]); + + await publishAgentActivityWidget({ kind: "signed-out" }, null); + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ + props: expect.objectContaining({ activities: [] }), + }), + ]); + expect(nativeWidget.updateTimeline.mock.lastCall?.[0][0].props).not.toHaveProperty("accountId"); + }); + + it("keeps sign-out last when an earlier cached timeline read is delayed", async () => { + let release = (_entries: unknown[]) => {}; + let requested = () => {}; + const started = new Promise((resolve) => { + requested = resolve; + }); + nativeWidget.getTimeline.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + requested(); + }), + ); + nativeWidget.updateTimeline.mockClear(); + + const loading = publishAgentActivityWidget({ kind: "loading" }, "user-a"); + await started; + const signOut = publishAgentActivityWidget({ kind: "signed-out" }, null); + release([]); + await Promise.all([loading, signOut]); + + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ + props: expect.objectContaining({ + subtitle: "Sign in to T3 Connect to see agents", + activities: [], + }), + }), + ]); + }); + + it("keeps persisted account rows and their stale deadline after a cold-start read failure", async () => { + const cached = agentActivityWidgetTimeline( + { kind: "snapshot", aggregate: aggregate([row("running", "Working")]) }, + now, + ).map((entry) => ({ ...entry, props: { ...entry.props, accountId: "user-a" } })); + nativeWidget.getTimeline.mockResolvedValue(cached); + nativeWidget.updateTimeline.mockClear(); + + await publishAgentActivityWidget({ kind: "loading" }, "user-a"); + await publishAgentActivityWidget({ kind: "unavailable" }, "user-a"); + + expect(nativeWidget.updateTimeline).not.toHaveBeenCalled(); + }); + + it("replaces another account's persisted rows before its first relay read", async () => { + nativeWidget.getTimeline.mockResolvedValue( + agentActivityWidgetTimeline( + { kind: "snapshot", aggregate: aggregate([row("running", "Working")]) }, + now, + ).map((entry) => ({ ...entry, props: { ...entry.props, accountId: "user-a" } })), + ); + nativeWidget.updateTimeline.mockClear(); + + await publishAgentActivityWidget({ kind: "loading" }, "user-b"); + + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ + props: expect.objectContaining({ subtitle: "Loading agents…", activities: [] }), + }), + ]); + }); + + it("replaces persisted placeholders after a failed relay read", async () => { + nativeWidget.getTimeline.mockResolvedValue( + agentActivityWidgetTimeline({ kind: "signed-out" }, now), + ); + nativeWidget.updateTimeline.mockClear(); + + await publishAgentActivityWidget({ kind: "unavailable" }, "user-a"); + + expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ + expect.objectContaining({ + props: expect.objectContaining({ subtitle: "Couldn't reach T3 Connect", activities: [] }), + }), + ]); + }); + + it("marks unfinished rows out of date once the widget can no longer vouch for them", () => { + const timeline = agentActivityWidgetTimeline( + { + kind: "snapshot", + aggregate: aggregate([row("running", "Working"), row("completed", "Done")]), + }, + now, + ); + + expect(timeline.map((entry) => entry.date.getTime())).toEqual([ + now, + now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, + ]); + expect(timeline[0]?.props.activities.map((entry) => entry.phase)).toEqual([ + "running", + "completed", + ]); + expect(timeline[1]?.props.activities.map((entry) => [entry.phase, entry.status])).toEqual([ + ["stale", "Out of date"], + ["completed", "Done"], + ]); + }); + + it("keeps finished outcomes on a single entry", () => { + const timeline = agentActivityWidgetTimeline( + { kind: "snapshot", aggregate: aggregate([row("failed", "Failed")]) }, + now, + ); + + expect(timeline).toHaveLength(1); + expect(timeline[0]?.props.activities[0]?.phase).toBe("failed"); + }); + + it("says no agents are active when the relay has no activity", () => { + const [entry] = agentActivityWidgetTimeline({ kind: "snapshot", aggregate: null }, now); + + expect(entry?.props).toMatchObject({ subtitle: "No active agents", activities: [] }); + }); + + it("asks the user to sign in instead of showing a previous account's agents", () => { + const [entry] = agentActivityWidgetTimeline({ kind: "signed-out" }, now); + + expect(entry?.props).toMatchObject({ + subtitle: "Sign in to T3 Connect to see agents", + activities: [], + }); + }); + + it("says T3 Connect is unreachable instead of asking a signed-in user to sign in", () => { + const [entry] = agentActivityWidgetTimeline({ kind: "unavailable" }, now); + + expect(entry?.props).toMatchObject({ subtitle: "Couldn't reach T3 Connect", activities: [] }); + }); +}); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts new file mode 100644 index 0000000000000..c09369d8cd9f7 --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts @@ -0,0 +1,62 @@ +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; + +import type { AgentActivityProps } from "./AgentActivity"; + +// The widget only refreshes while the app is open, so after this long it stops +// claiming an agent is still working. Matches the Live Activity's stale date. +export const AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS = 10 * 60_000; + +export type AgentActivityWidgetSource = + | { readonly kind: "signed-out" } + | { readonly kind: "unavailable" } + | { readonly kind: "loading" } + | { readonly kind: "snapshot"; readonly aggregate: RelayAgentActivityAggregateState | null }; + +export function agentActivityWidgetTimeline(source: AgentActivityWidgetSource, now: number) { + const updatedAt = new Date(now).toISOString(); + if (source.kind !== "snapshot" || !source.aggregate) { + const props: AgentActivityProps = { + title: "T3 Code", + subtitle: + source.kind === "signed-out" + ? "Sign in to T3 Connect to see agents" + : source.kind === "unavailable" + ? "Couldn't reach T3 Connect" + : source.kind === "loading" + ? "Loading agents…" + : "No active agents", + activeCount: 0, + updatedAt, + activities: [], + }; + return [{ date: new Date(now), props }]; + } + + const { aggregate } = source; + const props: AgentActivityProps & { readonly freshUntil: number } = { + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + freshUntil: now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, + }; + const hasInFlightRows = props.activities.some( + (row) => row.phase !== "completed" && row.phase !== "failed", + ); + if (!hasInFlightRows) { + return [{ date: new Date(now), props }]; + } + const staleProps: AgentActivityProps = { + ...props, + activities: props.activities.map((row) => + row.phase === "completed" || row.phase === "failed" + ? row + : { ...row, phase: "stale", status: "Out of date" }, + ), + }; + return [ + { date: new Date(now), props }, + { date: new Date(now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS), props: staleProps }, + ]; +} diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts new file mode 100644 index 0000000000000..0743761d80264 --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts @@ -0,0 +1,39 @@ +import { requireOptionalNativeModule } from "expo"; +import * as Linking from "expo-linking"; + +import { + agentActivityWidgetTimeline, + type AgentActivityWidgetSource, +} from "./agentActivityWidgetTimeline"; + +let publication = Promise.resolve(); + +export function publishAgentActivityWidget( + source: AgentActivityWidgetSource, + accountId: string | null, +) { + const next = publication.then(async () => { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const { default: widget } = await import("./AgentActivityWidget"); + if (accountId && (source.kind === "loading" || source.kind === "unavailable")) { + const cached = await widget.getTimeline().catch(() => []); + if (cached.some(({ props }) => props?.accountId === accountId && props.activities?.length)) { + return; + } + } + widget.updateTimeline( + agentActivityWidgetTimeline(source, Date.now()).map((entry) => ({ + ...entry, + props: { + ...entry.props, + source: source.kind, + 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/docs/user/usage.md b/docs/user/usage.md index 5aa5088618e71..c4313bb0427b4 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -194,8 +194,8 @@ Session, Weekly, or both for each provider. Reopen T3 to refresh expired reading ## Agent Activity widget -Add **Agent Activity** from the iOS widget gallery to see active agents from your connected -environments. Open T3 Code to refresh the snapshot. After 15 minutes, the widget asks for a refresh. +Add **Agent Activity** from the iOS widget gallery to see agents from your T3 Connect account. +Open T3 Code to refresh the snapshot. After 10 minutes, unfinished tasks ask for a refresh. Live Activities continue to receive updates separately. ## Keyboard shortcuts From de008d84a44d8609135646a9041fbf3ba04f7d23 Mon Sep 17 00:00:00 2001 From: Josh Tomaino Date: Tue, 3 Nov 2026 12:27:22 -0500 Subject: [PATCH 3/8] fix(mobile): show five compact agent rows with relay labels --- .../src/widgets/AgentActivityWidget.test.mjs | 33 ++++- .../src/widgets/AgentActivityWidget.tsx | 129 ++++++++++-------- docs/user/usage.md | 2 +- 3 files changed, 100 insertions(+), 64 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs index 65968a09ecbc8..f337fd15f3a2c 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs +++ b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs @@ -35,7 +35,6 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o { cwd: projectRoot, stdio: "pipe" }, ); const { widgets } = JSON.parse(NodeFS.readFileSync(outputPath, "utf8")); - expect(widgets.AgentActivity.layout).toContain("Tap to refresh"); expect(widgets.AgentActivity.initialProps).toMatchObject({ activeCount: 0, activities: [] }); const runtimePath = NodePath.join(directory, "runtime.js"); NodeChildProcess.execFileSync( @@ -59,7 +58,7 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o threadId: "thread", threadTitle: "Review the task", projectTitle: "Project", - status: "Waiting for input", + status: "Input", phase: "waiting_for_input", deepLink: "/threads/environment/mcp%3Atask", }, @@ -74,7 +73,7 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o const active = JSON.stringify(context.__expoWidgetRender(props, environment)); expect(active).toContain("1 active"); expect(active).toContain("Review the task"); - expect(active).toContain("Needs input"); + expect(active).toContain("Input"); expect(active).toContain("t3code-dev:///threads/environment/mcp%3Atask"); for (const [source, subtitle] of [ ["loading", "Loading agents…"], @@ -94,14 +93,34 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o const expired = JSON.stringify( context.__expoWidgetRender({ ...props, freshUntil: 1_000 }, environment), ); - expect(expired).toContain("Tap to refresh"); - expect(expired).not.toContain("Needs input"); + 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("Tap to refresh"); - expect(scheduled).not.toContain("Needs input"); + 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}`, + phase: "running", + status: "Working", + })), + }; + const aggregateLayout = JSON.stringify( + context.__expoWidgetRender(aggregate, { + widgetFamily: "systemMedium", + colorScheme: "light", + widgetRenderingMode: "fullColor", + }), + ); + expect(aggregateLayout).toContain("Relay task 5"); + expect(aggregateLayout).toContain("10 active"); } finally { NodeFS.rmSync(directory, { recursive: true, force: true }); } diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx index bd80e22179f4c..c5b47e39db317 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -5,6 +5,7 @@ import { foregroundStyle, frame, lineLimit, + layoutPriority, resizable, widgetURL, } from "@expo/ui/swift-ui/modifiers"; @@ -40,7 +41,7 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge const expired = (props.freshUntil ?? Infinity) <= now; const rows = (props.activities ?? []).map((row) => expired && row.phase !== "completed" && row.phase !== "failed" - ? { ...row, phase: "stale" as const } + ? { ...row, phase: "stale" as const, status: "Out of date" } : row, ); const stale = rows.some((row) => row.phase === "stale"); @@ -51,28 +52,26 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge const multiple = !small && !accessory && rows.length > 1; const monochrome = environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced; - const status = (row: AgentActivityWidgetProps["activities"][number]) => { + const statusColor = (row: AgentActivityWidgetProps["activities"][number]) => { switch (row.phase) { case "waiting_for_approval": - return { label: "Approval needed", color: "#B45309" }; + return "#B45309"; case "waiting_for_input": - return { label: "Needs input", color: "#6D28D9" }; - case "starting": - return { label: "Starting", color: "#0369A1" }; - case "running": - return { label: "Working", color: "#0369A1" }; + return "#6D28D9"; case "completed": - return { label: "Done", color: "#047857" }; + return "#047857"; case "failed": - return { label: "Failed", color: "#B91C1C" }; + return "#B91C1C"; case "stale": - return { label: "Tap to refresh", color: "secondary" }; + return "secondary"; default: - return { label: row.status, color: "primary" }; + return "#0369A1"; } }; - const renderStatus = (row: AgentActivityWidgetProps["activities"][number]) => { - const presentation = status(row); + const renderStatus = ( + row: AgentActivityWidgetProps["activities"][number], + horizontal = false, + ) => { const color = row.phase === "stale" ? "secondary" @@ -88,15 +87,27 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge : row.phase === "failed" ? "#FCA5A5" : "#7DD3FC" - : presentation.color; - return ( + : statusColor(row); + const text = ( + + {row.status} + + ); + return horizontal ? ( + text + ) : ( - - {presentation.label} - + {text} ); }; @@ -104,49 +115,24 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge return ( - - - - - - - {stale - ? "Out of date" - : activeCount > 0 - ? `${activeCount} active` - : rows.some((row) => row.phase === "failed") - ? "Failed" - : rows.length > 0 - ? "Done" - : "Agents"} - - - {!multiple && !accessory ? : null} {multiple ? ( - rows.slice(0, 2).map((row) => ( - - + rows.slice(0, 5).map((row) => ( + + {row.threadTitle} - - {renderStatus(row)} - - · {row.projectTitle} - - - + + {row.projectTitle} + + + {renderStatus(row, true)} + )) ) : hero ? ( @@ -168,7 +154,12 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge ) : ( - + {props.source === "loading" ? "Loading activity" : props.source === "unavailable" @@ -179,12 +170,38 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge ? "Ready when you are" : "Open T3 Code"} - + {props.subtitle ?? "Load agent activity"} )} {!accessory ? : null} + + + + {stale + ? "Out of date" + : activeCount > 0 + ? `${activeCount} active` + : rows.some((row) => row.phase === "failed") + ? "Failed" + : rows.length > 0 + ? "Done" + : "Agents"} + + ); } diff --git a/docs/user/usage.md b/docs/user/usage.md index c4313bb0427b4..bbcf6fc4617af 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -195,7 +195,7 @@ Session, Weekly, or both for each provider. Reopen T3 to refresh expired reading ## Agent Activity widget Add **Agent Activity** from the iOS widget gallery to see agents from your T3 Connect account. -Open T3 Code to refresh the snapshot. After 10 minutes, unfinished tasks ask for a refresh. +Open T3 Code to refresh the snapshot. After 10 minutes, unfinished tasks show **Out of date**. Live Activities continue to receive updates separately. ## Keyboard shortcuts From 88faeaac4f177042dd6b6c1d357d0ee0bc75e60d Mon Sep 17 00:00:00 2001 From: Josh Tomaino Date: Tue, 3 Nov 2026 13:14:06 -0500 Subject: [PATCH 4/8] fix(mobile): offer a large agent activity widget --- apps/mobile/app.config.ts | 2 +- .../src/widgets/AgentActivityWidget.test.mjs | 36 ++++++++++++----- .../src/widgets/AgentActivityWidget.tsx | 40 ++++++++++++++----- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index c90e9c2d214f3..2d6d765ea5ffc 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -192,7 +192,7 @@ const widgetsPlugin: NonNullable[number] = [ description: "Shows the current state of active T3 Code agents.", android: null, ios: { - supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], + supportedFamilies: ["systemSmall", "systemMedium", "systemLarge", "accessoryRectangular"], initialLayout: "./src/widgets/AgentActivityWidget.tsx", }, }, diff --git a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs index f337fd15f3a2c..10907bdfa1f95 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs +++ b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs @@ -64,7 +64,7 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o }, ], }; - for (const widgetFamily of ["systemSmall", "systemMedium", "accessoryRectangular"]) { + for (const widgetFamily of widget.ios.supportedFamilies) { const environment = { widgetFamily, colorScheme: "dark", widgetRenderingMode: "fullColor" }; const initial = JSON.stringify( context.__expoWidgetRender(widgets.AgentActivity.initialProps, environment), @@ -112,15 +112,31 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o status: "Working", })), }; - const aggregateLayout = JSON.stringify( - context.__expoWidgetRender(aggregate, { - widgetFamily: "systemMedium", - colorScheme: "light", - widgetRenderingMode: "fullColor", - }), - ); - expect(aggregateLayout).toContain("Relay task 5"); - expect(aggregateLayout).toContain("10 active"); + for (const widgetFamily of ["systemMedium", "systemLarge"]) { + const aggregateLayout = JSON.stringify( + context.__expoWidgetRender(aggregate, { + widgetFamily, + colorScheme: "light", + widgetRenderingMode: "fullColor", + }), + ); + expect(aggregateLayout).toContain("Relay task 5"); + expect(aggregateLayout).toContain("10 active"); + const waitingLayout = JSON.stringify( + context.__expoWidgetRender( + { + ...aggregate, + activities: aggregate.activities.map((row, index) => + index === 0 ? { ...row, phase: "stale", status: "Waiting" } : row, + ), + }, + { widgetFamily, 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 index c5b47e39db317..c240444a87578 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -44,10 +44,11 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge ? { ...row, phase: "stale" as const, status: "Out of date" } : row, ); - const stale = rows.some((row) => row.phase === "stale"); + 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 large = environment.widgetFamily === "systemLarge"; const hero = rows[0]; const multiple = !small && !accessory && rows.length > 1; const monochrome = @@ -91,7 +92,7 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge const text = ( {multiple ? ( rows.slice(0, 5).map((row) => ( - - - {row.threadTitle} - - - {row.projectTitle} - + + {large ? ( + + + {row.threadTitle} + + + {row.projectTitle} + + + ) : ( + <> + + {row.threadTitle} + + + {row.projectTitle} + + + )} {renderStatus(row, true)} @@ -191,7 +209,7 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge lineLimit(1), ]} > - {stale + {outOfDate ? "Out of date" : activeCount > 0 ? `${activeCount} active` From cac281c256aae62d86cf12202961554df84c334e Mon Sep 17 00:00:00 2001 From: Josh Tomaino Date: Tue, 3 Nov 2026 14:10:57 -0500 Subject: [PATCH 5/8] fix(mobile): use roomier widgets with system text styles --- .../src/widgets/AgentActivityWidget.test.mjs | 19 ++- .../src/widgets/AgentActivityWidget.tsx | 146 ++++++++++-------- patches/expo-widgets@58.0.20.patch | 98 +++++++++--- pnpm-lock.yaml | 8 +- 4 files changed, 181 insertions(+), 90 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs index 10907bdfa1f95..46e03c2eb6e14 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs +++ b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs @@ -112,15 +112,28 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o status: "Working", })), }; - for (const widgetFamily of ["systemMedium", "systemLarge"]) { + for (const [widgetFamily, dynamicTypeSize, visibleRows] of [ + ["systemMedium", "large", 2], + ["systemMedium", "xxLarge", 2], + ["systemMedium", "xxxLarge", 1], + ["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 5"); + expect(aggregateLayout).toContain(`Relay task ${visibleRows}`); + if (visibleRows < 5) { + expect(aggregateLayout).not.toContain(`Relay task ${visibleRows + 1}`); + } expect(aggregateLayout).toContain("10 active"); const waitingLayout = JSON.stringify( context.__expoWidgetRender( @@ -130,7 +143,7 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o index === 0 ? { ...row, phase: "stale", status: "Waiting" } : row, ), }, - { widgetFamily, colorScheme: "light", widgetRenderingMode: "fullColor" }, + { widgetFamily, dynamicTypeSize, colorScheme: "light", widgetRenderingMode: "fullColor" }, ), ); expect(waitingLayout).toContain("Waiting"); diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx index c240444a87578..11475c359e76d 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -49,50 +49,60 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge const accessory = environment.widgetFamily === "accessoryRectangular"; const small = environment.widgetFamily === "systemSmall"; const large = environment.widgetFamily === "systemLarge"; + const roomier = !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 + : 5 + : accessibility || enlarged + ? 1 + : 2; const hero = rows[0]; - const multiple = !small && !accessory && rows.length > 1; + const showRows = roomier && 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 "#B45309"; + return dark ? "#FCD34D" : "#B45309"; case "waiting_for_input": - return "#6D28D9"; + return dark ? "#C4B5FD" : "#6D28D9"; case "completed": - return "#047857"; + return dark ? "#6EE7B7" : "#047857"; case "failed": - return "#B91C1C"; - case "stale": - return "secondary"; + return dark ? "#FCA5A5" : "#B91C1C"; default: - return "#0369A1"; + 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 = - row.phase === "stale" - ? "secondary" - : monochrome - ? "primary" - : environment.colorScheme === "dark" - ? row.phase === "waiting_for_approval" - ? "#FCD34D" - : row.phase === "waiting_for_input" - ? "#C4B5FD" - : row.phase === "completed" - ? "#6EE7B7" - : row.phase === "failed" - ? "#FCA5A5" - : "#7DD3FC" - : statusColor(row); + const color = statusColor(row); const text = ( - {multiple ? ( - rows.slice(0, 5).map((row) => ( - - {large ? ( - - - {row.threadTitle} - - + {showRows ? ( + rows.slice(0, rowLimit).map((row) => ( + + {!accessibility ? ( + + ) : null} + + + {row.threadTitle} + + {!accessibility || large ? ( + {row.projectTitle} - - ) : ( - <> - - {row.threadTitle} - - - {row.projectTitle} - - - )} + ) : null} + {renderStatus(row, true)} @@ -174,8 +188,12 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge {props.source === "loading" @@ -188,15 +206,17 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge ? "Ready when you are" : "Open T3 Code"} - - {props.subtitle ?? "Load agent activity"} - + {!(roomier && accessibility && !large) ? ( + + {props.subtitle ?? "Load agent activity"} + + ) : null} )} {!accessory ? : null} @@ -204,9 +224,11 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge {outOfDate 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 755f2f95c2ed1..ce35719411b28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,7 +383,7 @@ patchedDependencies: expo-network@58.0.3: cd09b6d1744dde4efa081b5619ebcff38877d98f7502a51cab121629dece8237 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 @@ -729,7 +729,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) react: specifier: 'catalog:' version: 19.3.0 @@ -17578,7 +17578,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 58.0.14(277a83826bb26ba15256633fefeb0bc2) - 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 @@ -18849,7 +18849,7 @@ snapshots: expo: 58.0.14(277a83826bb26ba15256633fefeb0bc2) 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) From 0e64cf4dc4f20a2036d89a9bfdfa5394d0ffddc3 Mon Sep 17 00:00:00 2001 From: Josh Tomaino Date: Tue, 3 Nov 2026 15:00:01 -0500 Subject: [PATCH 6/8] fix(mobile): scale widget titles and link each activity row --- .../src/widgets/AgentActivityWidget.test.mjs | 3 + .../src/widgets/AgentActivityWidget.tsx | 77 +++++++++++-------- .../agentActivityWidgetTimeline.test.ts | 28 ++++++- .../widgets/publishAgentActivityWidget.ios.ts | 9 +++ 4 files changed, 85 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs index 46e03c2eb6e14..66856541ec18a 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs +++ b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs @@ -108,6 +108,7 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o ...props.activities[0], threadId: `thread-${index}`, threadTitle: `Relay task ${index + 1}`, + deepLink: `/threads/environment/task%3A${index + 1}`, phase: "running", status: "Working", })), @@ -135,6 +136,8 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o 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( { diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx index 11475c359e76d..fc8d6974b6418 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -1,4 +1,4 @@ -import { HStack, Image, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { HStack, Image, Link, Spacer, Text, VStack } from "@expo/ui/swift-ui"; import { fixedSize, font, @@ -133,44 +133,61 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge ]} > {showRows ? ( - rows.slice(0, rowLimit).map((row) => ( - - {!accessibility ? ( - - ) : null} - - - {row.threadTitle} - - {!accessibility || large ? ( + rows.slice(0, rowLimit).map((row) => { + const content = ( + + {!accessibility ? ( + + ) : null} + - {row.projectTitle} + {row.threadTitle} - ) : null} - - - {renderStatus(row, true)} - - )) + {!accessibility || large ? ( + + {row.projectTitle} + + ) : null} + + + {renderStatus(row, true)} + + ); + return props.baseUrl ? ( + + {content} + + ) : ( + content + ); + }) ) : hero ? ( { expect(nativeWidget.updateTimeline.mock.lastCall?.[0][0].props).not.toHaveProperty("accountId"); }); - it("keeps sign-out last when an earlier cached timeline read is delayed", async () => { + 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) => { @@ -80,9 +80,19 @@ describe("agentActivityWidgetTimeline", () => { 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, signOut]); + await Promise.all([loading, obsolete]); + await expect(signOut).rejects.toThrow("Native clearing failed"); + + expect(nativeWidget.updateTimeline).toHaveBeenCalledTimes(1); expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ expect.objectContaining({ @@ -106,6 +116,20 @@ describe("agentActivityWidgetTimeline", () => { await publishAgentActivityWidget({ kind: "unavailable" }, "user-a"); expect(nativeWidget.updateTimeline).not.toHaveBeenCalled(); + + 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 () => { diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts index 0743761d80264..097f687309127 100644 --- a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts @@ -7,16 +7,25 @@ import { } 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)) { return; } From 90dab34487fd6acd30093ee75b70e0e7d129e1a6 Mon Sep 17 00:00:00 2001 From: Josh Tomaino Date: Tue, 3 Nov 2026 17:19:46 -0500 Subject: [PATCH 7/8] fix(mobile): fit compact widget rows and fill large with unsettled threads --- .../src/widgets/AgentActivityWidget.test.mjs | 48 ++++++++++- .../src/widgets/AgentActivityWidget.tsx | 75 +++++++++++------ .../agentActivityWidgetThreads.test.ts | 74 +++++++++++++++++ .../src/widgets/agentActivityWidgetThreads.ts | 80 +++++++++++++++++++ .../agentActivityWidgetTimeline.test.ts | 54 +++++++++++-- .../widgets/agentActivityWidgetTimeline.ts | 39 ++++++--- .../widgets/publishAgentActivityWidget.ios.ts | 35 +++++++- .../widgets/readWidgetUnsettledRows.ios.ts | 22 +++++ docs/user/usage.md | 4 +- 9 files changed, 384 insertions(+), 47 deletions(-) create mode 100644 apps/mobile/src/widgets/agentActivityWidgetThreads.test.ts create mode 100644 apps/mobile/src/widgets/agentActivityWidgetThreads.ts create mode 100644 apps/mobile/src/widgets/readWidgetUnsettledRows.ios.ts diff --git a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs index 66856541ec18a..d0714da1394b9 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.test.mjs +++ b/apps/mobile/src/widgets/AgentActivityWidget.test.mjs @@ -113,10 +113,52 @@ it("embeds an Agent Activity Home Screen layout and initial data independently o 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", 2], - ["systemMedium", "xxLarge", 2], - ["systemMedium", "xxxLarge", 1], + ["systemMedium", "large", 5], + ["systemMedium", "xxLarge", 4], + ["systemMedium", "xxxLarge", 3], ["systemMedium", "accessibility5", 1], ["systemLarge", "large", 5], ["systemLarge", "xLarge", 4], diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx index fc8d6974b6418..029f50eca7f68 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -1,4 +1,4 @@ -import { HStack, Image, Link, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { Divider, HStack, Image, Link, Spacer, Text, VStack } from "@expo/ui/swift-ui"; import { fixedSize, font, @@ -27,6 +27,7 @@ export interface AgentActivityWidgetProps { | "deepLink" > >; + readonly supplementalActivities?: AgentActivityWidgetProps["activities"]; readonly baseUrl?: string; readonly freshUntil?: number; readonly updatedAt?: string; @@ -39,7 +40,21 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge "widget"; const now = Math.max(environment.date?.getTime() ?? 0, Date.now()); const expired = (props.freshUntil ?? Infinity) <= now; - const rows = (props.activities ?? []).map((row) => + 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, @@ -48,8 +63,7 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge const activeCount = props.activeCount ?? 0; const accessory = environment.widgetFamily === "accessoryRectangular"; const small = environment.widgetFamily === "systemSmall"; - const large = environment.widgetFamily === "systemLarge"; - const roomier = !small && !accessory; + const listLayout = !small && !accessory; const textSize = environment.dynamicTypeSize ?? "large"; const accessibility = textSize.startsWith("accessibility"); const enlarged = textSize === "xxxLarge"; @@ -60,12 +74,16 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge ? 3 : textSize === "xLarge" || textSize === "xxLarge" ? 4 - : 5 - : accessibility || enlarged + : 7 + : accessibility ? 1 - : 2; + : enlarged + ? 3 + : textSize === "xLarge" || textSize === "xxLarge" + ? 4 + : 5; const hero = rows[0]; - const showRows = roomier && rows.length > 0; + const showRows = listLayout && rows.length > 0; const monochrome = environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced; const statusColor = (row: AgentActivityWidgetProps["activities"][number]) => { @@ -102,7 +120,7 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge const text = ( {showRows ? ( - rows.slice(0, rowLimit).map((row) => { + rows.slice(0, rowLimit).flatMap((row, index) => { const content = ( - + {!accessibility ? ( ) : null} @@ -147,12 +173,12 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge modifiers={[ font({ textStyle: "subheadline", weight: "semibold" }), foregroundStyle("primary"), - lineLimit(large && !accessibility ? 2 : 1), + lineLimit(large && rows.length <= 5 && !accessibility ? 2 : 1), ]} > {row.threadTitle} - {!accessibility || large ? ( + {large ? ( ); - return props.baseUrl ? ( + const linked = props.baseUrl ? ( 0 && index === (props.activities?.length ?? 0) + ? [, linked] + : [linked]; }) ) : hero ? ( @@ -206,11 +235,11 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge {props.source === "loading" @@ -223,10 +252,10 @@ function AgentActivityWidget(props: AgentActivityWidgetProps, environment: Widge ? "Ready when you are" : "Open T3 Code"} - {!(roomier && accessibility && !large) ? ( + {!(listLayout && accessibility && !large) ? ( )} - {!accessory ? : null} + {!accessory && !(showRows && !large) ? : null} {outOfDate ? "Out of date" - : activeCount > 0 + : activeCount > 0 || (large && (props.supplementalActivities?.length ?? 0) > 0) ? `${activeCount} active` : rows.some((row) => row.phase === "failed") ? "Failed" 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 index 90c7c16433dcd..4a51ac8ec67d7 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -7,9 +7,17 @@ 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() })); +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 })); @@ -45,19 +53,33 @@ function aggregate(activities: ReadonlyArray): RelayAgentActivityAggregateS 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" }) }), - expect.objectContaining({ props: expect.objectContaining({ accountId: "user-a" }) }), + 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: [] }), + props: expect.objectContaining({ activities: [], supplementalActivities: [] }), }), ]); expect(nativeWidget.updateTimeline.mock.lastCall?.[0][0].props).not.toHaveProperty("accountId"); @@ -108,14 +130,27 @@ describe("agentActivityWidgetTimeline", () => { const cached = agentActivityWidgetTimeline( { kind: "snapshot", aggregate: aggregate([row("running", "Working")]) }, now, - ).map((entry) => ({ ...entry, props: { ...entry.props, accountId: "user-a" } })); + ).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"); - expect(nativeWidget.updateTimeline).not.toHaveBeenCalled(); + 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( @@ -202,6 +237,13 @@ describe("agentActivityWidgetTimeline", () => { 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", () => { diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts index c09369d8cd9f7..53f31d6fe95f7 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts @@ -1,5 +1,6 @@ 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 @@ -12,10 +13,14 @@ export type AgentActivityWidgetSource = | { readonly kind: "loading" } | { readonly kind: "snapshot"; readonly aggregate: RelayAgentActivityAggregateState | null }; -export function agentActivityWidgetTimeline(source: AgentActivityWidgetSource, now: number) { +export function agentActivityWidgetTimeline( + source: AgentActivityWidgetSource, + now: number, + supplementalActivities: AgentActivityWidgetProps["activities"] = [], +) { const updatedAt = new Date(now).toISOString(); - if (source.kind !== "snapshot" || !source.aggregate) { - const props: AgentActivityProps = { + if (source.kind !== "snapshot" || (!source.aggregate && supplementalActivities.length === 0)) { + const props: AgentActivityProps & Pick = { title: "T3 Code", subtitle: source.kind === "signed-out" @@ -28,31 +33,41 @@ export function agentActivityWidgetTimeline(source: AgentActivityWidgetSource, n activeCount: 0, updatedAt, activities: [], + supplementalActivities: [], }; return [{ date: new Date(now), props }]; } const { aggregate } = source; - const props: AgentActivityProps & { readonly freshUntil: number } = { - title: aggregate.title, - subtitle: aggregate.subtitle, - activeCount: aggregate.activeCount, - updatedAt: aggregate.updatedAt, - activities: aggregate.activities, + 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.some( + const hasInFlightRows = [...props.activities, ...supplementalActivities].some( (row) => row.phase !== "completed" && row.phase !== "failed", ); if (!hasInFlightRows) { return [{ date: new Date(now), props }]; } - const staleProps: AgentActivityProps = { + 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", status: "Out of date" }, + : { ...row, phase: "stale" as const, status: "Out of date" }, ), }; return [ diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts index 097f687309127..4168f76b636aa 100644 --- a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts @@ -26,16 +26,47 @@ export function publishAgentActivityWidget( 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)) { + 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()).map((entry) => ({ + 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 } : {}), }, 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 bbcf6fc4617af..0bd5da4e6d72b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -195,7 +195,9 @@ Session, Weekly, or both for each provider. Reopen T3 to refresh expired reading ## Agent Activity widget Add **Agent Activity** from the iOS widget gallery to see agents from your T3 Connect account. -Open T3 Code to refresh the snapshot. After 10 minutes, unfinished tasks show **Out of date**. +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 From 40ad7bc028d90010d38f12fdbb879ddb66e744c6 Mon Sep 17 00:00:00 2001 From: Josh Tomaino Date: Tue, 3 Nov 2026 18:38:46 -0500 Subject: [PATCH 8/8] fix(mobile): refresh widgets before live activity token registration --- .../remoteRegistration.test.ts | 105 +++++++++--------- .../agent-awareness/remoteRegistration.ts | 11 +- 2 files changed, 59 insertions(+), 57 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 061443d60d53a..f01bc2cb710c5 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -836,35 +836,39 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relay)); }); - it.effect("registers an existing Live Activity before waiting on the widget read", () => { - const order: Array = []; - const activity = { - getId: () => "activity-1", - getPushToken: vi.fn(() => { - order.push("push-token"); - return Promise.resolve(null); - }), - addPushTokenListener: vi.fn(), - }; - widgetMocks.getInstances.mockReturnValue([activity] as never); - const relay = fakeRelay(() => - Effect.sync(() => { - order.push("widget-snapshot"); - return { aggregate }; - }), - ); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); - - return Effect.gen(function* () { - yield* refreshActiveLiveActivityRemoteRegistration(); + 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"); - expect(order).toEqual(["push-token", "widget-snapshot"]); - expect(publishWidgetMock).toHaveBeenLastCalledWith( - { kind: "snapshot", aggregate }, - "user-a", + const refresh = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), ); - }).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* () { @@ -928,44 +932,43 @@ describe("makeRelayDeviceRegistrationRequest", () => { it.effect("an obsolete refresh does not discard the next account's read", () => Effect.gen(function* () { - let markTokenRequested = () => {}; - const tokenRequested = new Promise((resolve) => { - markTokenRequested = resolve; - }); - let releaseToken = () => {}; - widgetMocks.getInstances.mockReturnValueOnce([ - { - getId: () => "activity-a", - getPushToken: () => { - markTokenRequested(); - return new Promise((resolve) => { - releaseToken = () => resolve(null); - }); - }, - addPushTokenListener: vi.fn(), - }, - ] as never); - const requested = yield* Deferred.make(); - const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); + const 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(() => - Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))), + ++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* Effect.promise(() => tokenRequested); + 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(requested); - releaseToken(); + yield* Deferred.await(requestedB); + yield* Deferred.succeed(responseA, { aggregate }); yield* Fiber.join(refreshA); - yield* Deferred.succeed(response, { aggregate }); + yield* Deferred.succeed(responseB, { aggregate }); yield* Fiber.join(refreshB); + expect(activity.getPushToken).not.toHaveBeenCalled(); expect(publishWidgetMock).toHaveBeenLastCalledWith( { kind: "snapshot", aggregate }, "user-b", diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 62fc911ab4498..78c6312592001 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -1076,6 +1076,11 @@ export 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), @@ -1091,10 +1096,4 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( if (registrationResults.some(Boolean)) { scheduleActiveLiveActivityRegistrationRetry(); } - - // An existing card keeps its push token registered without waiting on the - // relay; the widget still repaints afterwards. - if (hadActivities) { - yield* refreshWidget; - } });