From 5a1c6d94251c0c380ac533e48b1e228e9a58ea68 Mon Sep 17 00:00:00 2001 From: T3 Code Test Date: Sun, 18 Oct 2026 16:31:59 +0530 Subject: [PATCH 01/13] fix(mobile): iOS Agent Activity widget renders instead of a blank tile The home-screen widget was declared in the widget extension, but the app only registered the Live Activity layout and never published widget data, so the widget stayed blank forever. Register a home-screen layout with createWidget, embed it so it renders before the first launch, and repaint it from the relay snapshot the foreground refresh already reads. Unfinished rows go out of date after 10 minutes, sign-out shows a sign-in prompt, and a failed read never claims a signed-in user is signed out. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/app.config.ts | 7 +- .../remoteRegistration.test.ts | 151 +++++++++++++- .../agent-awareness/remoteRegistration.ts | 36 +++- .../src/widgets/AgentActivityWidget.tsx | 197 ++++++++++++++++++ .../agentActivityWidgetTimeline.test.ts | 93 +++++++++ .../widgets/agentActivityWidgetTimeline.ts | 58 ++++++ .../widgets/publishAgentActivityWidget.ios.ts | 12 ++ .../src/widgets/publishAgentActivityWidget.ts | 3 + docs/user/mobile-notifications.md | 2 + 9 files changed, 547 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/widgets/AgentActivityWidget.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/app.config.ts b/apps/mobile/app.config.ts index d1d0398e0942a..a2f616261396f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -185,7 +185,12 @@ const widgetsPlugin: NonNullable[number] = [ displayName: "Agent Activity", description: "Shows the current state of active T3 Code agents.", android: null, - ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] }, + ios: { + supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], + // Embeds the layout so the gallery preview and a new widget render + // before the app has run once; the app replaces it on publish. + initialLayout: "./src/widgets/AgentActivityWidget.tsx", + }, }, ], }, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 9c06ec09e7eca..fe7b35d3c1c0e 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -7,13 +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 } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import type { SavedRemoteConnection } from "../../lib/connection"; import * as Dpop from "../cloud/dpop"; @@ -61,6 +63,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, @@ -97,6 +100,10 @@ vi.mock("expo-widgets", () => ({ addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })), })); +vi.mock("../../widgets/publishAgentActivityWidget", () => ({ + publishAgentActivityWidget: publishWidgetMock, +})); + vi.mock("./agentLiveActivity", () => ({ getAgentLiveActivities: widgetMocks.getInstances, startAgentLiveActivity: widgetMocks.start, @@ -271,6 +278,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); widgetMocks.start.mockClear(); + publishWidgetMock.mockClear(); environmentConfigsMock.configs.clear(); }); @@ -680,12 +688,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"); @@ -905,9 +915,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]; @@ -918,7 +931,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* runBackgroundOperations(); expect(backgroundRuntime.pending).toHaveLength(0); - expect(tokenProvider).toHaveBeenCalledTimes(2); + expect(tokenProvider).toHaveBeenCalledTimes(3); }).pipe(Effect.provide(layerRelayTest)); }); @@ -1249,4 +1262,126 @@ 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, + 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 }); + }).pipe(Effect.provide(relay)); + }); + + it.effect("keeps the last rows when a later read fails, but never the signed-out state", () => { + 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* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "unavailable" }); + + relayUp = true; + yield* refreshActiveLiveActivityRemoteRegistration(); + relayUp = false; + publishWidgetMock.mockClear(); + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(publishWidgetMock).not.toHaveBeenCalled(); + }).pipe(Effect.provide(relay)); + }); + + 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" }); + }); + + it.effect("drops a snapshot that arrives after the account changed", () => + Effect.gen(function* () { + const requested = yield* Deferred.make(); + const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); + const relay = fakeRelay(() => + Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + + const refresh = yield* Effect.forkChild( + refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), + ); + yield* Deferred.await(requested); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + yield* Deferred.succeed(response, { aggregate }); + yield* Fiber.join(refresh); + + expect(publishWidgetMock).not.toHaveBeenCalledWith({ kind: "snapshot", aggregate }); + }), + ); + }); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index cccb7056cb2b0..44d778d218e63 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -37,6 +37,8 @@ import { saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import type { AgentActivityWidgetSource } from "../../widgets/agentActivityWidgetTimeline"; +import { publishAgentActivityWidget } from "../../widgets/publishAgentActivityWidget"; import { getAgentLiveActivities, startAgentLiveActivity } from "./agentLiveActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { supportsAgentAwarenessPush } from "./capabilities"; @@ -89,6 +91,9 @@ const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000; const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS); const registeredActivityPushTokens = new Map(); +// A failed read keeps the current account's last rows, but must not leave the +// widget claiming the user is signed out or showing a previous account. +let widgetShowsCurrentAccount = false; let androidDeviceReplayedAt: number | null = null; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -188,6 +193,7 @@ export function setAgentAwarenessRelayTokenProvider( activeDeviceRegistration = null; pendingDeviceRegistration = null; registeredActivityPushTokens.clear(); + widgetShowsCurrentAccount = false; } relayTokenProvider = provider; relayTokenProviderIdentity = provider ? (identity ?? null) : null; @@ -204,6 +210,7 @@ export function setAgentAwarenessRelayTokenProvider( // Without a signed-in user the relay can no longer update or end these // activities, so they would sit orphaned on the lock screen. endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); + publishWidget({ kind: "signed-out" }); setRegistrationStatus("unknown"); void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { logRegistrationError("clear registration record on sign-out failed", error); @@ -810,6 +817,15 @@ function ensureAppStateListener(): void { }); } +function publishWidget(source: AgentActivityWidgetSource): void { + if (!canRegisterRemoteLiveActivities()) { + return; + } + publishAgentActivityWidget(source).catch((error: unknown) => { + logRegistrationError("agent activity widget publish failed", error); + }); +} + function endLocalLiveActivities(context: string): void { if (!canRegisterRemoteLiveActivities()) { return; @@ -900,6 +916,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); + widgetShowsCurrentAccount = false; } export function unregisterAgentAwarenessDeviceForCurrentUser( @@ -1041,6 +1058,20 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< return; } + // The home-screen widget has no push path of its own, so every foreground + // refresh repaints it from the same snapshot that primes the Live Activity. + const identity = relayTokenProviderIdentity; + const snapshot = yield* readAgentActivitySnapshot(); + if (!relayTokenProvider || relayTokenProviderIdentity !== identity) { + return; + } + if (snapshot) { + widgetShowsCurrentAccount = true; + publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate }); + } else if (!widgetShowsCurrentAccount) { + publishWidget({ kind: "unavailable" }); + } + let activities = yield* Effect.try({ try: () => getAgentLiveActivities(), catch: (cause) => @@ -1082,9 +1113,8 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< }), }).pipe(Effect.orElseSucceed(() => null)); 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. + // Loading preferences yields; an arm-on-send may have created the card + // in the meantime. Re-check so two cards are never started. const armedMeanwhile = yield* Effect.try({ try: () => getAgentLiveActivities(), catch: () => [] as ReadonlyArray>, diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx new file mode 100644 index 0000000000000..1bbe1e3737e20 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -0,0 +1,197 @@ +import { HStack, Image, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { + containerBackground, + font, + foregroundStyle, + frame, + layoutPriority, + lineLimit, + resizable, + widgetURL, +} from "@expo/ui/swift-ui/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; + +import type { + AgentActivityPhase, + AgentActivityProps, + AgentActivityRowProps, +} from "./AgentActivity"; + +// Home-screen and Lock Screen counterpart of the AgentActivity Live Activity. +// It shares the Live Activity's name, but expo-widgets stores widget and Live +// Activity layouts under different keys, so each needs its own registration. +export function AgentActivityWidget( + publishedProps: Partial | undefined, + environment: WidgetEnvironment, +) { + "widget"; + // The extension evaluates this function without the app's module scope. + // Before the app's first publish, the embedded layout renders with no props. + const props = publishedProps ?? {}; + const family = environment.widgetFamily; + const accessory = family === "accessoryRectangular"; + const small = family === "systemSmall"; + const monochrome = + environment.widgetRenderingMode === "accented" || + environment.widgetRenderingMode === "vibrant" || + environment.isLuminanceReduced === true; + const isLightScheme = environment.colorScheme === "light"; + + type Foreground = Parameters[0]; + const primaryForeground = { type: "hierarchical", style: "primary" } as const; + const secondaryForeground = { type: "hierarchical", style: "secondary" } as const; + const phaseTint = (phase: AgentActivityPhase): Foreground => { + if (monochrome) return primaryForeground; + switch (phase) { + case "waiting_for_approval": + return isLightScheme ? "#d97706" : "#fcd34d"; + case "waiting_for_input": + return isLightScheme ? "#4f46e5" : "#a5b4fc"; + case "failed": + return isLightScheme ? "#dc2626" : "#fca5a5"; + case "completed": + return isLightScheme ? "#059669" : "#6ee7b7"; + case "stale": + return secondaryForeground; + case "starting": + case "running": + default: + return isLightScheme ? "#0284c7" : "#7dd3fc"; + } + }; + const phasePriority = (phase: AgentActivityPhase): number => { + if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; + if (phase === "failed") return 1; + if (phase === "running" || phase === "starting") return 2; + return 3; + }; + + const loaded = props.activities !== undefined; + const rows = [...(props.activities ?? [])].sort( + (a, b) => phasePriority(a.phase) - phasePriority(b.phase), + ); + const shown = rows.slice(0, accessory || small ? 2 : 4); + const attentionCount = rows.filter( + (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + ).length; + const workingCount = rows.filter( + (row) => row.phase === "running" || row.phase === "starting", + ).length; + const summary = + attentionCount > 0 + ? `${attentionCount} need${attentionCount === 1 ? "s" : ""} ${small ? "you" : "attention"}` + : workingCount > 0 + ? `${props.activeCount ?? workingCount} active` + : rows.some((row) => row.phase === "stale") + ? "Out of date" + : rows.some((row) => row.phase === "failed") + ? "Failed" + : rows.length > 0 + ? "Done" + : ""; + const summaryTint = rows[0] ? phaseTint(rows[0].phase) : secondaryForeground; + const emptyMessage = loaded + ? (props.subtitle ?? "No active agents") + : "Open T3 Code to load agent activity"; + + const linkRow = rows[0]; + const deepLink = + linkRow && linkRow.deepLink.startsWith("/") && !linkRow.deepLink.startsWith("//") + ? `t3code://${linkRow.deepLink.slice(1)}` + : null; + + const background = accessory ? "clear" : isLightScheme ? "#ffffff" : "#1c1c1e"; + const rootModifiers = [ + containerBackground(background, "widget"), + ...(deepLink ? [widgetURL(deepLink)] : []), + ]; + + // The template image ignores frame/foregroundStyle applied to itself; size + // and tint it through a container the resizable image fills. + const renderLogo = (height: number) => ( + + + + ); + + const renderRow = (row: AgentActivityRowProps, key: string) => + small || accessory ? ( + + + {row.threadTitle} + + + {row.status} + + + ) : ( + + + {row.threadTitle} + + + {row.projectTitle} + + + + {row.status} + + + ); + + return ( + + + {accessory ? null : renderLogo(13)} + + {summary || (accessory ? "T3 Code" : "")} + + + + {shown.length > 0 ? ( + + {shown.map((row, index) => renderRow(row, `${row.threadId}:${index}`))} + + ) : ( + + {emptyMessage} + + )} + {accessory ? null : } + + ); +} + +export default createWidget("AgentActivity", AgentActivityWidget); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts new file mode 100644 index 0000000000000..c991b0b1463ea --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } 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"; + +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("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..d9274d0772432 --- /dev/null +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts @@ -0,0 +1,58 @@ +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: "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" + : "No active agents", + activeCount: 0, + updatedAt, + activities: [], + }; + return [{ date: new Date(now), props }]; + } + + const { aggregate } = source; + const props: AgentActivityProps = { + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + }; + const hasInFlightRows = props.activities.some( + (row) => row.phase !== "completed" && row.phase !== "failed", + ); + if (!hasInFlightRows) { + return [{ date: new Date(now), props }]; + } + const staleProps: AgentActivityProps = { + ...props, + activities: props.activities.map((row) => + row.phase === "completed" || row.phase === "failed" + ? row + : { ...row, phase: "stale", status: "Out of date" }, + ), + }; + return [ + { date: new Date(now), props }, + { date: new Date(now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS), props: staleProps }, + ]; +} diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts new file mode 100644 index 0000000000000..feaf058172a22 --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts @@ -0,0 +1,12 @@ +import { requireOptionalNativeModule } from "expo"; + +import { + agentActivityWidgetTimeline, + type AgentActivityWidgetSource, +} from "./agentActivityWidgetTimeline"; + +export async function publishAgentActivityWidget(source: AgentActivityWidgetSource) { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const { default: widget } = await import("./AgentActivityWidget"); + widget.updateTimeline(agentActivityWidgetTimeline(source, Date.now())); +} diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ts new file mode 100644 index 0000000000000..a432fc4c92471 --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ts @@ -0,0 +1,3 @@ +import type { AgentActivityWidgetSource } from "./agentActivityWidgetTimeline"; + +export async function publishAgentActivityWidget(_source: AgentActivityWidgetSource) {} diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 9c5e30ef76074..b8b7626627083 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -4,6 +4,8 @@ Sign in to T3 Connect, link your environments, and enable **Device Notifications Enable **Ongoing Agent Activity** on Android or **Live Activity Updates** on iOS to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off ongoing activity in Settings to stop future cards. +On iOS you can also add the **Agent Activity** widget to your Home Screen or Lock Screen. It shows the same agents as the Live Activity, using your T3 Connect sign-in. The widget refreshes when you open T3 and shows unfinished work as out of date after 10 minutes. + Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer can promote ongoing activity to a Live Update, subject to system settings and device support. Other devices show a regular ongoing notification. Android 7's battery-saving modes can delay removal of expired cards. From 25247c6193edcbf7bd2c189dade9c8d80c5eda83 Mon Sep 17 00:00:00 2001 From: T3 Code Test Date: Mon, 19 Oct 2026 13:28:28 +0530 Subject: [PATCH 02/13] fix(mobile): reconcile Live Activities before the widget's relay read An existing Live Activity re-registers its push token without waiting on the snapshot read, which can take up to the relay timeout. The widget still repaints from one read per refresh. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 27 ++++++++++++ .../agent-awareness/remoteRegistration.ts | 42 ++++++++++++------- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index fe7b35d3c1c0e..6acbef721354f 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1355,6 +1355,33 @@ 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(); + + expect(order).toEqual(["push-token", "widget-snapshot"]); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "snapshot", aggregate }); + }).pipe(Effect.provide(relay)); + }); + it("shows the signed-out state after cloud sign-out", () => { setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 44d778d218e63..b7508f07b7af8 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -1059,18 +1059,21 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< } // The home-screen widget has no push path of its own, so every foreground - // refresh repaints it from the same snapshot that primes the Live Activity. + // refresh repaints it from one snapshot read. Returns false once the account + // changed during the read, so a stale result is never shown or primed. const identity = relayTokenProviderIdentity; - const snapshot = yield* readAgentActivitySnapshot(); - if (!relayTokenProvider || relayTokenProviderIdentity !== identity) { - return; - } - if (snapshot) { - widgetShowsCurrentAccount = true; - publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate }); - } else if (!widgetShowsCurrentAccount) { - publishWidget({ kind: "unavailable" }); - } + const repaintWidget = (snapshot: RelayAgentActivitySnapshotResponse | null): boolean => { + if (!relayTokenProvider || relayTokenProviderIdentity !== identity) { + return false; + } + if (snapshot) { + widgetShowsCurrentAccount = true; + publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate }); + } else if (!widgetShowsCurrentAccount) { + publishWidget({ kind: "unavailable" }); + } + return true; + }; let activities = yield* Effect.try({ try: () => getAgentLiveActivities(), @@ -1090,6 +1093,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // 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) => { @@ -1103,7 +1107,11 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // 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 snapshot = yield* readAgentActivitySnapshot(); + if (!repaintWidget(snapshot)) { + return; + } const preferences = yield* Effect.tryPromise({ try: () => loadPreferences(), catch: (cause) => @@ -1113,8 +1121,8 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< }), }).pipe(Effect.orElseSucceed(() => null)); if (preferences?.liveActivitiesEnabled !== false) { - // Loading preferences 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>, @@ -1173,5 +1181,11 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< 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) { + repaintWidget(yield* readAgentActivitySnapshot()); + } }); } From f7773cff973107c2099c429e02e2fdf195876cf5 Mon Sep 17 00:00:00 2001 From: T3 Code Test Date: Mon, 19 Oct 2026 13:32:16 +0530 Subject: [PATCH 03/13] fix(mobile): a slow older widget read no longer overwrites newer rows Sign-in, foreground and the retry timer can start overlapping refreshes. Each widget snapshot read is numbered, and only the newest one repaints the widget or primes a Live Activity. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 28 ++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 32 +++++++++++++------ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 6acbef721354f..701e1812141dc 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1382,6 +1382,34 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).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 }); + }), + ); + it("shows the signed-out state after cloud sign-out", () => { setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index b7508f07b7af8..4c5b77b7b57c6 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -94,6 +94,9 @@ const registeredActivityPushTokens = new Map(); // A failed read keeps the current account's last rows, but must not leave the // widget claiming the user is signed out or showing a previous account. let widgetShowsCurrentAccount = false; +// 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; @@ -917,6 +920,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); widgetShowsCurrentAccount = false; + widgetSnapshotReads = 0; } export function unregisterAgentAwarenessDeviceForCurrentUser( @@ -1059,12 +1063,19 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< } // The home-screen widget has no push path of its own, so every foreground - // refresh repaints it from one snapshot read. Returns false once the account - // changed during the read, so a stale result is never shown or primed. + // 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 repaintWidget = (snapshot: RelayAgentActivitySnapshotResponse | null): boolean => { - if (!relayTokenProvider || relayTokenProviderIdentity !== identity) { - return false; + const refreshWidget = Effect.gen(function* () { + const read = ++widgetSnapshotReads; + const snapshot = yield* readAgentActivitySnapshot(); + if ( + read !== widgetSnapshotReads || + !relayTokenProvider || + relayTokenProviderIdentity !== identity + ) { + return undefined; } if (snapshot) { widgetShowsCurrentAccount = true; @@ -1072,8 +1083,8 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< } else if (!widgetShowsCurrentAccount) { publishWidget({ kind: "unavailable" }); } - return true; - }; + return { snapshot }; + }); let activities = yield* Effect.try({ try: () => getAgentLiveActivities(), @@ -1108,10 +1119,11 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // remote-starts one (background push-to-start wakes proved too unreliable // to hand the token over). if (!hadActivities) { - const snapshot = yield* readAgentActivitySnapshot(); - if (!repaintWidget(snapshot)) { + const widgetRead = yield* refreshWidget; + if (!widgetRead) { return; } + const { snapshot } = widgetRead; const preferences = yield* Effect.tryPromise({ try: () => loadPreferences(), catch: (cause) => @@ -1185,7 +1197,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // An existing card keeps its push token registered without waiting on the // relay; the widget still repaints afterwards. if (hadActivities) { - repaintWidget(yield* readAgentActivitySnapshot()); + yield* refreshWidget; } }); } From 31c97b0a3617079c661b0cdb23365b1864c56b4a Mon Sep 17 00:00:00 2001 From: T3 Code Test Date: Mon, 19 Oct 2026 13:46:07 +0530 Subject: [PATCH 04/13] fix(mobile): widget hides the previous account's agents on account switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching T3 Connect accounts now shows "Loading agents…" until the new account's first read finishes, instead of leaving the old rows visible. Co-Authored-By: Claude Opus 5.5 --- .../features/agent-awareness/remoteRegistration.test.ts | 9 +++++++++ .../src/features/agent-awareness/remoteRegistration.ts | 9 +++++++++ apps/mobile/src/widgets/agentActivityWidgetTimeline.ts | 5 ++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 701e1812141dc..2eac8cb366ab6 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1410,6 +1410,15 @@ describe("makeRelayDeviceRegistrationRequest", () => { }), ); + it("hides the previous account's rows as soon as another account signs in", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + expect(publishWidgetMock).not.toHaveBeenCalled(); + + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }); + }); + it("shows the signed-out state after cloud sign-out", () => { setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 4c5b77b7b57c6..8536f82fa426a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -191,6 +191,15 @@ export function setAgentAwarenessRelayTokenProvider( if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) { clearAndroidAgentNotifications(); } + // Hide the previous account's rows while the new account's first read runs. + if ( + provider && + identity && + relayTokenProviderIdentity && + identity !== relayTokenProviderIdentity + ) { + publishWidget({ kind: "loading" }); + } androidDeviceReplayedAt = null; deviceRegistrationGeneration++; activeDeviceRegistration = null; diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts index d9274d0772432..7a00789cc9b5a 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts @@ -9,6 +9,7 @@ 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) { @@ -21,7 +22,9 @@ export function agentActivityWidgetTimeline(source: AgentActivityWidgetSource, n ? "Sign in to T3 Connect to see agents" : source.kind === "unavailable" ? "Couldn't reach T3 Connect" - : "No active agents", + : source.kind === "loading" + ? "Loading agents…" + : "No active agents", activeCount: 0, updatedAt, activities: [], From 1ba2c18973d1b0a66f6e0dcfe17f44ca2cab1629 Mon Sep 17 00:00:00 2001 From: shivamhwp Date: Wed, 21 Oct 2026 09:14:36 +0530 Subject: [PATCH 05/13] fix(mobile): widget keeps its rows across a cold start and never re-primes after sign-out - Recheck the account after the preference read so a sign-out during it can't prime a Live Activity with the old account's rows. - Track whether the widget is showing a non-account state instead of whether this process painted rows, so a failed read after a cold start keeps the persisted rows, and the real deactivate-then-activate account switch shows loading instead of the sign-in prompt. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 40 ++++++++++++++++++- .../agent-awareness/remoteRegistration.ts | 32 ++++++++------- .../src/widgets/AgentActivityWidget.tsx | 2 + 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 6a2fff92e9024..e07c6de1479d8 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -858,7 +858,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relay)); }); - it.effect("keeps the last rows when a later read fails, but never the signed-out state", () => { + it.effect("a failed read keeps the shown rows, but never the signed-out state", () => { const relayDown = new ManagedRelay.ManagedRelayRequestFailedError({ action: "read relay agent activity snapshot", transportFailed: true, @@ -871,6 +871,12 @@ describe("makeRelayDeviceRegistrationRequest", () => { setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); return Effect.gen(function* () { + // A cold start keeps the rows the widget persisted before the app quit. + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(publishWidgetMock).not.toHaveBeenCalled(); + + setAgentAwarenessRelayTokenProvider(null); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); yield* refreshActiveLiveActivityRemoteRegistration(); expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "unavailable" }); @@ -938,10 +944,12 @@ describe("makeRelayDeviceRegistrationRequest", () => { }), ); - it("hides the previous account's rows as soon as another account signs in", () => { + 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).not.toHaveBeenCalled(); + // 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" }); @@ -975,5 +983,33 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(publishWidgetMock).not.toHaveBeenCalledWith({ kind: "snapshot", aggregate }); }), ); + + 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 2b1e9e4f57cda..fcbe9609217b2 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -95,9 +95,10 @@ 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(); -// A failed read keeps the current account's last rows, but must not leave the -// widget claiming the user is signed out or showing a previous account. -let widgetShowsCurrentAccount = false; +// Set while the widget shows the signed-out or loading state instead of the +// active account's rows. A failed read replaces only those; otherwise it keeps +// the rows on screen, including ones persisted from before a cold start. +let widgetAwaitsAccountRead = false; // 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; @@ -195,13 +196,11 @@ export function setAgentAwarenessRelayTokenProvider( if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) { clearAndroidAgentNotifications(); } - // Hide the previous account's rows while the new account's first read runs. - if ( - provider && - identity && - relayTokenProviderIdentity && - identity !== relayTokenProviderIdentity - ) { + // Sign-in, including an account switch after the old account signed out, + // shows loading until the first read. A cold start keeps the persisted + // rows, which belong to the last account that was signed in. + if (provider && identity && (relayTokenProviderIdentity || widgetAwaitsAccountRead)) { + widgetAwaitsAccountRead = true; publishWidget({ kind: "loading" }); } androidDeviceReplayedAt = null; @@ -209,7 +208,6 @@ export function setAgentAwarenessRelayTokenProvider( activeDeviceRegistration = null; pendingDeviceRegistration = null; registeredActivityPushTokens.clear(); - widgetShowsCurrentAccount = false; } relayTokenProvider = provider; relayTokenProviderIdentity = provider ? (identity ?? null) : null; @@ -226,6 +224,7 @@ export function setAgentAwarenessRelayTokenProvider( // Without a signed-in user the relay can no longer update or end these // activities, so they would sit orphaned on the lock screen. endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); + widgetAwaitsAccountRead = true; publishWidget({ kind: "signed-out" }); setRegistrationStatus("unknown"); void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { @@ -936,7 +935,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); - widgetShowsCurrentAccount = false; + widgetAwaitsAccountRead = false; widgetSnapshotReads = 0; } @@ -1090,9 +1089,9 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( return undefined; } if (snapshot) { - widgetShowsCurrentAccount = true; + widgetAwaitsAccountRead = false; publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate }); - } else if (!widgetShowsCurrentAccount) { + } else if (widgetAwaitsAccountRead) { publishWidget({ kind: "unavailable" }); } return { snapshot }; @@ -1145,6 +1144,11 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( cause, }), }).pipe(Effect.orElseSucceed(() => null)); + // A sign-out or account switch during the preference read already ended + // this account's cards; priming now would bring its rows back. + if (!relayTokenProvider || relayTokenProviderIdentity !== identity) { + return; + } if (preferences?.liveActivitiesEnabled !== false) { // 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. diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx index 1bbe1e3737e20..6d0f19fc26039 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -94,6 +94,8 @@ export function AgentActivityWidget( ? (props.subtitle ?? "No active agents") : "Open T3 Code to load agent activity"; + // Widget taps open the containing app, and every build accepts the prod + // scheme as a link prefix, so dev and preview builds route this too. const linkRow = rows[0]; const deepLink = linkRow && linkRow.deepLink.startsWith("/") && !linkRow.deepLink.startsWith("//") From 9a71d1093083e4246a5d3a146b798e181f1aeb66 Mon Sep 17 00:00:00 2001 From: shivamhwp Date: Wed, 21 Oct 2026 09:18:39 +0530 Subject: [PATCH 06/13] fix(mobile): a failed read after a cold start never leaves a stale widget placeholder The persisted timeline is unknown after a restart, so a failed read now shows "Couldn't reach T3 Connect" unless this process already painted the current account's rows. Sign-in still shows loading only after a sign-out or account switch, never over the persisted timeline on launch. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 9 ++----- .../agent-awareness/remoteRegistration.ts | 25 ++++++++++--------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index e07c6de1479d8..3bc2c4667e925 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -858,7 +858,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relay)); }); - it.effect("a failed read keeps the shown rows, but never the signed-out state", () => { + it.effect("a failed read keeps rows painted this session, but never a placeholder", () => { const relayDown = new ManagedRelay.ManagedRelayRequestFailedError({ action: "read relay agent activity snapshot", transportFailed: true, @@ -871,12 +871,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); return Effect.gen(function* () { - // A cold start keeps the rows the widget persisted before the app quit. - yield* refreshActiveLiveActivityRemoteRegistration(); - expect(publishWidgetMock).not.toHaveBeenCalled(); - - setAgentAwarenessRelayTokenProvider(null); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + // After a cold start the persisted timeline may be a placeholder. yield* refreshActiveLiveActivityRemoteRegistration(); expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "unavailable" }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index fcbe9609217b2..3796c28b2a3cf 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -95,10 +95,10 @@ 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(); -// Set while the widget shows the signed-out or loading state instead of the -// active account's rows. A failed read replaces only those; otherwise it keeps -// the rows on screen, including ones persisted from before a cold start. -let widgetAwaitsAccountRead = false; +// What the widget shows, as far as this process knows. A failed read keeps +// only rows this process painted for the current account; after a cold start +// the persisted timeline could be any state, so it is "unknown". +let widgetShows: "unknown" | "placeholder" | "account-rows" = "unknown"; // 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; @@ -197,10 +197,10 @@ export function setAgentAwarenessRelayTokenProvider( clearAndroidAgentNotifications(); } // Sign-in, including an account switch after the old account signed out, - // shows loading until the first read. A cold start keeps the persisted - // rows, which belong to the last account that was signed in. - if (provider && identity && (relayTokenProviderIdentity || widgetAwaitsAccountRead)) { - widgetAwaitsAccountRead = true; + // shows loading until the first read. A cold start doesn't, so a launch + // never flashes it over the persisted timeline. + if (provider && identity && (relayTokenProviderIdentity || widgetShows === "placeholder")) { + widgetShows = "placeholder"; publishWidget({ kind: "loading" }); } androidDeviceReplayedAt = null; @@ -224,7 +224,7 @@ export function setAgentAwarenessRelayTokenProvider( // Without a signed-in user the relay can no longer update or end these // activities, so they would sit orphaned on the lock screen. endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); - widgetAwaitsAccountRead = true; + widgetShows = "placeholder"; publishWidget({ kind: "signed-out" }); setRegistrationStatus("unknown"); void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { @@ -935,7 +935,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); - widgetAwaitsAccountRead = false; + widgetShows = "unknown"; widgetSnapshotReads = 0; } @@ -1089,9 +1089,10 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( return undefined; } if (snapshot) { - widgetAwaitsAccountRead = false; + widgetShows = "account-rows"; publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate }); - } else if (widgetAwaitsAccountRead) { + } else if (widgetShows !== "account-rows") { + widgetShows = "placeholder"; publishWidget({ kind: "unavailable" }); } return { snapshot }; From 49a8baacdd2e33c0a57a8dee4fe48eaa17a81670 Mon Sep 17 00:00:00 2001 From: shivamhwp Date: Wed, 21 Oct 2026 09:24:00 +0530 Subject: [PATCH 07/13] fix(mobile): widget rows keep their owner across a provider release - Record which account painted the widget rows, so a remount for another account shows loading and a failed read never keeps the old account's rows. - Check the account before numbering a widget read, so an obsolete refresh can't discard the next account's read and leave it on loading. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 62 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 27 +++++--- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 3bc2c4667e925..4e4ca59ab0b3c 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -950,6 +950,68 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }); }); + 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(); + + // A remount for the same account keeps its rows. + releaseAgentAwarenessRelayTokenProvider(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "snapshot", aggregate }); + + releaseAgentAwarenessRelayTokenProvider(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }); + }).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 }); + }), + ); + it("shows the signed-out state after cloud sign-out", () => { setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 3796c28b2a3cf..50486a7a641ac 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -97,8 +97,11 @@ const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AF const registeredActivityPushTokens = new Map(); // What the widget shows, as far as this process knows. A failed read keeps // only rows this process painted for the current account; after a cold start -// the persisted timeline could be any state, so it is "unknown". -let widgetShows: "unknown" | "placeholder" | "account-rows" = "unknown"; +// the persisted timeline could be any state, so it is "unknown". Rows keep +// their owner because a provider release forgets the active identity. +let widgetShows: "unknown" | "placeholder" | { readonly rowsOf: string | null } = "unknown"; +const widgetShowsRowsOf = (identity: string | null) => + typeof widgetShows === "object" && widgetShows.rowsOf === identity; // 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; @@ -196,10 +199,14 @@ export function setAgentAwarenessRelayTokenProvider( if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) { clearAndroidAgentNotifications(); } - // Sign-in, including an account switch after the old account signed out, - // shows loading until the first read. A cold start doesn't, so a launch - // never flashes it over the persisted timeline. - if (provider && identity && (relayTokenProviderIdentity || widgetShows === "placeholder")) { + // Sign-in and account switches show loading until the first read. A cold + // start doesn't, so a launch never flashes it over the persisted timeline. + if ( + provider && + identity && + (relayTokenProviderIdentity || widgetShows !== "unknown") && + !widgetShowsRowsOf(identity) + ) { widgetShows = "placeholder"; publishWidget({ kind: "loading" }); } @@ -1079,6 +1086,10 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( // shown or primed. const identity = relayTokenProviderIdentity; const refreshWidget = Effect.gen(function* () { + // An obsolete refresh must not supersede the current account's read. + if (!relayTokenProvider || relayTokenProviderIdentity !== identity) { + return undefined; + } const read = ++widgetSnapshotReads; const snapshot = yield* readAgentActivitySnapshot(); if ( @@ -1089,9 +1100,9 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( return undefined; } if (snapshot) { - widgetShows = "account-rows"; + widgetShows = { rowsOf: identity }; publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate }); - } else if (widgetShows !== "account-rows") { + } else if (!widgetShowsRowsOf(identity)) { widgetShows = "placeholder"; publishWidget({ kind: "unavailable" }); } From a7365470a27ba3536738d3672438c9b92f6c2283 Mon Sep 17 00:00:00 2001 From: shivamhwp Date: Wed, 21 Oct 2026 09:25:12 +0530 Subject: [PATCH 08/13] fix(mobile): only the newest widget read may prime a Live Activity Recheck the read number, not just the account, after the preference read, so an older snapshot can't start a card after a newer read reported no agents. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 32 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 20 ++++++------ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 4e4ca59ab0b3c..558f3e368ed88 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1012,6 +1012,38 @@ describe("makeRelayDeviceRegistrationRequest", () => { }), ); + 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"); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 50486a7a641ac..70e8f5df26849 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -1085,18 +1085,18 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( // account changed or a newer read started, so a stale result is never // shown or primed. const identity = relayTokenProviderIdentity; + const isCurrentAccount = () => + relayTokenProvider !== null && relayTokenProviderIdentity === identity; + let read = 0; + const isNewestRead = () => isCurrentAccount() && read === widgetSnapshotReads; const refreshWidget = Effect.gen(function* () { // An obsolete refresh must not supersede the current account's read. - if (!relayTokenProvider || relayTokenProviderIdentity !== identity) { + if (!isCurrentAccount()) { return undefined; } - const read = ++widgetSnapshotReads; + read = ++widgetSnapshotReads; const snapshot = yield* readAgentActivitySnapshot(); - if ( - read !== widgetSnapshotReads || - !relayTokenProvider || - relayTokenProviderIdentity !== identity - ) { + if (!isNewestRead()) { return undefined; } if (snapshot) { @@ -1156,9 +1156,9 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( cause, }), }).pipe(Effect.orElseSucceed(() => null)); - // A sign-out or account switch during the preference read already ended - // this account's cards; priming now would bring its rows back. - if (!relayTokenProvider || relayTokenProviderIdentity !== identity) { + // 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) { From 9644c168e619c68200c81949e9ff539d7ebe2c54 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 03:08:16 +0000 Subject: [PATCH 09/13] fix(mobile): preserve account widget rows across cold starts --- .../remoteRegistration.test.ts | 43 +++++--- .../agent-awareness/remoteRegistration.ts | 32 ++---- .../src/widgets/AgentActivityWidget.tsx | 9 +- .../agentActivityWidgetTimeline.test.ts | 104 +++++++++++++++++- .../widgets/publishAgentActivityWidget.ios.ts | 29 ++++- .../src/widgets/publishAgentActivityWidget.ts | 5 +- 6 files changed, 176 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 67992464fa0fd..9afac8bead422 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -853,11 +853,14 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { yield* refreshActiveLiveActivityRemoteRegistration(); - expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "snapshot", aggregate }); + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate }, + "user-a", + ); }).pipe(Effect.provide(relay)); }); - it.effect("a failed read keeps rows painted this session, but never a placeholder", () => { + it.effect("delegates failed reads to the account-aware native publisher", () => { const relayDown = new ManagedRelay.ManagedRelayRequestFailedError({ action: "read relay agent activity snapshot", transportFailed: true, @@ -872,14 +875,14 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { // After a cold start the persisted timeline may be a placeholder. yield* refreshActiveLiveActivityRemoteRegistration(); - expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "unavailable" }); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "unavailable" }, "user-a"); relayUp = true; yield* refreshActiveLiveActivityRemoteRegistration(); relayUp = false; publishWidgetMock.mockClear(); yield* refreshActiveLiveActivityRemoteRegistration(); - expect(publishWidgetMock).not.toHaveBeenCalled(); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "unavailable" }, "user-a"); }).pipe(Effect.provide(relay)); }); @@ -906,7 +909,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* refreshActiveLiveActivityRemoteRegistration(); expect(order).toEqual(["push-token", "widget-snapshot"]); - expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "snapshot", aggregate }); + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate }, + "user-a", + ); }).pipe(Effect.provide(relay)); }); @@ -934,19 +940,22 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* Deferred.succeed(olderResponse, { aggregate }); yield* Fiber.join(older); - expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "snapshot", aggregate: newer }); + 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).not.toHaveBeenCalled(); + 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" }); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-b"); }); it.effect("shows loading when another account signs in after a provider release", () => { @@ -956,14 +965,14 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { yield* refreshActiveLiveActivityRemoteRegistration(); - // A remount for the same account keeps its rows. + // 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: "snapshot", aggregate }); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-a"); releaseAgentAwarenessRelayTokenProvider(); setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); - expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "loading" }, "user-b"); }).pipe(Effect.provide(relay)); }); @@ -1007,7 +1016,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* Deferred.succeed(response, { aggregate }); yield* Fiber.join(refreshB); - expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "snapshot", aggregate }); + expect(publishWidgetMock).toHaveBeenLastCalledWith( + { kind: "snapshot", aggregate }, + "user-b", + ); }), ); @@ -1048,7 +1060,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { setAgentAwarenessRelayTokenProvider(null); - expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "signed-out" }); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "signed-out" }, null); }); it.effect("drops a snapshot that arrives after the account changed", () => @@ -1068,7 +1080,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* Deferred.succeed(response, { aggregate }); yield* Fiber.join(refresh); - expect(publishWidgetMock).not.toHaveBeenCalledWith({ kind: "snapshot", aggregate }); + expect(publishWidgetMock).not.toHaveBeenCalledWith( + { kind: "snapshot", aggregate }, + "user-a", + ); }), ); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 79c1c66bace5f..2fba6fd1b9012 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -87,13 +87,6 @@ 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(); -// What the widget shows, as far as this process knows. A failed read keeps -// only rows this process painted for the current account; after a cold start -// the persisted timeline could be any state, so it is "unknown". Rows keep -// their owner because a provider release forgets the active identity. -let widgetShows: "unknown" | "placeholder" | { readonly rowsOf: string | null } = "unknown"; -const widgetShowsRowsOf = (identity: string | null) => - typeof widgetShows === "object" && widgetShows.rowsOf === identity; // 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; @@ -190,16 +183,8 @@ export function setAgentAwarenessRelayTokenProvider( if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) { clearAndroidAgentNotifications(); } - // Sign-in and account switches show loading until the first read. A cold - // start doesn't, so a launch never flashes it over the persisted timeline. - if ( - provider && - identity && - (relayTokenProviderIdentity || widgetShows !== "unknown") && - !widgetShowsRowsOf(identity) - ) { - widgetShows = "placeholder"; - publishWidget({ kind: "loading" }); + if (provider) { + publishWidget({ kind: "loading" }, identity ?? null); } androidDeviceReplayedAt = null; deviceRegistrationGeneration++; @@ -221,7 +206,6 @@ 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"); - widgetShows = "placeholder"; publishWidget({ kind: "signed-out" }); setRegistrationStatus("unknown"); void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { @@ -817,11 +801,14 @@ function ensureAppStateListener(): void { }); } -function publishWidget(source: AgentActivityWidgetSource): void { +function publishWidget( + source: AgentActivityWidgetSource, + accountId = relayTokenProviderIdentity, +): void { if (!canRegisterRemoteLiveActivities()) { return; } - publishAgentActivityWidget(source).catch((error: unknown) => { + publishAgentActivityWidget(source, accountId).catch((error: unknown) => { logRegistrationError("agent activity widget publish failed", error); }); } @@ -926,7 +913,6 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { registrationStatusListeners.clear(); registeredActivityPushTokens.clear(); activityPushTokenLocks.clear(); - widgetShows = "unknown"; widgetSnapshotReads = 0; } @@ -1082,10 +1068,8 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( return undefined; } if (snapshot) { - widgetShows = { rowsOf: identity }; publishWidget({ kind: "snapshot", aggregate: snapshot.aggregate }); - } else if (!widgetShowsRowsOf(identity)) { - widgetShows = "placeholder"; + } else { publishWidget({ kind: "unavailable" }); } return { snapshot }; diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx index 6d0f19fc26039..5331a54cf228c 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -21,7 +21,9 @@ import type { // It shares the Live Activity's name, but expo-widgets stores widget and Live // Activity layouts under different keys, so each needs its own registration. export function AgentActivityWidget( - publishedProps: Partial | undefined, + publishedProps: + | (Partial & { readonly accountId?: string | null }) + | undefined, environment: WidgetEnvironment, ) { "widget"; @@ -196,4 +198,7 @@ export function AgentActivityWidget( ); } -export default createWidget("AgentActivity", AgentActivityWidget); +export default createWidget & { readonly accountId?: string | null }>( + "AgentActivity", + AgentActivityWidget, +); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts index c991b0b1463ea..98d418a1c295a 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; @@ -7,6 +7,11 @@ import { AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, agentActivityWidgetTimeline, } from "./agentActivityWidgetTimeline"; +import { publishAgentActivityWidget } from "./publishAgentActivityWidget.ios"; + +const nativeWidget = vi.hoisted(() => ({ getTimeline: vi.fn(), updateTimeline: vi.fn() })); +vi.mock("expo", () => ({ requireOptionalNativeModule: () => ({}) })); +vi.mock("./AgentActivityWidget", () => ({ default: nativeWidget })); const now = Date.parse("2026-10-18T10:00:00.000Z"); @@ -37,6 +42,103 @@ function aggregate(activities: ReadonlyArray): RelayAgentActivityAggregateS } 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({ accountId: null, activities: [] }), + }), + ]); + }); + + 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( { diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts index feaf058172a22..1f116a11112e5 100644 --- a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts @@ -5,8 +5,29 @@ import { type AgentActivityWidgetSource, } from "./agentActivityWidgetTimeline"; -export async function publishAgentActivityWidget(source: AgentActivityWidgetSource) { - if (!requireOptionalNativeModule("ExpoWidgets")) return; - const { default: widget } = await import("./AgentActivityWidget"); - widget.updateTimeline(agentActivityWidgetTimeline(source, Date.now())); +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, accountId: source.kind === "snapshot" ? accountId : null }, + })), + ); + }); + // 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 index a432fc4c92471..840357356c780 100644 --- a/apps/mobile/src/widgets/publishAgentActivityWidget.ts +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ts @@ -1,3 +1,6 @@ import type { AgentActivityWidgetSource } from "./agentActivityWidgetTimeline"; -export async function publishAgentActivityWidget(_source: AgentActivityWidgetSource) {} +export async function publishAgentActivityWidget( + _source: AgentActivityWidgetSource, + _accountId: string | null, +) {} From ca9b8d57dbc7252835d6dec38be22e6028e33ac5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 03:10:26 +0000 Subject: [PATCH 10/13] fix(mobile): omit absent widget owners from native storage --- apps/mobile/src/widgets/AgentActivityWidget.tsx | 6 ++---- apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts | 3 ++- apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts | 5 ++++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx index 5331a54cf228c..688a7bc8c3b02 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -21,9 +21,7 @@ import type { // It shares the Live Activity's name, but expo-widgets stores widget and Live // Activity layouts under different keys, so each needs its own registration. export function AgentActivityWidget( - publishedProps: - | (Partial & { readonly accountId?: string | null }) - | undefined, + publishedProps: (Partial & { readonly accountId?: string }) | undefined, environment: WidgetEnvironment, ) { "widget"; @@ -198,7 +196,7 @@ export function AgentActivityWidget( ); } -export default createWidget & { readonly accountId?: string | null }>( +export default createWidget & { readonly accountId?: string }>( "AgentActivity", AgentActivityWidget, ); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts index 98d418a1c295a..9d832fd4bce46 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -56,9 +56,10 @@ describe("agentActivityWidgetTimeline", () => { await publishAgentActivityWidget({ kind: "signed-out" }, null); expect(nativeWidget.updateTimeline).toHaveBeenLastCalledWith([ expect.objectContaining({ - props: expect.objectContaining({ accountId: null, activities: [] }), + 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 () => { diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts index 1f116a11112e5..8f30fa5cda4ad 100644 --- a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts @@ -23,7 +23,10 @@ export function publishAgentActivityWidget( widget.updateTimeline( agentActivityWidgetTimeline(source, Date.now()).map((entry) => ({ ...entry, - props: { ...entry.props, accountId: source.kind === "snapshot" ? accountId : null }, + props: { + ...entry.props, + ...(source.kind === "snapshot" && accountId ? { accountId } : {}), + }, })), ); }); From c27c70e084bfc08fbb8d6ef674602d318af86393 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 07:37:00 +0000 Subject: [PATCH 11/13] fix(mobile): keep widget checks compatible with main --- .../src/features/agent-awareness/remoteRegistration.test.ts | 3 +++ apps/mobile/src/features/agent-awareness/remoteRegistration.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 0ff4d1702ee74..c6b23f2dab15c 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -779,6 +779,9 @@ describe("makeRelayDeviceRegistrationRequest", () => { relayUrl: "https://relay.example.test", listEnvironments: unexpected, listDevices: unexpected, + getT3ChatLink: unexpected, + createT3ChatLinkRequest: unexpected, + unlinkT3Chat: unexpected, createEnvironmentLinkChallenge: unexpected, linkEnvironment: unexpected, unlinkEnvironment: unexpected, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 92376616f2613..3427ab8baf19d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -944,7 +944,7 @@ 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) { From f070681dfa5c00d205df3ed6f5dbf3665a37815d Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:11:38 +0000 Subject: [PATCH 12/13] fix(mobile): drop the widget's account cache and guard reads by registration generation The widget now always shows loading/unavailable/signed-out placeholders instead of preserving cached rows, which removes the cold-start and account-ownership edge cases. Stale reads compare the device registration generation, so a sign-out and re-sign-in to the same account also drops the old read. Trimmed tests to the race guards and stale timeline. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 107 ++------------- .../agent-awareness/remoteRegistration.ts | 13 +- .../src/widgets/AgentActivityWidget.tsx | 7 +- .../agentActivityWidgetTimeline.test.ts | 126 +----------------- .../widgets/publishAgentActivityWidget.ios.ts | 32 +---- .../src/widgets/publishAgentActivityWidget.ts | 5 +- 6 files changed, 27 insertions(+), 263 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 1b7d18c33f146..33358347fca29 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -805,46 +805,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); } - 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 = { @@ -868,10 +828,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* refreshActiveLiveActivityRemoteRegistration(); expect(order).toEqual(["push-token", "widget-snapshot"]); - expect(publishWidgetMock).toHaveBeenLastCalledWith( - { kind: "snapshot", aggregate }, - "user-a", - ); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ + kind: "snapshot", + aggregate, + }); }).pipe(Effect.provide(relay)); }); @@ -899,42 +859,13 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* Deferred.succeed(olderResponse, { aggregate }); yield* Fiber.join(older); - expect(publishWidgetMock).toHaveBeenLastCalledWith( - { kind: "snapshot", aggregate: newer }, - "user-a", - ); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ + kind: "snapshot", + aggregate: newer, + }); }), ); - 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 = () => {}; @@ -975,10 +906,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* Deferred.succeed(response, { aggregate }); yield* Fiber.join(refreshB); - expect(publishWidgetMock).toHaveBeenLastCalledWith( - { kind: "snapshot", aggregate }, - "user-b", - ); + expect(publishWidgetMock).toHaveBeenLastCalledWith({ + kind: "snapshot", + aggregate, + }); }), ); @@ -1014,14 +945,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }), ); - it("shows the signed-out state after cloud sign-out", () => { - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); - - setAgentAwarenessRelayTokenProvider(null); - - expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "signed-out" }, null); - }); - it.effect("drops a snapshot that arrives after the account changed", () => Effect.gen(function* () { const requested = yield* Deferred.make(); @@ -1036,13 +959,11 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); yield* Deferred.await(requested); setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + publishWidgetMock.mockClear(); yield* Deferred.succeed(response, { aggregate }); yield* Fiber.join(refresh); - expect(publishWidgetMock).not.toHaveBeenCalledWith( - { kind: "snapshot", aggregate }, - "user-a", - ); + expect(publishWidgetMock).not.toHaveBeenCalled(); }), ); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index d77345b94863c..caed74e42c488 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -187,7 +187,7 @@ export function setAgentAwarenessRelayTokenProvider( clearAndroidAgentNotifications(); } if (provider) { - publishWidget({ kind: "loading" }, identity ?? null); + publishWidget({ kind: "loading" }); } androidDeviceReplayedAt = null; deviceRegistrationGeneration++; @@ -746,14 +746,11 @@ function ensureAppStateListener(): void { }); } -function publishWidget( - source: AgentActivityWidgetSource, - accountId = relayTokenProviderIdentity, -): void { +function publishWidget(source: AgentActivityWidgetSource): void { if (!canRegisterRemoteLiveActivities()) { return; } - publishAgentActivityWidget(source, accountId).catch((error: unknown) => { + publishAgentActivityWidget(source).catch((error: unknown) => { logRegistrationError("agent activity widget publish failed", error); }); } @@ -966,9 +963,9 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( // 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; + relayTokenProvider !== null && deviceRegistrationGeneration === generation; let read = 0; const isNewestRead = () => isCurrentAccount() && read === widgetSnapshotReads; const refreshWidget = Effect.gen(function* () { diff --git a/apps/mobile/src/widgets/AgentActivityWidget.tsx b/apps/mobile/src/widgets/AgentActivityWidget.tsx index 688a7bc8c3b02..2d97c15b8e2d7 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.tsx @@ -21,7 +21,7 @@ import type { // It shares the Live Activity's name, but expo-widgets stores widget and Live // Activity layouts under different keys, so each needs its own registration. export function AgentActivityWidget( - publishedProps: (Partial & { readonly accountId?: string }) | undefined, + publishedProps: Partial | undefined, environment: WidgetEnvironment, ) { "widget"; @@ -196,7 +196,4 @@ export function AgentActivityWidget( ); } -export default createWidget & { readonly accountId?: string }>( - "AgentActivity", - AgentActivityWidget, -); +export default createWidget>("AgentActivity", AgentActivityWidget); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts index 9d832fd4bce46..9ffd91aeeeb20 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; @@ -7,11 +7,6 @@ import { AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, agentActivityWidgetTimeline, } from "./agentActivityWidgetTimeline"; -import { publishAgentActivityWidget } from "./publishAgentActivityWidget.ios"; - -const nativeWidget = vi.hoisted(() => ({ getTimeline: vi.fn(), updateTimeline: vi.fn() })); -vi.mock("expo", () => ({ requireOptionalNativeModule: () => ({}) })); -vi.mock("./AgentActivityWidget", () => ({ default: nativeWidget })); const now = Date.parse("2026-10-18T10:00:00.000Z"); @@ -42,104 +37,6 @@ function aggregate(activities: ReadonlyArray): RelayAgentActivityAggregateS } 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( { @@ -172,25 +69,4 @@ describe("agentActivityWidgetTimeline", () => { 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/publishAgentActivityWidget.ios.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts index 8f30fa5cda4ad..feaf058172a22 100644 --- a/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ios.ts @@ -5,32 +5,8 @@ import { type AgentActivityWidgetSource, } from "./agentActivityWidgetTimeline"; -let publication = Promise.resolve(); - -export function publishAgentActivityWidget( - source: AgentActivityWidgetSource, - accountId: string | null, -) { - const next = publication.then(async () => { - if (!requireOptionalNativeModule("ExpoWidgets")) return; - const { default: widget } = await import("./AgentActivityWidget"); - if (accountId && (source.kind === "loading" || source.kind === "unavailable")) { - const cached = await widget.getTimeline().catch(() => []); - if (cached.some(({ props }) => props?.accountId === accountId && props.activities?.length)) { - return; - } - } - widget.updateTimeline( - agentActivityWidgetTimeline(source, Date.now()).map((entry) => ({ - ...entry, - props: { - ...entry.props, - ...(source.kind === "snapshot" && accountId ? { accountId } : {}), - }, - })), - ); - }); - // Failed native writes must not prevent a later sign-out from clearing the timeline. - publication = next.catch(() => {}); - return next; +export async function publishAgentActivityWidget(source: AgentActivityWidgetSource) { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const { default: widget } = await import("./AgentActivityWidget"); + widget.updateTimeline(agentActivityWidgetTimeline(source, Date.now())); } diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.ts index 840357356c780..a432fc4c92471 100644 --- a/apps/mobile/src/widgets/publishAgentActivityWidget.ts +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.ts @@ -1,6 +1,3 @@ import type { AgentActivityWidgetSource } from "./agentActivityWidgetTimeline"; -export async function publishAgentActivityWidget( - _source: AgentActivityWidgetSource, - _accountId: string | null, -) {} +export async function publishAgentActivityWidget(_source: AgentActivityWidgetSource) {} From 9e1694d4f573aa5e7f8a1ba63d9668dcb8c34aad Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:17:33 +0000 Subject: [PATCH 13/13] test(mobile): cover a sign-out and re-sign-in during a widget read Co-Authored-By: Claude Opus 5.5 --- .../src/features/agent-awareness/remoteRegistration.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 33358347fca29..c58bc492b8bd5 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -945,7 +945,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }), ); - it.effect("drops a snapshot that arrives after the account changed", () => + it.effect("drops a snapshot that arrives after a sign-out, even on re-sign-in", () => Effect.gen(function* () { const requested = yield* Deferred.make(); const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>(); @@ -958,7 +958,8 @@ describe("makeRelayDeviceRegistrationRequest", () => { refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)), ); yield* Deferred.await(requested); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b"); + setAgentAwarenessRelayTokenProvider(null); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a"); publishWidgetMock.mockClear(); yield* Deferred.succeed(response, { aggregate }); yield* Fiber.join(refresh);