From caeeb241378c95eb5b5e400d2a443ca99f982f87 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 08:59:06 -0400 Subject: [PATCH 01/19] fix(mobile): iOS home screen widgets render instead of a containerBackground error expo-widgets 56 stopped applying containerBackground for us, and the home-screen widget never got a createWidget layout. iOS 17 then showed "Please adopt containerBackground API" instead of agent activity. Adopt the modifier on the home-screen view, register the widget layout, and publish snapshots from the existing Live Activity refresh path. Made-with: Grok 4.6 (T3 Code) --- .../remoteRegistration.test.ts | 1 + .../agent-awareness/remoteRegistration.ts | 54 +++- apps/mobile/src/widgets/AgentActivity.test.ts | 49 ++++ apps/mobile/src/widgets/AgentActivity.tsx | 234 +++++++++++------- 4 files changed, 240 insertions(+), 98 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 582c58fb27e6..fd106ea408f9 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -89,6 +89,7 @@ vi.mock("../../widgets/AgentActivity", () => ({ getInstances: widgetMocks.getInstances, start: widgetMocks.start, }, + publishAgentActivityWidget: vi.fn(), })); // The state modules pull the whole connection stack (and native expo modules) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a2d4261de603..c416c7345d8b 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -31,7 +31,10 @@ import { loadPreferences, saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; -import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; +import AgentActivity, { + publishAgentActivityWidget, + type AgentActivityProps, +} from "../../widgets/AgentActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { supportsAgentAwarenessPush } from "./capabilities"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; @@ -461,6 +464,35 @@ function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolea ); } +function publishHomeScreenWidget(props: AgentActivityProps): void { + if (typeof publishAgentActivityWidget !== "function") { + return; + } + publishAgentActivityWidget(props); +} + +function widgetPropsFromAggregate( + aggregate: NonNullable, +): AgentActivityProps { + return { + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + }; +} + +function idleWidgetProps(): AgentActivityProps { + return { + title: "T3 Code", + subtitle: "No active agents", + activeCount: 0, + updatedAt: new Date().toISOString(), + activities: [], + }; +} + // Arms the lock-screen card the moment the user starts agent work from this // phone, while the app is still foregrounded and the fresh activity's token // can be registered immediately. The seeded row is a best-effort placeholder; @@ -501,7 +533,7 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { return; } const nowIso = new Date(Date.now()).toISOString(); - const activity = AgentActivity.start({ + const props: AgentActivityProps = { title: "T3 Code", subtitle: "Agent work in progress", activeCount: 1, @@ -519,7 +551,9 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { deepLink: "/", }, ], - }); + }; + publishHomeScreenWidget(props); + const activity = AgentActivity.start(props); logRegistrationDebug("live activity card armed for local work", { threadTitle: input.threadTitle, }); @@ -1067,6 +1101,11 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // prime, so only an explicit false blocks it. if (preferences?.liveActivitiesEnabled !== false) { const snapshot = yield* readAgentActivitySnapshot(); + if (snapshot) { + publishHomeScreenWidget( + snapshot.aggregate ? widgetPropsFromAggregate(snapshot.aggregate) : idleWidgetProps(), + ); + } // The snapshot request yields; an arm-on-send may have created the // card in the meantime. Re-check so two cards are never started. const armedMeanwhile = yield* Effect.try({ @@ -1078,14 +1117,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { const aggregate = snapshot.aggregate; const primed = yield* Effect.try({ - try: () => - AgentActivity.start({ - title: aggregate.title, - subtitle: aggregate.subtitle, - activeCount: aggregate.activeCount, - updatedAt: aggregate.updatedAt, - activities: aggregate.activities, - }), + try: () => AgentActivity.start(widgetPropsFromAggregate(aggregate)), catch: (cause) => new AgentAwarenessOperationError({ operation: "prime-live-activity", diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index dc9cd0e22117..bae8df1ee5c8 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -10,6 +10,9 @@ vi.mock("@expo/ui/swift-ui", () => ({ })); vi.mock("@expo/ui/swift-ui/modifiers", () => ({ + containerBackground: (color: unknown, container: unknown) => ({ + containerBackground: { color, container }, + }), font: (value: unknown) => value, foregroundStyle: (value: unknown) => value, frame: (value: unknown) => value, @@ -22,6 +25,11 @@ vi.mock("@expo/ui/swift-ui/modifiers", () => ({ vi.mock("expo-widgets", () => ({ createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })), + createWidget: vi.fn((name: string, layout: unknown) => ({ + layout, + name, + updateSnapshot: vi.fn(), + })), })); import { @@ -263,6 +271,47 @@ describe("AgentActivity widget layout", () => { expect(JSON.stringify(layout.minimal)).toContain("xmark.octagon.fill"); }); + it("adopts containerBackground and returns a view for home-screen widgets", () => { + const medium = AgentActivity({ ...props, activities: [makeRow({})] }, { + ...environment, + widgetFamily: "systemMedium", + } as never); + const mediumJson = JSON.stringify(medium); + expect(medium).not.toHaveProperty("banner"); + expect(mediumJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); + expect(mediumJson).toContain('"all":14'); + + const small = AgentActivity({ ...props, activities: [makeRow({})] }, { + ...environment, + widgetFamily: "systemSmall", + } as never); + const smallJson = JSON.stringify(small); + expect(small).not.toHaveProperty("banner"); + expect(smallJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); + expect(smallJson).toContain('"all":10'); + expect(smallJson).not.toContain('"all":14'); + }); + + it("does not apply containerBackground to the Live Activity layout", () => { + const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment as never); + expect(layout).toHaveProperty("banner"); + expect(JSON.stringify(layout)).not.toContain("containerBackground"); + }); + + it("renders an idle home-screen widget when props are missing", () => { + const view = AgentActivity( + {} as AgentActivityProps, + { + ...environment, + widgetFamily: "systemMedium", + } as never, + ); + const json = JSON.stringify(view); + expect(json).toContain("No active agents"); + expect(json).toContain('"containerBackground":{"color":"clear","container":"widget"}'); + expect(json).not.toContain("0 active"); + }); + it("renders up to five rows in the banner", () => { const layout = AgentActivity( { diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 88c85648271f..a8210853cc6b 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -1,6 +1,7 @@ import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; import type { ComponentProps } from "react"; import { + containerBackground, font, foregroundStyle, frame, @@ -12,12 +13,11 @@ import { } from "@expo/ui/swift-ui/modifiers"; import { createLiveActivity, + createWidget, type LiveActivityComponent, type LiveActivityLayout, } from "expo-widgets"; -type LiveActivityEnvironment = Parameters>[1]; - export type AgentActivityPhase = | "starting" | "running" @@ -47,15 +47,44 @@ export interface AgentActivityProps { readonly activities: ReadonlyArray; } +type LiveActivityEnvironment = Parameters>[1]; + +// Home-screen widgets pass widgetFamily; Live Activities do not. The same +// serialized function serves both surfaces. +type AgentActivityEnvironment = LiveActivityEnvironment & { + readonly widgetFamily?: + | "systemSmall" + | "systemMedium" + | "systemLarge" + | "systemExtraLarge" + | "accessoryCircular" + | "accessoryRectangular" + | "accessoryInline"; +}; + // This function is serialized into the widget extension's JS bundle, so it // must stay self-contained: no references to module-scope helpers, only the // imported view/modifier factories. export function AgentActivity( props: AgentActivityProps, - environment: LiveActivityEnvironment, + environment: AgentActivityEnvironment, ): LiveActivityLayout { "widget"; + // Placeholder / first-paint entries arrive with empty props. Treat missing + // fields as idle rather than throwing inside the widget JS runtime. + const activities = Array.isArray(props.activities) ? props.activities : []; + const activeCount = typeof props.activeCount === "number" ? props.activeCount : 0; + const widgetFamily = environment.widgetFamily; + const isHomeScreenWidget = typeof widgetFamily === "string"; + const useCompactWidget = + widgetFamily === "systemSmall" || + widgetFamily === "accessoryCircular" || + widgetFamily === "accessoryInline"; + // expo-widgets 56 stopped applying this natively. iOS 17+ home-screen + // widgets that omit it render "Please adopt containerBackground API". + const homeScreenBackground = isHomeScreenWidget ? [containerBackground("clear", "widget")] : []; + // Use SwiftUI's semantic label colors rather than fixed hex keyed off the // device color scheme. A Live Activity banner always renders over a dark // system material regardless of the device's light/dark setting, so @@ -100,20 +129,18 @@ export function AgentActivity( if (phase === "running" || phase === "starting") return 2; return 3; }; - const ordered = [...props.activities].sort( - (a, b) => phasePriority(a.phase) - phasePriority(b.phase), - ); + const ordered = [...activities].sort((a, b) => phasePriority(a.phase) - phasePriority(b.phase)); const row0 = ordered[0]; const row1 = ordered[1]; const row2 = ordered[2]; const row3 = ordered[3]; const row4 = ordered[4]; - const attentionRows = props.activities.filter( + const attentionRows = activities.filter( (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", ); const attentionRow = attentionRows[0]; - const failedRow = props.activities.find((row) => row.phase === "failed"); + const failedRow = activities.find((row) => row.phase === "failed"); const heroRow = attentionRow ?? failedRow ?? row0; const tint = phaseTint(heroRow?.phase); // Headline count leans on the accent when a human is actually blocked. @@ -130,20 +157,25 @@ export function AgentActivity( // terminal row): every presentation — header text, tint, count slots, // minimal glyph — must agree, and a failure anywhere should dominate a // newer success. - const allDone = props.activeCount === 0; - const doneLabel = failedRow ? "Failed" : "Done"; - const outcomeLabel = failedRow ? "Agent work failed" : "Agent work completed"; + const allDone = activeCount === 0; + const hasRows = activities.length > 0; + const doneLabel = failedRow ? "Failed" : hasRows ? "Done" : "Idle"; + const outcomeLabel = failedRow + ? "Agent work failed" + : hasRows + ? "Agent work completed" + : "No active agents"; // Header copy: "5 active agents" + (", 1 needs attention"). The banner renders // the two parts in-line so the attention half can carry the accent color; // `summary` is the short form for tight spots (expanded center, watch card). - const agentWord = props.activeCount === 1 ? "agent" : "agents"; - const agentsLabel = allDone ? outcomeLabel : `${props.activeCount} active ${agentWord}`; + const agentWord = activeCount === 1 ? "agent" : "agents"; + const agentsLabel = allDone ? outcomeLabel : `${activeCount} active ${agentWord}`; const attentionSuffix = attentionRows.length > 0 ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention` : ""; - const activeLabel = allDone ? doneLabel : `${props.activeCount} active`; + const activeLabel = allDone ? doneLabel : `${activeCount} active`; const summary = attentionSuffix || activeLabel; // Any registered scheme variant routes back to this app; taps are delivered @@ -235,94 +267,109 @@ export function AgentActivity( ); - return { - banner: ( - - {/* Logo pinned to the leading edge; the status texts centered across the - full width (ZStack so the logo doesn't skew the centering). No footer — - overflow beyond the visible rows is inferable from the count. */} - - - {renderLogo(13, primaryForeground)} - - - - + const banner = ( + + {/* Logo pinned to the leading edge; the status texts centered across the + full width (ZStack so the logo doesn't skew the centering). No footer — + overflow beyond the visible rows is inferable from the count. */} + + + {renderLogo(13, primaryForeground)} + + + + + + {agentsLabel} + + {attentionSuffix ? ( + · + ) : null} + {attentionSuffix ? ( - {agentsLabel} + {attentionSuffix} - {attentionSuffix ? ( - · - ) : null} - {attentionSuffix ? ( - - {attentionSuffix} - - ) : null} - - - - {row0 ? renderCompactRow(row0) : null} - {row1 ? renderCompactRow(row1) : null} - {row2 ? renderCompactRow(row2) : null} - {row3 ? renderCompactRow(row3) : null} - {row4 ? renderCompactRow(row4) : null} - - ), - // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family): - // brand + count, then the single most important agent with its status glyph. - bannerSmall: ( - + ) : null} + + + + {row0 ? renderCompactRow(row0) : null} + {row1 ? renderCompactRow(row1) : null} + {row2 ? renderCompactRow(row2) : null} + {row3 ? renderCompactRow(row3) : null} + {row4 ? renderCompactRow(row4) : null} + + ); + // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family) + // and the home-screen systemSmall widget. + const bannerSmall = ( + + + {renderLogo(14, primaryForeground)} + + {attentionRows.length > 0 ? summary : activeLabel} + + + + {row0 ? ( - {renderLogo(14, primaryForeground)} - {attentionRows.length > 0 ? summary : activeLabel} + {row0.threadTitle} + + {row0.status} + - {row0 ? ( - - - {row0.threadTitle} - - - - {row0.status} - - - ) : null} - - ), + ) : null} + + ); + + if (isHomeScreenWidget) { + return (useCompactWidget ? bannerSmall : banner) as unknown as LiveActivityLayout; + } + + return { + banner, + bannerSmall, compactLeading: renderLogo(14, tint), compactTrailing: ( @@ -344,7 +391,7 @@ export function AgentActivity( {renderLogo(15, tint)} - {allDone ? doneLabel : `${props.activeCount}`} + {allDone ? doneLabel : `${activeCount}`} ), @@ -378,4 +425,17 @@ export function AgentActivity( }; } +export const AgentActivityWidget = createWidget( + "AgentActivity", + AgentActivity as never, +); + +export function publishAgentActivityWidget(props: AgentActivityProps): void { + try { + AgentActivityWidget.updateSnapshot(props); + } catch { + // Personal-team and Android builds have no widget extension. + } +} + export default createLiveActivity("AgentActivity", AgentActivity); From 4c1020ba479dbecab5bf38ec70300a9bcf9eb2f6 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 09:12:17 -0400 Subject: [PATCH 02/19] fix: address review comment on apps/mobile/src/widgets/AgentActivity.tsx Use the compact banner for accessoryRectangular so lock-screen widgets are not clipped. --- apps/mobile/src/widgets/AgentActivity.test.ts | 10 ++++++++++ apps/mobile/src/widgets/AgentActivity.tsx | 7 ++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index bae8df1ee5c8..d75491cce238 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -290,6 +290,16 @@ describe("AgentActivity widget layout", () => { expect(smallJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); expect(smallJson).toContain('"all":10'); expect(smallJson).not.toContain('"all":14'); + + const accessory = AgentActivity({ ...props, activities: [makeRow({})] }, { + ...environment, + widgetFamily: "accessoryRectangular", + } as never); + const accessoryJson = JSON.stringify(accessory); + expect(accessory).not.toHaveProperty("banner"); + expect(accessoryJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); + expect(accessoryJson).toContain('"all":10'); + expect(accessoryJson).not.toContain('"all":14'); }); it("does not apply containerBackground to the Live Activity layout", () => { diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index a8210853cc6b..e16a70f432e9 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -80,7 +80,8 @@ export function AgentActivity( const useCompactWidget = widgetFamily === "systemSmall" || widgetFamily === "accessoryCircular" || - widgetFamily === "accessoryInline"; + widgetFamily === "accessoryInline" || + widgetFamily === "accessoryRectangular"; // expo-widgets 56 stopped applying this natively. iOS 17+ home-screen // widgets that omit it render "Please adopt containerBackground API". const homeScreenBackground = isHomeScreenWidget ? [containerBackground("clear", "widget")] : []; @@ -322,8 +323,8 @@ export function AgentActivity( {row4 ? renderCompactRow(row4) : null} ); - // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family) - // and the home-screen systemSmall widget. + // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family), + // the home-screen systemSmall widget, and lock-screen accessory families. const bannerSmall = ( Date: Thu, 13 Aug 2026 09:12:18 -0400 Subject: [PATCH 03/19] fix: address review comment on apps/mobile/src/features/agent-awareness/remoteRegistration.ts Publish the home-screen widget from the relay aggregate outside Live Activity priming. --- .../remoteRegistration.test.ts | 111 ++++++++++++++++-- .../agent-awareness/remoteRegistration.ts | 17 ++- 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index fd106ea408f9..66bdea589c4e 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -41,6 +41,7 @@ import { shouldRegisterAgentAwarenessDeviceForProvider, unregisterAgentAwarenessConnection, } from "./remoteRegistration"; +import { publishAgentActivityWidget } from "../../widgets/AgentActivity"; import * as Notifications from "expo-notifications"; const secureStore = vi.hoisted(() => new Map()); @@ -195,6 +196,54 @@ function proofIat(proof: string): number { return decoded.iat; } +const activeAgentActivitySnapshot = { + aggregate: { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: "2026-05-25T13:07:00.000Z", + activities: [ + { + environmentId: "env-1" as EnvironmentId, + threadId: "thread-1", + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running" as const, + status: "Working", + updatedAt: "2026-05-25T13:07:00.000Z", + deepLink: "/threads/env-1/thread-1", + }, + ], + }, +}; + +function snapshotRelayLayer() { + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + return Layer.succeed( + ManagedRelay.ManagedRelayClient, + ManagedRelay.ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => Effect.die("unused"), + listDevices: () => Effect.die("unused"), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + getEnvironmentStatus: () => Effect.die("unused"), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.succeed({ ok: true }), + getAgentActivitySnapshot: () => Effect.succeed(activeAgentActivitySnapshot), + resetTokenCache: Effect.void, + }), + ); +} + function savedConnection(): SavedRemoteConnection { return { environmentId: "env-1" as EnvironmentId, @@ -251,8 +300,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); - widgetMocks.start.mockClear(); + widgetMocks.start.mockReset(); + widgetMocks.start.mockReturnValue({}); environmentConfigsMock.configs.clear(); + vi.mocked(publishAgentActivityWidget).mockClear(); }); it("preserves disabled Live Activity preferences in relay registrations", () => { @@ -458,6 +509,48 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, ); + it.effect("publishes the home-screen widget when a Live Activity is already armed", () => { + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + start: vi.fn(), + update: vi.fn(), + end: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(activity.start).not.toHaveBeenCalled(); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("publishes the home-screen widget when Live Activities are disabled", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + }), + ); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + it.effect( "re-registers active Live Activity tokens when the app returns to the foreground", () => { @@ -546,11 +639,13 @@ describe("makeRelayDeviceRegistrationRequest", () => { return Effect.gen(function* () { yield* runBackgroundOperations(); - expect(fetchMock).toHaveBeenCalledTimes(2); - const [request, init] = fetchMock.mock.calls[1] as unknown as [ - unknown, - RequestInit | undefined, - ]; + const deviceCall = fetchMock.mock.calls.find((call) => { + const request = call[0]; + const url = request instanceof Request ? request.url : String(request); + return url === "https://relay.example.test/v1/mobile/devices"; + }); + expect(deviceCall).toBeDefined(); + const [request, init] = deviceCall as unknown as [unknown, RequestInit | undefined]; const url = request instanceof Request ? request.url : String(request); const method = request instanceof Request ? request.method : init?.method; const headers = request instanceof Request ? request.headers : new Headers(init?.headers); @@ -796,7 +891,9 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* runBackgroundOperations(); expect(backgroundRuntime.pending).toHaveLength(0); - expect(tokenProvider).toHaveBeenCalledTimes(2); + // Device registration retries after the first auth miss, and the + // home-screen widget refresh independently reads the relay token. + expect(tokenProvider).toHaveBeenCalledTimes(3); }).pipe(Effect.provide(relayTestLayer)); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index c416c7345d8b..145fa7e5165a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -1081,6 +1081,17 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< activities = activities.slice(0, 1); } + // Home-screen widgets are independent of the lock-screen card. Publish + // the latest aggregate even when a Live Activity already exists or the + // user has turned Live Activities off; otherwise the widget stays on the + // "Connecting" snapshot from local arming. + const snapshot = yield* readAgentActivitySnapshot(); + if (snapshot) { + publishHomeScreenWidget( + snapshot.aggregate ? widgetPropsFromAggregate(snapshot.aggregate) : idleWidgetProps(), + ); + } + // Activities are only ever created here, in the foreground, where the // update token can be observed and registered immediately — the relay // never remote-starts one (background push-to-start wakes proved too @@ -1100,12 +1111,6 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // The toggle defaults to on: an unset preference (fresh install) must // prime, so only an explicit false blocks it. if (preferences?.liveActivitiesEnabled !== false) { - const snapshot = yield* readAgentActivitySnapshot(); - if (snapshot) { - publishHomeScreenWidget( - snapshot.aggregate ? widgetPropsFromAggregate(snapshot.aggregate) : idleWidgetProps(), - ); - } // The snapshot request yields; an arm-on-send may have created the // card in the meantime. Re-check so two cards are never started. const armedMeanwhile = yield* Effect.try({ From 60b43f7955d716f2045670895c1ab038449a3dea Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 09:22:11 -0400 Subject: [PATCH 04/19] fix(mobile): brand ThreadId in agent-activity snapshot test The Check typecheck job failed because the snapshot fixture used a plain string threadId, which is not assignable to Brand<"ThreadId">. Made-with: Grok 4.6 (T3 Code) --- .../src/features/agent-awareness/remoteRegistration.test.ts | 4 ++-- 1 file changed, 2 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 66bdea589c4e..680a006ea845 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; import { FetchHttpClient } from "effect/unstable/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import type { SavedRemoteConnection } from "../../lib/connection"; import { cryptoLayer } from "../cloud/dpop"; @@ -205,7 +205,7 @@ const activeAgentActivitySnapshot = { activities: [ { environmentId: "env-1" as EnvironmentId, - threadId: "thread-1", + threadId: "thread-1" as ThreadId, projectTitle: "Project", threadTitle: "Thread", modelTitle: "gpt-5.4", From a6149901282edd3bd1a249d8a9d2f03a0cf619e9 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 11:46:10 -0400 Subject: [PATCH 05/19] refactor(mobile): simplify agent activity widget layouts --- .../remoteRegistration.test.ts | 3 +- .../agent-awareness/remoteRegistration.ts | 11 +- apps/mobile/src/widgets/AgentActivity.test.ts | 108 ++++--- apps/mobile/src/widgets/AgentActivity.tsx | 305 +++++++++++++----- 4 files changed, 297 insertions(+), 130 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 680a006ea845..17aca3df37cb 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -12,6 +12,7 @@ import { FetchHttpClient } from "effect/unstable/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivitySnapshotResponse } from "@t3tools/contracts/relay"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import type { SavedRemoteConnection } from "../../lib/connection"; import { cryptoLayer } from "../cloud/dpop"; @@ -216,7 +217,7 @@ const activeAgentActivitySnapshot = { }, ], }, -}; +} satisfies RelayAgentActivitySnapshotResponse; function snapshotRelayLayer() { Constants.expoConfig!.extra = { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 145fa7e5165a..826c4394f163 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -464,13 +464,6 @@ function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolea ); } -function publishHomeScreenWidget(props: AgentActivityProps): void { - if (typeof publishAgentActivityWidget !== "function") { - return; - } - publishAgentActivityWidget(props); -} - function widgetPropsFromAggregate( aggregate: NonNullable, ): AgentActivityProps { @@ -552,7 +545,7 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { }, ], }; - publishHomeScreenWidget(props); + publishAgentActivityWidget(props); const activity = AgentActivity.start(props); logRegistrationDebug("live activity card armed for local work", { threadTitle: input.threadTitle, @@ -1087,7 +1080,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // "Connecting" snapshot from local arming. const snapshot = yield* readAgentActivitySnapshot(); if (snapshot) { - publishHomeScreenWidget( + publishAgentActivityWidget( snapshot.aggregate ? widgetPropsFromAggregate(snapshot.aggregate) : idleWidgetProps(), ); } diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index d75491cce238..78f9250fc524 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import type { WidgetEnvironment } from "expo-widgets"; vi.mock("@expo/ui/swift-ui", () => ({ + Divider: "Divider", HStack: "HStack", Image: "Image", Spacer: "Spacer", @@ -71,6 +73,16 @@ const lightEnvironment = { isLuminanceReduced: false, } as const; +function widgetEnvironment(widgetFamily: WidgetEnvironment["widgetFamily"]): WidgetEnvironment { + return { + date: new Date(0), + widgetFamily, + colorScheme: "dark", + isLuminanceReduced: false, + configuration: undefined, + }; +} + describe("AgentActivity widget layout", () => { it("tints each row by its own phase using the web sidebar's dark palette", () => { const layout = AgentActivity( @@ -82,7 +94,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), ], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("#7dd3fc"); // sky-300: running @@ -101,7 +113,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), ], }, - lightEnvironment as never, + lightEnvironment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("#0284c7"); // sky-600: running @@ -125,7 +137,7 @@ describe("AgentActivity widget layout", () => { }), ], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner.indexOf("Blocked thread")).toBeGreaterThan(-1); @@ -142,7 +154,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }), ], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("3 active agents"); @@ -159,7 +171,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }), ], }, - environment as never, + environment, ); expect(JSON.stringify(layout.compactLeading)).toContain("#a5b4fc"); // indigo-300 expect(JSON.stringify(layout.compactTrailing)).toContain("Input"); @@ -181,7 +193,7 @@ describe("AgentActivity widget layout", () => { }), ], }, - environment as never, + environment, ); expect(JSON.stringify(layout.banner)).toContain( '"widgetURL":"t3code://threads/env-1/thread-2"', @@ -189,19 +201,19 @@ describe("AgentActivity widget layout", () => { }); it("deep links the banner to the first row when nothing needs attention", () => { - const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment as never); + const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment); expect(JSON.stringify(layout.banner)).toContain( '"widgetURL":"t3code://threads/env-1/thread-1"', ); }); it("omits the deep link for unsafe paths and empty aggregates", () => { - expect(JSON.stringify(AgentActivity(props, environment as never))).not.toContain("widgetURL"); + expect(JSON.stringify(AgentActivity(props, environment))).not.toContain("widgetURL"); expect( JSON.stringify( AgentActivity( { ...props, activities: [makeRow({ deepLink: "//evil.example" })] }, - environment as never, + environment, ), ), ).not.toContain("widgetURL"); @@ -215,7 +227,7 @@ describe("AgentActivity widget layout", () => { activeCount: 0, activities: [makeRow({ phase: "completed", status: "Done" })], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("Agent work completed"); @@ -236,7 +248,7 @@ describe("AgentActivity widget layout", () => { activeCount: 0, activities: [makeRow({ phase: "failed", status: "Failed" })], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("Agent work failed"); @@ -260,7 +272,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: "thread-2", phase: "failed", status: "Failed" }), ], }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); expect(banner).toContain("Agent work failed"); @@ -271,30 +283,60 @@ describe("AgentActivity widget layout", () => { expect(JSON.stringify(layout.minimal)).toContain("xmark.octagon.fill"); }); - it("adopts containerBackground and returns a view for home-screen widgets", () => { - const medium = AgentActivity({ ...props, activities: [makeRow({})] }, { - ...environment, - widgetFamily: "systemMedium", - } as never); + it("renders branded, top-aligned home-screen layouts", () => { + const medium = AgentActivity( + { + ...props, + activeCount: 3, + activities: [ + makeRow({ threadTitle: "First thread", projectTitle: "First project" }), + makeRow({ + threadId: "thread-2", + threadTitle: "Second thread", + projectTitle: "Second project", + }), + makeRow({ + threadId: "thread-3", + threadTitle: "Overflow thread", + phase: "completed", + status: "Done", + }), + ], + }, + widgetEnvironment("systemMedium"), + ); const mediumJson = JSON.stringify(medium); expect(medium).not.toHaveProperty("banner"); expect(mediumJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); - expect(mediumJson).toContain('"all":14'); + expect(mediumJson).toContain("T3Mark"); + expect(mediumJson).toContain("Code"); + expect(mediumJson).toContain("3 active agents"); + expect(mediumJson).toContain("arrow.up.right"); + expect(mediumJson).toContain("folder.fill"); + expect(mediumJson.indexOf("T3Mark")).toBeLessThan(mediumJson.indexOf("First thread")); + expect(mediumJson.indexOf("First thread")).toBeLessThan(mediumJson.indexOf("Second thread")); + expect(mediumJson).not.toContain("Overflow thread"); + expect(mediumJson).not.toContain('"all":14'); - const small = AgentActivity({ ...props, activities: [makeRow({})] }, { - ...environment, - widgetFamily: "systemSmall", - } as never); + const small = AgentActivity( + { ...props, activities: [makeRow({})] }, + widgetEnvironment("systemSmall"), + ); const smallJson = JSON.stringify(small); expect(small).not.toHaveProperty("banner"); expect(smallJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); - expect(smallJson).toContain('"all":10'); + expect(smallJson).toContain("T3Mark"); + expect(smallJson).toContain("Code"); + expect(smallJson).toContain("1 active agent"); + expect(smallJson).toContain("folder.fill"); + expect(smallJson.indexOf("T3Mark")).toBeLessThan(smallJson.indexOf("Thread")); + expect(smallJson).not.toContain('"all":10'); expect(smallJson).not.toContain('"all":14'); - const accessory = AgentActivity({ ...props, activities: [makeRow({})] }, { - ...environment, - widgetFamily: "accessoryRectangular", - } as never); + const accessory = AgentActivity( + { ...props, activities: [makeRow({})] }, + widgetEnvironment("accessoryRectangular"), + ); const accessoryJson = JSON.stringify(accessory); expect(accessory).not.toHaveProperty("banner"); expect(accessoryJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); @@ -303,19 +345,13 @@ describe("AgentActivity widget layout", () => { }); it("does not apply containerBackground to the Live Activity layout", () => { - const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment as never); + const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment); expect(layout).toHaveProperty("banner"); expect(JSON.stringify(layout)).not.toContain("containerBackground"); }); it("renders an idle home-screen widget when props are missing", () => { - const view = AgentActivity( - {} as AgentActivityProps, - { - ...environment, - widgetFamily: "systemMedium", - } as never, - ); + const view = AgentActivity({} as AgentActivityProps, widgetEnvironment("systemMedium")); const json = JSON.stringify(view); expect(json).toContain("No active agents"); expect(json).toContain('"containerBackground":{"color":"clear","container":"widget"}'); @@ -331,7 +367,7 @@ describe("AgentActivity widget layout", () => { makeRow({ threadId: `t${n}`, threadTitle: `Thread ${n}` }), ), }, - environment as never, + environment, ); const banner = JSON.stringify(layout.banner); for (const visible of [1, 2, 3, 4, 5]) { diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index e16a70f432e9..1e84f776de1d 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -1,5 +1,5 @@ -import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; -import type { ComponentProps } from "react"; +import { Divider, HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; +import type { ComponentProps, JSX } from "react"; import { containerBackground, font, @@ -14,8 +14,9 @@ import { import { createLiveActivity, createWidget, - type LiveActivityComponent, + type LiveActivityEnvironment, type LiveActivityLayout, + type WidgetEnvironment, } from "expo-widgets"; export type AgentActivityPhase = @@ -47,44 +48,28 @@ export interface AgentActivityProps { readonly activities: ReadonlyArray; } -type LiveActivityEnvironment = Parameters>[1]; - -// Home-screen widgets pass widgetFamily; Live Activities do not. The same -// serialized function serves both surfaces. -type AgentActivityEnvironment = LiveActivityEnvironment & { - readonly widgetFamily?: - | "systemSmall" - | "systemMedium" - | "systemLarge" - | "systemExtraLarge" - | "accessoryCircular" - | "accessoryRectangular" - | "accessoryInline"; -}; - // This function is serialized into the widget extension's JS bundle, so it // must stay self-contained: no references to module-scope helpers, only the // imported view/modifier factories. export function AgentActivity( props: AgentActivityProps, - environment: AgentActivityEnvironment, -): LiveActivityLayout { + environment: WidgetEnvironment, +): JSX.Element; +export function AgentActivity( + props: AgentActivityProps, + environment: LiveActivityEnvironment, +): LiveActivityLayout; +export function AgentActivity( + props: AgentActivityProps, + environment: WidgetEnvironment | LiveActivityEnvironment, +): JSX.Element | LiveActivityLayout { "widget"; // Placeholder / first-paint entries arrive with empty props. Treat missing // fields as idle rather than throwing inside the widget JS runtime. const activities = Array.isArray(props.activities) ? props.activities : []; const activeCount = typeof props.activeCount === "number" ? props.activeCount : 0; - const widgetFamily = environment.widgetFamily; - const isHomeScreenWidget = typeof widgetFamily === "string"; - const useCompactWidget = - widgetFamily === "systemSmall" || - widgetFamily === "accessoryCircular" || - widgetFamily === "accessoryInline" || - widgetFamily === "accessoryRectangular"; - // expo-widgets 56 stopped applying this natively. iOS 17+ home-screen - // widgets that omit it render "Please adopt containerBackground API". - const homeScreenBackground = isHomeScreenWidget ? [containerBackground("clear", "widget")] : []; + const widgetFamily = "widgetFamily" in environment ? environment.widgetFamily : undefined; // Use SwiftUI's semantic label colors rather than fixed hex keyed off the // device color scheme. A Live Activity banner always renders over a dark @@ -268,15 +253,214 @@ export function AgentActivity( ); - const banner = ( + // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family) + // and lock-screen accessory widgets. + const renderCompactLayout = () => ( + + {renderLogo(14, primaryForeground)} + + {attentionRows.length > 0 ? summary : activeLabel} + + + + {row0 ? ( + + + {row0.threadTitle} + + + + {row0.status} + + + ) : null} + + ); + + if ( + widgetFamily === "accessoryCircular" || + widgetFamily === "accessoryInline" || + widgetFamily === "accessoryRectangular" + ) { + return renderCompactLayout(); + } + + if (widgetFamily) { + // iOS supplies content margins for home-screen widgets, so adding explicit + // padding here would double-inset the layout. + const widgetModifiers = [ + ...(deepLink ? [widgetURL(deepLink)] : []), + containerBackground("clear", "widget"), + ]; + const homeSummaryTint = allDone && !hasRows ? secondaryForeground : headerTint; + const homeSummary = attentionSuffix || agentsLabel; + const renderHomeProjectIcon = (size: number) => + renderGlyph("folder.fill", size, secondaryForeground); + + if (widgetFamily === "systemSmall") { + return ( + + + {renderLogo(12, primaryForeground)} + + Code + + + {renderGlyph("arrow.up.right", 10, secondaryForeground)} + + + {homeSummary} + + {heroRow ? ( + + {heroRow.threadTitle} + + ) : ( + + {outcomeLabel} + + )} + {heroRow ? ( + + {renderHomeProjectIcon(11)} + + {heroRow.projectTitle} + + + + {heroRow.status} + + + ) : null} + + + ); + } + + const renderHomeRow = (row: AgentActivityRowProps) => ( + + {renderHomeProjectIcon(17)} + + + {row.threadTitle} + + + {row.projectTitle} + + + + + {row.status} + + + ); + + return ( + + + {renderLogo(13, primaryForeground)} + + Code + + + + {homeSummary} + + {renderGlyph("arrow.up.right", 10, secondaryForeground)} + + {row0 ? ( + renderHomeRow(row0) + ) : ( + + {outcomeLabel} + + )} + {row1 ? : null} + {row1 ? renderHomeRow(row1) : null} + + + ); + } + + const banner = ( + {/* Logo pinned to the leading edge; the status texts centered across the full width (ZStack so the logo doesn't skew the centering). No footer — @@ -323,54 +507,10 @@ export function AgentActivity( {row4 ? renderCompactRow(row4) : null} ); - // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family), - // the home-screen systemSmall widget, and lock-screen accessory families. - const bannerSmall = ( - - - {renderLogo(14, primaryForeground)} - - {attentionRows.length > 0 ? summary : activeLabel} - - - - {row0 ? ( - - - {row0.threadTitle} - - - - {row0.status} - - - ) : null} - - ); - - if (isHomeScreenWidget) { - return (useCompactWidget ? bannerSmall : banner) as unknown as LiveActivityLayout; - } return { banner, - bannerSmall, + bannerSmall: renderCompactLayout(), compactLeading: renderLogo(14, tint), compactTrailing: ( @@ -426,10 +566,7 @@ export function AgentActivity( }; } -export const AgentActivityWidget = createWidget( - "AgentActivity", - AgentActivity as never, -); +export const AgentActivityWidget = createWidget("AgentActivity", AgentActivity); export function publishAgentActivityWidget(props: AgentActivityProps): void { try { From 9f25c268a1a6bca221c9b59297a61f81d3b30348 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 11:51:51 -0400 Subject: [PATCH 06/19] fix(mobile): deep-link lock-screen accessory widgets Accessory circular/inline/rectangular layouts returned before widgetURL was applied, so taps on those families did not open the thread. Made-with: Grok 4.6 (T3 Code) --- apps/mobile/src/widgets/AgentActivity.test.ts | 21 +++++++++++++++++++ apps/mobile/src/widgets/AgentActivity.tsx | 1 + 2 files changed, 22 insertions(+) diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index 78f9250fc524..e0b1f86c0b6e 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -340,10 +340,31 @@ describe("AgentActivity widget layout", () => { const accessoryJson = JSON.stringify(accessory); expect(accessory).not.toHaveProperty("banner"); expect(accessoryJson).toContain('"containerBackground":{"color":"clear","container":"widget"}'); + expect(accessoryJson).toContain('"widgetURL":"t3code://threads/env-1/thread-1"'); expect(accessoryJson).toContain('"all":10'); expect(accessoryJson).not.toContain('"all":14'); }); + it("deep links lock-screen accessory widgets", () => { + const accessory = AgentActivity( + { + ...props, + activeCount: 2, + activities: [ + makeRow({}), + makeRow({ + threadId: "thread-2", + phase: "waiting_for_approval", + status: "Approval", + deepLink: "/threads/env-1/thread-2", + }), + ], + }, + widgetEnvironment("accessoryCircular"), + ); + expect(JSON.stringify(accessory)).toContain('"widgetURL":"t3code://threads/env-1/thread-2"'); + }); + it("does not apply containerBackground to the Live Activity layout", () => { const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment); expect(layout).toHaveProperty("banner"); diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 1e84f776de1d..606c58209475 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -261,6 +261,7 @@ export function AgentActivity( spacing={5} modifiers={[ padding({ all: 10 }), + ...(deepLink ? [widgetURL(deepLink)] : []), ...(widgetFamily ? [containerBackground("clear", "widget")] : []), ]} > From b44c8d8a3ffd946513289b070586bca9e2e60afe Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 13 Aug 2026 12:12:35 -0400 Subject: [PATCH 07/19] fix(mobile): show status icons in home widgets --- apps/mobile/src/widgets/AgentActivity.test.ts | 14 +++++++++++--- apps/mobile/src/widgets/AgentActivity.tsx | 8 ++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index e0b1f86c0b6e..9161d5479329 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -283,7 +283,7 @@ describe("AgentActivity widget layout", () => { expect(JSON.stringify(layout.minimal)).toContain("xmark.octagon.fill"); }); - it("renders branded, top-aligned home-screen layouts", () => { + it("renders branded, top-aligned home-screen layouts with status icons", () => { const medium = AgentActivity( { ...props, @@ -294,6 +294,8 @@ describe("AgentActivity widget layout", () => { threadId: "thread-2", threadTitle: "Second thread", projectTitle: "Second project", + phase: "completed", + status: "Done", }), makeRow({ threadId: "thread-3", @@ -312,7 +314,11 @@ describe("AgentActivity widget layout", () => { expect(mediumJson).toContain("Code"); expect(mediumJson).toContain("3 active agents"); expect(mediumJson).toContain("arrow.up.right"); - expect(mediumJson).toContain("folder.fill"); + expect(mediumJson).not.toContain("folder.fill"); + expect(mediumJson).toContain("arrow.triangle.2.circlepath"); + expect(mediumJson).toContain("checkmark.circle.fill"); + expect(mediumJson).toContain("First project"); + expect(mediumJson).toContain("Second project"); expect(mediumJson.indexOf("T3Mark")).toBeLessThan(mediumJson.indexOf("First thread")); expect(mediumJson.indexOf("First thread")).toBeLessThan(mediumJson.indexOf("Second thread")); expect(mediumJson).not.toContain("Overflow thread"); @@ -328,7 +334,9 @@ describe("AgentActivity widget layout", () => { expect(smallJson).toContain("T3Mark"); expect(smallJson).toContain("Code"); expect(smallJson).toContain("1 active agent"); - expect(smallJson).toContain("folder.fill"); + expect(smallJson).not.toContain("folder.fill"); + expect(smallJson).toContain("arrow.triangle.2.circlepath"); + expect(smallJson).toContain("Project"); expect(smallJson.indexOf("T3Mark")).toBeLessThan(smallJson.indexOf("Thread")); expect(smallJson).not.toContain('"all":10'); expect(smallJson).not.toContain('"all":14'); diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 606c58209475..6a67af472b6d 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -315,8 +315,8 @@ export function AgentActivity( ]; const homeSummaryTint = allDone && !hasRows ? secondaryForeground : headerTint; const homeSummary = attentionSuffix || agentsLabel; - const renderHomeProjectIcon = (size: number) => - renderGlyph("folder.fill", size, secondaryForeground); + const renderHomeStatusIcon = (row: AgentActivityRowProps, size: number) => + renderGlyph(phaseSymbol(row.phase), size, phaseTint(row.phase)); if (widgetFamily === "systemSmall") { return ( @@ -360,7 +360,7 @@ export function AgentActivity( )} {heroRow ? ( - {renderHomeProjectIcon(11)} + {renderHomeStatusIcon(heroRow, 11)} @@ -386,7 +386,7 @@ export function AgentActivity( const renderHomeRow = (row: AgentActivityRowProps) => ( - {renderHomeProjectIcon(17)} + {renderHomeStatusIcon(row, 17)} Date: Thu, 13 Aug 2026 12:35:41 -0400 Subject: [PATCH 08/19] fix(mobile): preserve widget symbol proportions --- apps/mobile/src/widgets/AgentActivity.test.ts | 2 ++ apps/mobile/src/widgets/AgentActivity.tsx | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index 9161d5479329..ba12a9720315 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -12,6 +12,7 @@ vi.mock("@expo/ui/swift-ui", () => ({ })); vi.mock("@expo/ui/swift-ui/modifiers", () => ({ + aspectRatio: (value: unknown) => ({ aspectRatio: value }), containerBackground: (color: unknown, container: unknown) => ({ containerBackground: { color, container }, }), @@ -315,6 +316,7 @@ describe("AgentActivity widget layout", () => { expect(mediumJson).toContain("3 active agents"); expect(mediumJson).toContain("arrow.up.right"); expect(mediumJson).not.toContain("folder.fill"); + expect(mediumJson).toContain('"aspectRatio":{"contentMode":"fit"}'); expect(mediumJson).toContain("arrow.triangle.2.circlepath"); expect(mediumJson).toContain("checkmark.circle.fill"); expect(mediumJson).toContain("First project"); diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 6a67af472b6d..32b38f4a1078 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -1,6 +1,7 @@ import { Divider, HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; import type { ComponentProps, JSX } from "react"; import { + aspectRatio, containerBackground, font, foregroundStyle, @@ -196,10 +197,14 @@ export function AgentActivity( }; // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to - // the image; size + tint them through a container the resizable symbol fills. + // the image; size + tint them through a container. Preserve the symbol's + // intrinsic aspect ratio when the resizable image fills that frame. const renderGlyph = (systemName: SFName, size: number, color: string) => ( - + ); From a0a7b4c60d797e6e127feabccb8e6a66746eefc9 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 25 Aug 2026 22:38:26 -0400 Subject: [PATCH 09/19] fix(mobile): discard stale widget snapshots --- .../remoteRegistration.test.ts | 134 +++++++++++++++++- .../agent-awareness/remoteRegistration.ts | 39 ++++- 2 files changed, 163 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 17aca3df37cb..d25ddec87d25 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -5,8 +5,10 @@ import * as NodeCrypto from "node:crypto"; import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import Constants from "expo-constants"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { FetchHttpClient } from "effect/unstable/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; @@ -219,7 +221,10 @@ const activeAgentActivitySnapshot = { }, } satisfies RelayAgentActivitySnapshotResponse; -function snapshotRelayLayer() { +function snapshotRelayLayer( + getAgentActivitySnapshot: () => Effect.Effect = () => + Effect.succeed(activeAgentActivitySnapshot), +) { Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/", @@ -239,7 +244,7 @@ function snapshotRelayLayer() { registerDevice: () => Effect.die("unused"), unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.succeed({ ok: true }), - getAgentActivitySnapshot: () => Effect.succeed(activeAgentActivitySnapshot), + getAgentActivitySnapshot, resetTokenCache: Effect.void, }), ); @@ -582,7 +587,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, ); - it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => { + it("ends local Live Activities and clears the home-screen widget on cloud sign-out", () => { const end = vi.fn(() => Promise.resolve()); const activity = { getPushToken: vi.fn(() => Promise.resolve("activity-token")), @@ -596,6 +601,14 @@ describe("makeRelayDeviceRegistrationRequest", () => { setAgentAwarenessRelayTokenProvider(null); expect(end).toHaveBeenCalledWith("immediate"); + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + title: "T3 Code", + subtitle: "No active agents", + activeCount: 0, + activities: [], + }), + ); expect(appStateMock.listeners).toHaveLength(0); }); @@ -1031,4 +1044,119 @@ describe("makeRelayDeviceRegistrationRequest", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(widgetMocks.start).toHaveBeenCalledTimes(1); }); + it.effect("refreshes the home-screen widget after arming local agent work", () => { + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + widgetMocks.start.mockReturnValueOnce(activity); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("discards an older widget snapshot when a newer refresh finishes first", () => + Effect.gen(function* () { + const firstReadStarted = yield* Deferred.make(); + const finishFirstRead = yield* Deferred.make(); + const olderSnapshot = { + aggregate: { + ...activeAgentActivitySnapshot.aggregate, + updatedAt: "2026-05-25T13:06:00.000Z", + activities: [ + { + ...activeAgentActivitySnapshot.aggregate.activities[0], + status: "Older status", + updatedAt: "2026-05-25T13:06:00.000Z", + }, + ], + }, + } satisfies RelayAgentActivitySnapshotResponse; + let readCount = 0; + const layer = snapshotRelayLayer(() => { + readCount++; + if (readCount === 1) { + return Deferred.succeed(firstReadStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishFirstRead)), + Effect.as(olderSnapshot), + ); + } + return Effect.succeed(activeAgentActivitySnapshot); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + + const olderRefresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(firstReadStarted); + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(layer)); + yield* Deferred.succeed(finishFirstRead, undefined); + yield* Fiber.join(olderRefresh); + + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(1); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect("does not restore an in-flight widget snapshot after cloud sign-out", () => + Effect.gen(function* () { + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + const layer = snapshotRelayLayer(() => + Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + Effect.as(activeAgentActivitySnapshot), + ), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(readStarted); + setAgentAwarenessRelayTokenProvider(null); + yield* Deferred.succeed(finishRead, undefined); + yield* Fiber.join(refresh); + + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(1); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + subtitle: "No active agents", + activeCount: 0, + activities: [], + }), + ); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 826c4394f163..a60db9a0bc0f 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -118,6 +118,7 @@ let activeLiveActivityRegistrationRetry: ReturnType | null = let relayTokenProvider: (() => Promise) | null = null; let relayTokenProviderIdentity: string | null = null; let deviceRegistrationGeneration = 0; +let widgetRefreshGeneration = 0; let activeDeviceRegistration: { readonly input: DeviceRegistrationInput; operation: Promise; @@ -200,6 +201,7 @@ export function setAgentAwarenessRelayTokenProvider( // Without a signed-in user the relay can no longer update or end these // activities, so they would sit orphaned on the lock screen. endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); + publishAgentActivityWidget(idleWidgetProps()); setRegistrationStatus("unknown"); // Sign-out is the only thing that invalidates a stored registration, so the // next sign-in re-registers. @@ -551,7 +553,10 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { threadTitle: input.threadTitle, }); runRegistrationInBackground( - registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid), + registerLiveActivityPushToken({ activity }).pipe( + Effect.ensuring(refreshAgentActivityWidget()), + Effect.asVoid, + ), "live activity arming after local task start failed", ); } catch (error) { @@ -582,6 +587,30 @@ function readAgentActivitySnapshot(): Effect.Effect< ); } +function refreshAgentActivityWidget(): Effect.Effect< + RelayAgentActivitySnapshotResponse | null, + never, + ManagedRelay.ManagedRelayClient +> { + return Effect.gen(function* () { + const expectedDeviceGeneration = deviceRegistrationGeneration; + const expectedRefreshGeneration = ++widgetRefreshGeneration; + const snapshot = yield* readAgentActivitySnapshot(); + if ( + expectedDeviceGeneration !== deviceRegistrationGeneration || + expectedRefreshGeneration !== widgetRefreshGeneration + ) { + return null; + } + if (snapshot) { + publishAgentActivityWidget( + snapshot.aggregate ? widgetPropsFromAggregate(snapshot.aggregate) : idleWidgetProps(), + ); + } + return snapshot; + }); +} + function registerLiveActivityWithRelay( body: RelayLiveActivityRegistrationRequest, ): Effect.Effect { @@ -902,6 +931,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { relayTokenProvider = null; relayTokenProviderIdentity = null; deviceRegistrationGeneration++; + widgetRefreshGeneration++; activeDeviceRegistration = null; pendingDeviceRegistration = null; registrationStatus = "unknown"; @@ -1078,12 +1108,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // the latest aggregate even when a Live Activity already exists or the // user has turned Live Activities off; otherwise the widget stays on the // "Connecting" snapshot from local arming. - const snapshot = yield* readAgentActivitySnapshot(); - if (snapshot) { - publishAgentActivityWidget( - snapshot.aggregate ? widgetPropsFromAggregate(snapshot.aggregate) : idleWidgetProps(), - ); - } + const snapshot = yield* refreshAgentActivityWidget(); // Activities are only ever created here, in the foreground, where the // update token can be observed and registered immediately — the relay From 616e68b23cb57b527e25b724843f47053fbd16f3 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 4 Sep 2026 07:50:40 -0400 Subject: [PATCH 10/19] fix(mobile): refresh widget when activity start fails --- .../remoteRegistration.test.ts | 32 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 4 +++ 2 files changed, 36 insertions(+) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d25ddec87d25..d754a12b0e90 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1078,6 +1078,38 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(snapshotRelayLayer())); }); + it.effect("refreshes the home-screen widget when local Live Activity arming fails", () => { + widgetMocks.start.mockImplementationOnce(() => { + throw new Error("start failed"); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + it.effect("discards an older widget snapshot when a newer refresh finishes first", () => Effect.gen(function* () { const firstReadStarted = yield* Deferred.make(); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a60db9a0bc0f..20a6d9c421dd 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -561,6 +561,10 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { ); } catch (error) { logRegistrationError("live activity arming failed", error); + runRegistrationInBackground( + refreshAgentActivityWidget(), + "widget refresh after live activity arming failed", + ); } } From 7d8d66e6bd44e0ccd29bd13d25bce816d8a37cb7 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 4 Sep 2026 08:17:35 -0400 Subject: [PATCH 11/19] fix(mobile): refresh widgets for every local start --- .../remoteRegistration.test.ts | 64 ++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 66 ++++++++++++------- 2 files changed, 106 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d754a12b0e90..1672d939ec0a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -304,6 +304,8 @@ describe("makeRelayDeviceRegistrationRequest", () => { vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear(); vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear(); vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); + vi.mocked(loadPreferences).mockReset(); + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false } as Preferences); widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); widgetMocks.start.mockReset(); @@ -1066,6 +1068,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); return Effect.gen(function* () { + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); yield* runBackgroundOperations(); expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( @@ -1078,6 +1081,67 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(snapshotRelayLayer())); }); + it.effect("refreshes the home-screen widget when local Live Activities are disabled", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: false, + } as Preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + + return Effect.gen(function* () { + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + yield* runBackgroundOperations(); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + + it.effect("refreshes the home-screen widget when a local Live Activity is already armed", () => { + widgetMocks.getInstances.mockReturnValueOnce([{}] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + backgroundRuntime.pending.length = 0; + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + vi.mocked(loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: true, + } as Preferences); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + + return Effect.gen(function* () { + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + yield* runBackgroundOperations(); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ status: "Working" })], + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + it.effect("refreshes the home-screen widget when local Live Activity arming fails", () => { widgetMocks.start.mockImplementationOnce(() => { throw new Error("start failed"); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 20a6d9c421dd..cf8b9c4c7577 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -512,42 +512,60 @@ export function armAgentAwarenessLiveActivityForLocalWork(input: { void loadPreferences() .catch(() => null) .then((preferences) => { + const widgetProps = localWorkStartingWidgetProps(input); + publishAgentActivityWidget(widgetProps); if (preferences?.liveActivitiesEnabled === false) { + runRegistrationInBackground( + refreshAgentActivityWidget(), + "widget refresh after local task start failed", + ); return; } - armAgentAwarenessLiveActivityForLocalWorkNow(input); + armAgentAwarenessLiveActivityForLocalWorkNow(input, widgetProps); }); } -function armAgentAwarenessLiveActivityForLocalWorkNow(input: { +function localWorkStartingWidgetProps(input: { readonly threadTitle: string; readonly projectTitle: string; -}): void { +}): AgentActivityProps { + const nowIso = new Date(Date.now()).toISOString(); + return { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: nowIso, + activities: [ + { + environmentId: "", + threadId: "", + projectTitle: input.projectTitle, + threadTitle: input.threadTitle, + modelTitle: "", + phase: "starting", + status: "Connecting", + updatedAt: nowIso, + deepLink: "/", + }, + ], + }; +} + +function armAgentAwarenessLiveActivityForLocalWorkNow( + input: { + readonly threadTitle: string; + readonly projectTitle: string; + }, + props: AgentActivityProps, +): void { try { if (AgentActivity.getInstances().length > 0) { + runRegistrationInBackground( + refreshAgentActivityWidget(), + "widget refresh after local task start failed", + ); return; } - const nowIso = new Date(Date.now()).toISOString(); - const props: AgentActivityProps = { - title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: 1, - updatedAt: nowIso, - activities: [ - { - environmentId: "", - threadId: "", - projectTitle: input.projectTitle, - threadTitle: input.threadTitle, - modelTitle: "", - phase: "starting", - status: "Connecting", - updatedAt: nowIso, - deepLink: "/", - }, - ], - }; - publishAgentActivityWidget(props); const activity = AgentActivity.start(props); logRegistrationDebug("live activity card armed for local work", { threadTitle: input.threadTitle, From ca31b31e4aff2534a20adf1c4ad369475ba76248 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 4 Sep 2026 08:28:23 -0400 Subject: [PATCH 12/19] fix(mobile): keep widget snapshots authoritative --- .../agent-awareness/remoteRegistration.test.ts | 2 ++ .../agent-awareness/remoteRegistration.ts | 17 +++++++---------- 2 files changed, 9 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 1672d939ec0a..07a51b4dc0bf 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1102,6 +1102,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* runBackgroundOperations(); expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(1); expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( expect.objectContaining({ activeCount: 1, @@ -1133,6 +1134,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* runBackgroundOperations(); expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(1); expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( expect.objectContaining({ activeCount: 1, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index cf8b9c4c7577..b65003293d03 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -512,8 +512,6 @@ export function armAgentAwarenessLiveActivityForLocalWork(input: { void loadPreferences() .catch(() => null) .then((preferences) => { - const widgetProps = localWorkStartingWidgetProps(input); - publishAgentActivityWidget(widgetProps); if (preferences?.liveActivitiesEnabled === false) { runRegistrationInBackground( refreshAgentActivityWidget(), @@ -521,7 +519,7 @@ export function armAgentAwarenessLiveActivityForLocalWork(input: { ); return; } - armAgentAwarenessLiveActivityForLocalWorkNow(input, widgetProps); + armAgentAwarenessLiveActivityForLocalWorkNow(input); }); } @@ -551,13 +549,10 @@ function localWorkStartingWidgetProps(input: { }; } -function armAgentAwarenessLiveActivityForLocalWorkNow( - input: { - readonly threadTitle: string; - readonly projectTitle: string; - }, - props: AgentActivityProps, -): void { +function armAgentAwarenessLiveActivityForLocalWorkNow(input: { + readonly threadTitle: string; + readonly projectTitle: string; +}): void { try { if (AgentActivity.getInstances().length > 0) { runRegistrationInBackground( @@ -566,6 +561,8 @@ function armAgentAwarenessLiveActivityForLocalWorkNow( ); return; } + const props = localWorkStartingWidgetProps(input); + publishAgentActivityWidget(props); const activity = AgentActivity.start(props); logRegistrationDebug("live activity card armed for local work", { threadTitle: input.threadTitle, From b275abe013ca4e509f94d80b804ae7f17eeac94e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 4 Sep 2026 09:03:13 -0400 Subject: [PATCH 13/19] fix(mobile): avoid repeating idle widget status --- apps/mobile/src/widgets/AgentActivity.test.ts | 12 +++++++----- apps/mobile/src/widgets/AgentActivity.tsx | 19 ++----------------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index ba12a9720315..280fba344fb0 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -382,11 +382,13 @@ describe("AgentActivity widget layout", () => { }); it("renders an idle home-screen widget when props are missing", () => { - const view = AgentActivity({} as AgentActivityProps, widgetEnvironment("systemMedium")); - const json = JSON.stringify(view); - expect(json).toContain("No active agents"); - expect(json).toContain('"containerBackground":{"color":"clear","container":"widget"}'); - expect(json).not.toContain("0 active"); + for (const family of ["systemSmall", "systemMedium"] as const) { + const view = AgentActivity({} as AgentActivityProps, widgetEnvironment(family)); + const json = JSON.stringify(view); + expect(json.match(/No active agents/g)).toHaveLength(1); + expect(json).toContain('"containerBackground":{"color":"clear","container":"widget"}'); + expect(json).not.toContain("0 active"); + } }); it("renders up to five rows in the banner", () => { diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 32b38f4a1078..7078b88b583b 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -358,11 +358,7 @@ export function AgentActivity( > {heroRow.threadTitle} - ) : ( - - {outcomeLabel} - - )} + ) : null} {heroRow ? ( {renderHomeStatusIcon(heroRow, 11)} @@ -443,18 +439,7 @@ export function AgentActivity( {renderGlyph("arrow.up.right", 10, secondaryForeground)} - {row0 ? ( - renderHomeRow(row0) - ) : ( - - {outcomeLabel} - - )} + {row0 ? renderHomeRow(row0) : null} {row1 ? : null} {row1 ? renderHomeRow(row1) : null} From f311e2569f7e8cfffd1cc18e47e5efcaf356551f Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 4 Sep 2026 09:09:47 -0400 Subject: [PATCH 14/19] fix(mobile): refresh widgets after delayed activity tokens --- .../remoteRegistration.test.ts | 27 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 2 +- 2 files changed, 28 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 07a51b4dc0bf..c7ed44e2f095 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -456,6 +456,33 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relayTestLayer)); }); + it.effect("refreshes the widget after a delayed Live Activity push token registers", () => { + let onPushToken: ((event: { pushToken: string }) => void) | undefined; + const activity = { + getPushToken: vi.fn(() => Promise.resolve(null)), + addPushTokenListener: vi.fn((listener: (event: { pushToken: string }) => void) => { + onPushToken = listener; + return { remove: vi.fn() }; + }), + }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); + expect(onPushToken).toBeDefined(); + + onPushToken?.({ pushToken: "delayed-activity-token" }); + yield* runBackgroundOperations(); + + expect(publishAgentActivityWidget).toHaveBeenCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + }), + ); + }).pipe(Effect.provide(snapshotRelayLayer())); + }); + it.effect("preserves Live Activity push-token lookup failures", () => { const cause = new Error("native token lookup failed"); const activity = { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index b65003293d03..5f0fce03e7d6 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -1025,7 +1025,7 @@ export function registerLiveActivityPushToken(input: { runRegistrationInBackground( registerLiveActivityPushTokenValue({ activityPushToken: event.pushToken, - }), + }).pipe(Effect.ensuring(refreshAgentActivityWidget())), "live activity token listener registration failed", ); } From 7489d9208470c6fa00fc60167e44d9912fc5cf1a Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 4 Sep 2026 09:17:59 -0400 Subject: [PATCH 15/19] fix(mobile): preserve local widget activity seeds --- .../remoteRegistration.test.ts | 54 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 4 ++ 2 files changed, 58 insertions(+) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index c7ed44e2f095..37ff1c8f4f9c 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1252,6 +1252,60 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.scoped), ); + it.effect("does not overwrite a local work seed with an in-flight widget snapshot", () => + Effect.gen(function* () { + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + const layer = snapshotRelayLayer(() => + Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + Effect.as({ aggregate: null }), + ), + ); + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + widgetMocks.getInstances + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValue([activity] as never); + widgetMocks.start.mockReturnValueOnce(activity); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + vi.mocked(loadPreferences).mockResolvedValue({ + liveActivitiesEnabled: true, + } as Preferences); + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Deferred.await(readStarted); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Fix the flaky test", + projectTitle: "t3code", + }); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + + yield* Deferred.succeed(finishRead, undefined); + yield* Fiber.join(refresh); + + expect(publishAgentActivityWidget).toHaveBeenCalledTimes(1); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + subtitle: "Agent work in progress", + activities: [expect.objectContaining({ status: "Connecting" })], + }), + ); + }).pipe(Effect.scoped), + ); + it.effect("does not restore an in-flight widget snapshot after cloud sign-out", () => Effect.gen(function* () { const readStarted = yield* Deferred.make(); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 5f0fce03e7d6..c67d9431d49d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -562,6 +562,10 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { return; } const props = localWorkStartingWidgetProps(input); + // This local seed is newer than any relay snapshot already being read. + // Invalidate those reads before publishing so they cannot repaint the + // widget with stale idle or aggregate state when they eventually finish. + widgetRefreshGeneration++; publishAgentActivityWidget(props); const activity = AgentActivity.start(props); logRegistrationDebug("live activity card armed for local work", { From c124f084699df8b152816fda6ab9797996b9236c Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 4 Sep 2026 09:34:31 -0400 Subject: [PATCH 16/19] fix(mobile): stop stale Live Activity priming --- .../remoteRegistration.test.ts | 37 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 4 ++ 2 files changed, 41 insertions(+) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 37ff1c8f4f9c..f8bbc2eff2e3 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1306,6 +1306,43 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.scoped), ); + it.effect("does not prime a Live Activity after cloud sign-out", () => + Effect.gen(function* () { + let preferencesStarted!: () => void; + let finishPreferences!: (preferences: Preferences) => void; + const started = new Promise((resolve) => { + preferencesStarted = resolve; + }); + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockImplementationOnce(() => { + preferencesStarted(); + return preferences; + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(snapshotRelayLayer()), + Effect.forkChild, + ); + yield* Effect.promise(() => started); + + setAgentAwarenessRelayTokenProvider(null); + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + yield* Fiber.join(refresh); + + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + subtitle: "No active agents", + activeCount: 0, + activities: [], + }), + ); + }).pipe(Effect.scoped), + ); + it.effect("does not restore an in-flight widget snapshot after cloud sign-out", () => Effect.gen(function* () { const readStarted = yield* Deferred.make(); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index c67d9431d49d..74d83b8f3b52 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -1099,6 +1099,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { return; } + const expectedDeviceGeneration = deviceRegistrationGeneration; let activities = yield* Effect.try({ try: () => AgentActivity.getInstances(), @@ -1149,6 +1150,9 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< cause, }), }).pipe(Effect.orElseSucceed(() => null)); + if (expectedDeviceGeneration !== deviceRegistrationGeneration || !relayTokenProvider) { + return; + } // The toggle defaults to on: an unset preference (fresh install) must // prime, so only an explicit false blocks it. if (preferences?.liveActivitiesEnabled !== false) { From 0f5ebc8a770a7bd372480ae858b93647a2e3f8ec Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 4 Sep 2026 09:41:06 -0400 Subject: [PATCH 17/19] fix(mobile): refresh activity state before priming --- .../remoteRegistration.test.ts | 42 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 10 +++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index f8bbc2eff2e3..f649044649a6 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1343,6 +1343,48 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.scoped), ); + it.effect("does not prime from a snapshot that became idle while preferences loaded", () => + Effect.gen(function* () { + let preferencesStarted!: () => void; + let finishPreferences!: (preferences: Preferences) => void; + const started = new Promise((resolve) => { + preferencesStarted = resolve; + }); + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockImplementationOnce(() => { + preferencesStarted(); + return preferences; + }); + let readCount = 0; + const layer = snapshotRelayLayer(() => { + readCount++; + return Effect.succeed(readCount === 1 ? activeAgentActivitySnapshot : { aggregate: null }); + }); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( + Effect.provide(layer), + Effect.forkChild, + ); + yield* Effect.promise(() => started); + + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + yield* Fiber.join(refresh); + + expect(readCount).toBe(2); + expect(widgetMocks.start).not.toHaveBeenCalled(); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + subtitle: "No active agents", + activeCount: 0, + activities: [], + }), + ); + }).pipe(Effect.scoped), + ); + it.effect("does not restore an in-flight widget snapshot after cloud sign-out", () => Effect.gen(function* () { const readStarted = yield* Deferred.make(); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 74d83b8f3b52..913bd954713e 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -1132,7 +1132,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // the latest aggregate even when a Live Activity already exists or the // user has turned Live Activities off; otherwise the widget stays on the // "Connecting" snapshot from local arming. - const snapshot = yield* refreshAgentActivityWidget(); + yield* refreshAgentActivityWidget(); // Activities are only ever created here, in the foreground, where the // update token can be observed and registered immediately — the relay @@ -1164,8 +1164,12 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray>)); if (armedMeanwhile.length > 0) { activities = [...armedMeanwhile]; - } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { - const aggregate = snapshot.aggregate; + } else { + const latestSnapshot = yield* refreshAgentActivityWidget(); + if (!latestSnapshot?.aggregate || latestSnapshot.aggregate.activeCount <= 0) { + return; + } + const aggregate = latestSnapshot.aggregate; const primed = yield* Effect.try({ try: () => AgentActivity.start(widgetPropsFromAggregate(aggregate)), catch: (cause) => From ee7365cf110af04a0298e631d68377d4a765e906 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:53:40 -0700 Subject: [PATCH 18/19] fix(mobile): discard widget work after account changes --- .../remoteRegistration.test.ts | 71 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 4 ++ 2 files changed, 75 insertions(+) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index f649044649a6..02d315c89a53 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -314,6 +314,77 @@ describe("makeRelayDeviceRegistrationRequest", () => { vi.mocked(publishAgentActivityWidget).mockClear(); }); + it.each(["sign-out", "account switch"])( + "does not restore local-work widget data after %s during preference loading", + async (transition) => { + let finishPreferences!: (preferences: Preferences) => void; + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Previous account thread", + projectTitle: "Previous account project", + }); + expect(loadPreferences).toHaveBeenCalledTimes(1); + // Register the same catch/then depth after the production continuation. + // Its receipt settles after the already-registered preference callback, + // without a timer, polling, or executing queued relay operations. + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + + setAgentAwarenessRelayTokenProvider(null); + if (transition === "account switch") { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-b"), "user-b"); + } + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, activities: [] }), + ); + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + await preferenceCallbackDrained; + + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ activeCount: 0, activities: [] }), + ); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }, + ); + + it("still arms local work after a same-account token refresh during preference loading", async () => { + let finishPreferences!: (preferences: Preferences) => void; + const preferences = new Promise((resolve) => { + finishPreferences = resolve; + }); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + environmentConfigsMock.configs.set("env-1", { + environment: { capabilities: { agentActivityPublishing: true } }, + }); + + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: "env-1" as EnvironmentId, + threadTitle: "Current account thread", + projectTitle: "Current account project", + }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("refreshed-token-user-a"), "user-a"); + finishPreferences({ liveActivitiesEnabled: true } as Preferences); + await preferenceCallbackDrained; + + expect(widgetMocks.start).toHaveBeenCalledTimes(1); + expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( + expect.objectContaining({ + activeCount: 1, + activities: [expect.objectContaining({ threadTitle: "Current account thread" })], + }), + ); + }); + it("preserves disabled Live Activity preferences in relay registrations", () => { expect( makeRelayDeviceRegistrationRequest({ diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 913bd954713e..bfdd9865c30a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -509,9 +509,13 @@ export function armAgentAwarenessLiveActivityForLocalWork(input: { }); return; } + const expectedDeviceGeneration = deviceRegistrationGeneration; void loadPreferences() .catch(() => null) .then((preferences) => { + if (deviceRegistrationGeneration !== expectedDeviceGeneration || !relayTokenProvider) { + return; + } if (preferences?.liveActivitiesEnabled === false) { runRegistrationInBackground( refreshAgentActivityWidget(), From 845ed3d2d1c37e12c940871669fc7dfb75f58a4f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:03:46 -0700 Subject: [PATCH 19/19] test(mobile): await widget preference callbacks --- .../remoteRegistration.test.ts | 24 ++++++++++++------- 1 file changed, 16 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 02d315c89a53..eccec2b878d2 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -1155,18 +1155,20 @@ describe("makeRelayDeviceRegistrationRequest", () => { environmentConfigsMock.configs.set("env-1", { environment: { capabilities: { agentActivityPublishing: true } }, }); - vi.mocked(loadPreferences).mockResolvedValueOnce({ + const preferences = Promise.resolve({ liveActivitiesEnabled: true, } as Preferences); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); armAgentAwarenessLiveActivityForLocalWork({ environmentId: "env-1" as EnvironmentId, threadTitle: "Fix the flaky test", projectTitle: "t3code", }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); return Effect.gen(function* () { - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + yield* Effect.promise(() => preferenceCallbackDrained); yield* runBackgroundOperations(); expect(publishAgentActivityWidget).toHaveBeenLastCalledWith( @@ -1185,18 +1187,20 @@ describe("makeRelayDeviceRegistrationRequest", () => { environmentConfigsMock.configs.set("env-1", { environment: { capabilities: { agentActivityPublishing: true } }, }); - vi.mocked(loadPreferences).mockResolvedValueOnce({ + const preferences = Promise.resolve({ liveActivitiesEnabled: false, } as Preferences); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); armAgentAwarenessLiveActivityForLocalWork({ environmentId: "env-1" as EnvironmentId, threadTitle: "Fix the flaky test", projectTitle: "t3code", }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); return Effect.gen(function* () { - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + yield* Effect.promise(() => preferenceCallbackDrained); yield* runBackgroundOperations(); expect(widgetMocks.start).not.toHaveBeenCalled(); @@ -1217,18 +1221,20 @@ describe("makeRelayDeviceRegistrationRequest", () => { environmentConfigsMock.configs.set("env-1", { environment: { capabilities: { agentActivityPublishing: true } }, }); - vi.mocked(loadPreferences).mockResolvedValueOnce({ + const preferences = Promise.resolve({ liveActivitiesEnabled: true, } as Preferences); + vi.mocked(loadPreferences).mockReturnValueOnce(preferences); armAgentAwarenessLiveActivityForLocalWork({ environmentId: "env-1" as EnvironmentId, threadTitle: "Fix the flaky test", projectTitle: "t3code", }); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); return Effect.gen(function* () { - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + yield* Effect.promise(() => preferenceCallbackDrained); yield* runBackgroundOperations(); expect(widgetMocks.start).not.toHaveBeenCalled(); @@ -1346,9 +1352,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { environmentConfigsMock.configs.set("env-1", { environment: { capabilities: { agentActivityPublishing: true } }, }); - vi.mocked(loadPreferences).mockResolvedValue({ + const preferences = Promise.resolve({ liveActivitiesEnabled: true, } as Preferences); + vi.mocked(loadPreferences).mockReturnValue(preferences); const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe( Effect.provide(layer), @@ -1361,7 +1368,8 @@ describe("makeRelayDeviceRegistrationRequest", () => { threadTitle: "Fix the flaky test", projectTitle: "t3code", }); - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + const preferenceCallbackDrained = preferences.catch(() => null).then(() => undefined); + yield* Effect.promise(() => preferenceCallbackDrained); yield* Deferred.succeed(finishRead, undefined); yield* Fiber.join(refresh);