From 7c261c68f9f5f5f5011e84f1739d26ce2e6d9ce3 Mon Sep 17 00:00:00 2001 From: Andrew Garber Date: Thu, 12 Nov 2026 20:20:05 -0800 Subject: [PATCH 1/6] feat(mobile): show agent activity in an Android home screen widget The Agent Activity widget shipped on iOS only (android: null). Add the Android layout and publisher, and let the foreground refresh repaint the widget on Android, where there is no Live Activity to reconcile. Co-Authored-By: Claude --- apps/mobile/app.config.ts | 10 +- .../remoteRegistration.test.ts | 15 ++ .../agent-awareness/remoteRegistration.ts | 15 +- .../widgets/AgentActivityWidget.android.tsx | 133 ++++++++++++++++++ .../agentActivityWidgetTimeline.test.ts | 34 +++++ .../widgets/agentActivityWidgetTimeline.ts | 30 ++++ .../publishAgentActivityWidget.android.ts | 33 +++++ docs/user/mobile-notifications.md | 2 +- 8 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/widgets/AgentActivityWidget.android.tsx create mode 100644 apps/mobile/src/widgets/publishAgentActivityWidget.android.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 97fe5cf977acc..e0e3dd64c1878 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -194,7 +194,15 @@ const widgetsPlugin: NonNullable[number] = [ name: "AgentActivity", displayName: "Agent Activity", description: "Shows the current state of active T3 Code agents.", - android: null, + android: { + minWidth: 250, + minHeight: 110, + targetCellWidth: 4, + targetCellHeight: 2, + resizeMode: "both", + // The embedded layout renders before first launch; publishing replaces it with stored props. + initialLayout: "./src/widgets/AgentActivityWidget.android.tsx", + }, ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], // Embeds the layout so the gallery preview and a new widget render diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 9085f98058066..ae1ef1d81a422 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -927,6 +927,21 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relay)); }); + it.effect("repaints the Android widget without touching Live Activities", () => + Effect.gen(function* () { + vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); + const relay = fakeRelay(() => Effect.succeed({ aggregate })); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + widgetMocks.getInstances.mockClear(); + + yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)); + + expect(publishWidgetMock).toHaveBeenLastCalledWith({ kind: "snapshot", aggregate }); + expect(widgetMocks.getInstances).not.toHaveBeenCalled(); + expect(widgetMocks.dismissEnded).not.toHaveBeenCalled(); + }), + ); + it.effect("keeps the newest rows when an older overlapping read finishes last", () => Effect.gen(function* () { const newer = { ...aggregate, activeCount: 2 }; diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index ec712608fad94..e47ad0bd6a4dd 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -163,6 +163,11 @@ function canRegisterRemoteLiveActivities(): boolean { return Platform.OS === "ios"; } +// The home-screen widget reads the relay snapshot itself, so it needs no Live Activity. +function canPublishAgentActivityWidget(): boolean { + return Platform.OS === "ios" || Platform.OS === "android"; +} + function canRegisterPushNotifications(): boolean { return Platform.OS === "ios" || Platform.OS === "android"; } @@ -775,7 +780,7 @@ function ensureAppStateListener(): void { } function publishWidget(source: AgentActivityWidgetSource): void { - if (!canRegisterRemoteLiveActivities()) { + if (!canPublishAgentActivityWidget()) { return; } publishAgentActivityWidget(source).catch((error: unknown) => { @@ -983,7 +988,7 @@ function scheduleActiveLiveActivityRegistrationRetry(): void { export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( "remoteRegistration.refreshActiveLiveActivityRemoteRegistration", )(function* (): Effect.fn.Return { - if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { + if (!canPublishAgentActivityWidget() || !relayTokenProvider) { return; } @@ -1014,6 +1019,12 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( return { snapshot }; }); + // Android has no Live Activity to reconcile; its widget is the whole refresh. + if (!canRegisterRemoteLiveActivities()) { + yield* refreshWidget; + return; + } + dismissEndedLocalLiveActivities(); let activities = yield* Effect.try({ try: () => getAgentLiveActivities(), diff --git a/apps/mobile/src/widgets/AgentActivityWidget.android.tsx b/apps/mobile/src/widgets/AgentActivityWidget.android.tsx new file mode 100644 index 0000000000000..2ae26a02cb843 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivityWidget.android.tsx @@ -0,0 +1,133 @@ +import { Button, Column, getMaterialColors, Row, Text } from "@expo/ui/jetpack-compose"; +import { fillMaxSize, fillMaxWidth, padding, paddingAll } from "@expo/ui/jetpack-compose/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; + +import type { AgentActivityPhase } from "./AgentActivity"; +import type { AgentActivityWidgetSnapshot } from "./agentActivityWidgetTimeline"; + +// Android counterpart of the iOS Agent Activity widget. The publisher sorts +// the rows, so the first one is the thread that most needs the user. +export function AgentActivityWidget( + publishedProps: Partial | undefined, + environment: WidgetEnvironment, +) { + "widget"; + // The widget runtime evaluates this function without the app's module scope. + // Before the app's first publish, the embedded layout renders with no props. + const props = publishedProps ?? {}; + const dark = environment.colorScheme === "dark"; + const colors = getMaterialColors({ scheme: dark ? "dark" : "light" }); + const muted = colors.onSurfaceVariant; + const phaseColor = (phase: AgentActivityPhase): string => { + switch (phase) { + case "waiting_for_approval": + return dark ? "#fcd34d" : "#b45309"; + case "waiting_for_input": + return dark ? "#a5b4fc" : "#4f46e5"; + case "failed": + return dark ? "#fca5a5" : "#dc2626"; + case "completed": + return dark ? "#6ee7b7" : "#047857"; + case "stale": + return muted; + case "starting": + case "running": + default: + return dark ? "#7dd3fc" : "#0369a1"; + } + }; + + const loaded = props.activities !== undefined; + // Android has no timeline; an expiry alarm re-renders this layout at `stale.at`. + const rows = + props.stale && Date.now() >= props.stale.at ? props.stale.activities : (props.activities ?? []); + const attentionCount = rows.filter( + (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + ).length; + const workingCount = rows.filter( + (row) => row.phase === "running" || row.phase === "starting", + ).length; + const summary = + attentionCount > 0 + ? `${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` + : workingCount > 0 + ? `${workingCount} active` + : rows.some((row) => row.phase === "stale") + ? "Out of date" + : rows.some((row) => row.phase === "failed") + ? "Failed" + : rows.length > 0 + ? "Done" + : "T3 Code"; + const limit = 4; + const hidden = rows.length - limit; + + return ( + // Android lacks widgetURL; one card Button routes taps to the app listener. + + ); +} + +export default createWidget>( + "AgentActivity", + AgentActivityWidget, +); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts index 9ffd91aeeeb20..81b137659e201 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -5,6 +5,7 @@ import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay" import { AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, + agentActivityWidgetSnapshot, agentActivityWidgetTimeline, } from "./agentActivityWidgetTimeline"; @@ -69,4 +70,37 @@ describe("agentActivityWidgetTimeline", () => { expect(timeline).toHaveLength(1); expect(timeline[0]?.props.activities[0]?.phase).toBe("failed"); }); + + it("leads the Android snapshot with the thread that needs the user and carries the stale rows", () => { + const snapshot = agentActivityWidgetSnapshot( + { + kind: "snapshot", + aggregate: aggregate([ + row("completed", "Done"), + row("running", "Working"), + row("waiting_for_approval", "Approval"), + ]), + }, + now, + ); + + expect(snapshot.activities.map((entry) => entry.phase)).toEqual([ + "waiting_for_approval", + "running", + "completed", + ]); + expect(snapshot.stale?.at).toBe(now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS); + expect(snapshot.stale?.activities.map((entry) => entry.status)).toEqual([ + "Out of date", + "Out of date", + "Done", + ]); + }); + + it("gives the Android snapshot no stale rows when nothing is in flight", () => { + const snapshot = agentActivityWidgetSnapshot({ kind: "signed-out" }, now); + + expect(snapshot.subtitle).toBe("Sign in to T3 Connect to see agents"); + expect(snapshot.stale).toBeUndefined(); + }); }); diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts index 7a00789cc9b5a..4869ea6b00d9f 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts @@ -59,3 +59,33 @@ export function agentActivityWidgetTimeline(source: AgentActivityWidgetSource, n { date: new Date(now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS), props: staleProps }, ]; } + +export interface AgentActivityWidgetSnapshot extends AgentActivityProps { + // Android has no timeline, so its layout swaps to these rows once `at` passes. + readonly stale?: { readonly at: number; readonly activities: AgentActivityProps["activities"] }; +} + +export function agentActivityWidgetSnapshot( + source: AgentActivityWidgetSource, + now: number, +): AgentActivityWidgetSnapshot { + const [current, stale] = agentActivityWidgetTimeline(source, now); + const { activities } = current!.props; + const priority = (index: number) => { + const phase = activities[index]!.phase; + if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; + if (phase === "failed") return 1; + return phase === "running" || phase === "starting" ? 2 : 3; + }; + // Threads that need the user come first, then failures, then running work. + // The stale rows keep that order so nothing moves when they go out of date. + const order = activities.map((_, index) => index).sort((a, b) => priority(a) - priority(b)); + const ordered = (rows: AgentActivityProps["activities"]) => order.map((index) => rows[index]!); + return { + ...current!.props, + activities: ordered(activities), + ...(stale + ? { stale: { at: stale.date.getTime(), activities: ordered(stale.props.activities) } } + : {}), + }; +} diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.android.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.android.ts new file mode 100644 index 0000000000000..9f33d5b667b30 --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.android.ts @@ -0,0 +1,33 @@ +import { requireOptionalNativeModule } from "expo"; +import * as Linking from "expo-linking"; + +import { + agentActivityWidgetSnapshot, + type AgentActivityWidgetSource, +} from "./agentActivityWidgetTimeline"; + +let tapUrl: string | undefined; +let listening = false; + +export async function publishAgentActivityWidget(source: AgentActivityWidgetSource) { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const [{ default: widget }, { addUserInteractionListener }] = await Promise.all([ + import("./AgentActivityWidget"), + import("expo-widgets"), + ]); + const snapshot = agentActivityWidgetSnapshot(source, Date.now()); + const deepLink = snapshot.activities[0]?.deepLink; + tapUrl = Linking.createURL(deepLink?.startsWith("/") ? deepLink.slice(1) : ""); + if (!listening) { + // expo-widgets lacks Android widgetURL. Button events need a live JS runtime to open the app. + listening = true; + addUserInteractionListener((event) => { + if (event.source === "AgentActivity" && tapUrl) void Linking.openURL(tapUrl); + }); + } + widget.updateSnapshot(snapshot); + // Android lacks timelines; an alarm re-renders the snapshot when unfinished rows go out of date. + requireOptionalNativeModule<{ schedule: (name: string, deadlines: number[]) => void }>( + "T3WidgetExpiry", + )?.schedule("AgentActivity", snapshot.stale ? [snapshot.stale.at] : []); +} diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 992efd5b7b29a..ab8440b7eca84 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -6,7 +6,7 @@ Enable **Live Activity Updates** on iOS, **Agent Live Updates** on Android 16 an For activity without ordinary alerts on Android 8 or newer, leave **Ongoing agent activity** enabled and turn off **Agent alerts** in Android's notification settings for T3 Code. Activity updates continue, and the same card shows the finished result without a separate alert. On Android 16 and newer, the finished result remains a Live Update until dismissed or expired. Keep app-wide notification permission and **Live updates** enabled so Android can display it. -On iOS you can also add the **Agent Activity** widget to your Home Screen or Lock Screen. It shows the same agents as the Live Activity, using your T3 Connect sign-in. The widget refreshes when you open T3 and shows unfinished work as out of date after 10 minutes. +You can also add the **Agent Activity** widget to your Home Screen, and on iOS to your Lock Screen. It shows the same agents as the Live Activity or Android ongoing activity card, using your T3 Connect sign-in. The widget refreshes when you open T3 and shows unfinished work as out of date after 10 minutes. Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. From b85fdc76dcac375f63b9f62b80251c78290478fb Mon Sep 17 00:00:00 2001 From: Andrew Garber Date: Thu, 12 Nov 2026 22:00:11 -0800 Subject: [PATCH 2/6] fix(mobile): match the Android Agent Activity widget to the iOS layout Same header, phase colors, and right-aligned status as the iOS widget. Co-Authored-By: Claude Opus 5.5 --- .../widgets/AgentActivityWidget.android.tsx | 111 +++++++++--------- 1 file changed, 54 insertions(+), 57 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivityWidget.android.tsx b/apps/mobile/src/widgets/AgentActivityWidget.android.tsx index 2ae26a02cb843..20ca392cb9d08 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.android.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.android.tsx @@ -1,12 +1,13 @@ -import { Button, Column, getMaterialColors, Row, Text } from "@expo/ui/jetpack-compose"; +import { Box, Button, Column, Row, Text } from "@expo/ui/jetpack-compose"; import { fillMaxSize, fillMaxWidth, padding, paddingAll } from "@expo/ui/jetpack-compose/modifiers"; import { createWidget, type WidgetEnvironment } from "expo-widgets"; import type { AgentActivityPhase } from "./AgentActivity"; import type { AgentActivityWidgetSnapshot } from "./agentActivityWidgetTimeline"; -// Android counterpart of the iOS Agent Activity widget. The publisher sorts -// the rows, so the first one is the thread that most needs the user. +// Android counterpart of the iOS Agent Activity widget, with the same header, +// row layout, and phase colors. The publisher sorts the rows, so the first one +// is the thread that most needs the user. export function AgentActivityWidget( publishedProps: Partial | undefined, environment: WidgetEnvironment, @@ -15,25 +16,26 @@ export function AgentActivityWidget( // The widget runtime evaluates this function without the app's module scope. // Before the app's first publish, the embedded layout renders with no props. const props = publishedProps ?? {}; - const dark = environment.colorScheme === "dark"; - const colors = getMaterialColors({ scheme: dark ? "dark" : "light" }); - const muted = colors.onSurfaceVariant; + const light = environment.colorScheme !== "dark"; + const background = light ? "#ffffff" : "#1c1c1e"; + const primary = light ? "#000000" : "#ffffff"; + const secondary = light ? "#8e8e93" : "#98989f"; const phaseColor = (phase: AgentActivityPhase): string => { switch (phase) { case "waiting_for_approval": - return dark ? "#fcd34d" : "#b45309"; + return light ? "#d97706" : "#fcd34d"; case "waiting_for_input": - return dark ? "#a5b4fc" : "#4f46e5"; + return light ? "#4f46e5" : "#a5b4fc"; case "failed": - return dark ? "#fca5a5" : "#dc2626"; + return light ? "#dc2626" : "#fca5a5"; case "completed": - return dark ? "#6ee7b7" : "#047857"; + return light ? "#059669" : "#6ee7b7"; case "stale": - return muted; + return secondary; case "starting": case "running": default: - return dark ? "#7dd3fc" : "#0369a1"; + return light ? "#0284c7" : "#7dd3fc"; } }; @@ -51,77 +53,72 @@ export function AgentActivityWidget( attentionCount > 0 ? `${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` : workingCount > 0 - ? `${workingCount} active` + ? `${props.activeCount ?? workingCount} active` : rows.some((row) => row.phase === "stale") ? "Out of date" : rows.some((row) => row.phase === "failed") ? "Failed" : rows.length > 0 ? "Done" - : "T3 Code"; - const limit = 4; - const hidden = rows.length - limit; + : ""; + const shown = rows.slice(0, 4); return ( // Android lacks widgetURL; one card Button routes taps to the app listener. - ); From e89fd32c19f030c9e4e8042b4a8af31f4613880c Mon Sep 17 00:00:00 2001 From: Andrew Garber Date: Fri, 13 Nov 2026 10:08:01 -0800 Subject: [PATCH 3/6] Repaint the Android Agent Activity widget from relay pushes The widget only repainted when the app came to the foreground, so it sat on "Out of date" ten minutes after the last open and stayed there until the user opened T3 again. Agent activity pushes already reach the device and drive the ongoing notification natively; now the same handler writes the pushed rows into the widget's props and repaints it, ignoring out-of-order pushes. The relay pushes only on changes, so a ten-minute quiet period no longer means the data is old. Android widget rows now go out of date after the relay's two-hour running-row TTL instead. Co-Authored-By: Claude Opus 5.5 --- .../AgentActivityWidgetSync.kt | 89 +++++++++++++++++++ .../AgentNotifications.kt | 4 + .../AgentActivityWidgetSyncTest.kt | 69 ++++++++++++++ .../agentActivityWidgetTimeline.test.ts | 3 +- .../widgets/agentActivityWidgetTimeline.ts | 11 ++- 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt new file mode 100644 index 0000000000000..1cfe0d97fedc6 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt @@ -0,0 +1,89 @@ +package expo.modules.t3agentnotifications + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import org.json.JSONArray +import org.json.JSONObject + +/** + * Repaints the Agent Activity home screen widget from a relay push, so it stays current + * while the app is closed. The app's foreground refresh still overwrites these props with + * the full snapshot; this only keeps the widget from sitting on "Out of date" in between. + */ +internal object AgentActivityWidgetSync { + private const val WIDGET = "AgentActivity" + private const val WIDGET_STORE = "expo.modules.widgets" + private const val PROPS_KEY = "__expo_widgets_${WIDGET}_props" + private const val PUSH_AT_KEY = "t3_agent_activity_push_at" + // Pushes arrive only on changes; match the relay's running-row TTL (and the app's + // AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS) instead of flagging quiet agents. + private const val STALE_AFTER_MS = 2 * 60 * 60 * 1000L + private const val EXPIRY_RECEIVER = "expo.modules.t3widgetexpiry.WidgetExpiryReceiver" + private const val EXPIRY_NAME = "expo.modules.t3widgetexpiry.NAME" + private const val EXPIRY_DEADLINES = "expo.modules.t3widgetexpiry.DEADLINES" + + fun update(context: Context, data: Map, updatedAt: Long) { + val store = context.applicationContext.getSharedPreferences(WIDGET_STORE, Context.MODE_PRIVATE) + // FCM can deliver out of order; an older push must not replace newer rows. + if (updatedAt <= store.getLong(PUSH_AT_KEY, 0)) return + val rows = activityRows(data) + val path = data["activity_path"].orEmpty() + val updated = iso(updatedAt) + val current = JSONArray() + val stale = JSONArray() + var inFlight = false + rows.forEachIndexed { index, row -> + val phase = widgetPhase(ActivityPhase.forStatus(row.status)) + val finished = phase == "completed" || phase == "failed" + if (!finished) inFlight = true + fun json(phase: String, status: String) = JSONObject() + .put("environmentId", "push") + .put("threadId", index.toString()) + .put("projectTitle", row.project) + .put("threadTitle", row.title) + .put("modelTitle", "") + .put("phase", phase) + .put("status", status) + .put("updatedAt", updated) + .put("deepLink", if (index == 0) path else "") + current.put(json(phase, row.status)) + stale.put(if (finished) json(phase, row.status) else json("stale", "Out of date")) + } + val staleAt = updatedAt + STALE_AFTER_MS + val props = JSONObject() + .put("title", data["activity_title"].orEmpty()) + .put("subtitle", if (rows.isEmpty()) "No active agents" else "") + .put("activeCount", data["activity_active_count"]?.toIntOrNull() ?: 0) + .put("updatedAt", updated) + .put("activities", current) + if (inFlight) props.put("stale", JSONObject().put("at", staleAt).put("activities", stale)) + store.edit().putString(PROPS_KEY, props.toString()).putLong(PUSH_AT_KEY, updatedAt).commit() + + // The expiry receiver repaints now and sets the alarm that swaps in the stale rows. + context.sendBroadcast( + Intent().setComponent(ComponentName(context.packageName, EXPIRY_RECEIVER)) + .putExtra(EXPIRY_NAME, WIDGET) + .putExtra(EXPIRY_DEADLINES, if (inFlight) longArrayOf(staleAt) else LongArray(0)) + ) + } + + private fun widgetPhase(phase: ActivityPhase?) = when (phase) { + ActivityPhase.STARTING -> "starting" + ActivityPhase.APPROVAL -> "waiting_for_approval" + ActivityPhase.INPUT -> "waiting_for_input" + ActivityPhase.STALE -> "stale" + ActivityPhase.COMPLETED -> "completed" + ActivityPhase.FAILED -> "failed" + ActivityPhase.RUNNING, null -> "running" + } + + private fun iso(millis: Long) = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + .format(Date(millis)) +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index fdfd144ebb9cc..976e5c57ca03a 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -124,6 +124,10 @@ object AgentNotifications { data["device_id"] == prefs.getString("deviceId", null) && data["user_id"] == prefs.getString("userId", null) val fresh = System.currentTimeMillis() - updatedAt in -MAX_MESSAGE_AGE_MS..MAX_MESSAGE_AGE_MS + // The widget needs no notification permission, so it updates even when alerts are off. + if (registered && fresh) { + runCatching { AgentActivityWidgetSync.update(context, data, updatedAt) } + } if (registered && fresh && NotificationManagerCompat.from(context).areNotificationsEnabled()) { channels(context) val scheme = prefs.getString("scheme", "t3code") ?: "t3code" diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt new file mode 100644 index 0000000000000..77778d4d60872 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt @@ -0,0 +1,69 @@ +package expo.modules.t3agentnotifications + +import android.content.Context +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24, 36], manifest = Config.NONE) +class AgentActivityWidgetSyncTest : AgentNotificationsFixture() { + private fun widgetStore() = context.getSharedPreferences("expo.modules.widgets", Context.MODE_PRIVATE) + + private fun props() = + JSONObject(requireNotNull(widgetStore().getString("__expo_widgets_AgentActivity_props", null))) + + private fun push(updatedAt: Long, vararg lines: String) = mapOf( + "device_id" to "device", + "user_id" to "user", + "updated_at" to updatedAt.toString(), + "activity_title" to "2 active agents", + "activity_active_count" to "2", + "activity_path" to "/threads/env/first", + ) + lines.mapIndexed { index, line -> "activity_line_$index" to line } + + @Test + fun pushRepaintsTheWidget() { + val now = System.currentTimeMillis() + + AgentNotifications.receive( + context, + push(now, "Approval\tShip it\tweb", "Working\tRefactor\tapi", "Done\tDocs\tweb") + ) + + val props = props() + assertEquals(2, props.getInt("activeCount")) + val rows = props.getJSONArray("activities") + assertEquals( + listOf("waiting_for_approval", "running", "completed"), + (0 until rows.length()).map { rows.getJSONObject(it).getString("phase") } + ) + assertEquals("Refactor", rows.getJSONObject(1).getString("threadTitle")) + assertEquals("/threads/env/first", rows.getJSONObject(0).getString("deepLink")) + val stale = props.getJSONObject("stale") + assertEquals(now + 2 * 60 * 60 * 1000L, stale.getLong("at")) + assertEquals( + listOf("Out of date", "Out of date", "Done"), + (0 until 3).map { stale.getJSONArray("activities").getJSONObject(it).getString("status") } + ) + val broadcast = shadowOf(context).broadcastIntents.last() + assertEquals("expo.modules.t3widgetexpiry.WidgetExpiryReceiver", broadcast.component?.className) + assertEquals("AgentActivity", broadcast.getStringExtra("expo.modules.t3widgetexpiry.NAME")) + } + + @Test + fun anOlderPushDoesNotReplaceNewerRows() { + val now = System.currentTimeMillis() + AgentNotifications.receive(context, push(now, "Done\tNewer\tweb")) + AgentNotifications.receive(context, push(now - 1000, "Working\tOlder\tweb")) + + val props = props() + assertEquals("Newer", props.getJSONArray("activities").getJSONObject(0).getString("threadTitle")) + assertFalse(props.has("stale")) + } +} diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts index 81b137659e201..e5ca8f389f5cf 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.test.ts @@ -4,6 +4,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; import { + AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS, AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS, agentActivityWidgetSnapshot, agentActivityWidgetTimeline, @@ -89,7 +90,7 @@ describe("agentActivityWidgetTimeline", () => { "running", "completed", ]); - expect(snapshot.stale?.at).toBe(now + AGENT_ACTIVITY_WIDGET_STALE_AFTER_MS); + expect(snapshot.stale?.at).toBe(now + AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS); expect(snapshot.stale?.activities.map((entry) => entry.status)).toEqual([ "Out of date", "Out of date", diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts index 4869ea6b00d9f..df77a58ae7437 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts @@ -60,6 +60,10 @@ export function agentActivityWidgetTimeline(source: AgentActivityWidgetSource, n ]; } +// Android's widget also repaints from relay pushes, which arrive only on changes, so a +// long-running agent is not out of date after 10 quiet minutes. Match the relay's running-row TTL. +export const AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS = 2 * 60 * 60_000; + export interface AgentActivityWidgetSnapshot extends AgentActivityProps { // Android has no timeline, so its layout swaps to these rows once `at` passes. readonly stale?: { readonly at: number; readonly activities: AgentActivityProps["activities"] }; @@ -85,7 +89,12 @@ export function agentActivityWidgetSnapshot( ...current!.props, activities: ordered(activities), ...(stale - ? { stale: { at: stale.date.getTime(), activities: ordered(stale.props.activities) } } + ? { + stale: { + at: now + AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS, + activities: ordered(stale.props.activities), + }, + } : {}), }; } From 530c9d820dd4f98ae7770e377a47bdded85393d6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 04:28:47 +0000 Subject: [PATCH 4/6] fix(mobile): route Android widget taps through native intents --- .../AgentActivityWidgetSync.kt | 6 ++- .../AgentActivityWidgetSyncTest.kt | 11 ++++- .../widgets/AgentActivityWidget.android.tsx | 44 +++++++++++-------- .../widgets/agentActivityWidgetTimeline.ts | 1 + .../publishAgentActivityWidget.android.ts | 22 +++------- .../src/agentActivity/FcmDeliveries.test.ts | 6 ++- infra/relay/src/agentActivity/fcmPayloads.ts | 3 ++ patches/expo-widgets@58.0.27.patch | 27 ++++++++++++ pnpm-lock.yaml | 8 ++-- 9 files changed, 85 insertions(+), 43 deletions(-) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt index 1cfe0d97fedc6..f6e91f85a1a60 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityWidgetSync.kt @@ -20,6 +20,7 @@ internal object AgentActivityWidgetSync { private const val WIDGET_STORE = "expo.modules.widgets" private const val PROPS_KEY = "__expo_widgets_${WIDGET}_props" private const val PUSH_AT_KEY = "t3_agent_activity_push_at" + // Pushes arrive only on changes; match the relay's running-row TTL (and the app's // AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS) instead of flagging quiet agents. private const val STALE_AFTER_MS = 2 * 60 * 60 * 1000L @@ -33,6 +34,8 @@ internal object AgentActivityWidgetSync { if (updatedAt <= store.getLong(PUSH_AT_KEY, 0)) return val rows = activityRows(data) val path = data["activity_path"].orEmpty() + val scheme = context.getSharedPreferences("t3-agent-notifications", Context.MODE_PRIVATE) + .getString("scheme", "t3code") ?: "t3code" val updated = iso(updatedAt) val current = JSONArray() val stale = JSONArray() @@ -50,12 +53,13 @@ internal object AgentActivityWidgetSync { .put("phase", phase) .put("status", status) .put("updatedAt", updated) - .put("deepLink", if (index == 0) path else "") + .put("deepLink", data["activity_row_path_$index"] ?: if (index == 0) path else "") current.put(json(phase, row.status)) stale.put(if (finished) json(phase, row.status) else json("stale", "Out of date")) } val staleAt = updatedAt + STALE_AFTER_MS val props = JSONObject() + .put("appUrl", "$scheme://") .put("title", data["activity_title"].orEmpty()) .put("subtitle", if (rows.isEmpty()) "No active agents" else "") .put("activeCount", data["activity_active_count"]?.toIntOrNull() ?: 0) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt index 77778d4d60872..11bb88c23bf28 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityWidgetSyncTest.kt @@ -13,7 +13,8 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [24, 36], manifest = Config.NONE) class AgentActivityWidgetSyncTest : AgentNotificationsFixture() { - private fun widgetStore() = context.getSharedPreferences("expo.modules.widgets", Context.MODE_PRIVATE) + private fun widgetStore() = + context.getSharedPreferences("expo.modules.widgets", Context.MODE_PRIVATE) private fun props() = JSONObject(requireNotNull(widgetStore().getString("__expo_widgets_AgentActivity_props", null))) @@ -25,6 +26,7 @@ class AgentActivityWidgetSyncTest : AgentNotificationsFixture() { "activity_title" to "2 active agents", "activity_active_count" to "2", "activity_path" to "/threads/env/first", + "activity_row_path_1" to "/threads/env/second", ) + lines.mapIndexed { index, line -> "activity_line_$index" to line } @Test @@ -44,6 +46,8 @@ class AgentActivityWidgetSyncTest : AgentNotificationsFixture() { (0 until rows.length()).map { rows.getJSONObject(it).getString("phase") } ) assertEquals("Refactor", rows.getJSONObject(1).getString("threadTitle")) + assertEquals("/threads/env/second", rows.getJSONObject(1).getString("deepLink")) + assertEquals("t3code-dev://", props.getString("appUrl")) assertEquals("/threads/env/first", rows.getJSONObject(0).getString("deepLink")) val stale = props.getJSONObject("stale") assertEquals(now + 2 * 60 * 60 * 1000L, stale.getLong("at")) @@ -63,7 +67,10 @@ class AgentActivityWidgetSyncTest : AgentNotificationsFixture() { AgentNotifications.receive(context, push(now - 1000, "Working\tOlder\tweb")) val props = props() - assertEquals("Newer", props.getJSONArray("activities").getJSONObject(0).getString("threadTitle")) + assertEquals( + "Newer", + props.getJSONArray("activities").getJSONObject(0).getString("threadTitle") + ) assertFalse(props.has("stale")) } } diff --git a/apps/mobile/src/widgets/AgentActivityWidget.android.tsx b/apps/mobile/src/widgets/AgentActivityWidget.android.tsx index 20ca392cb9d08..1dba8344e1221 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.android.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.android.tsx @@ -19,7 +19,7 @@ export function AgentActivityWidget( const light = environment.colorScheme !== "dark"; const background = light ? "#ffffff" : "#1c1c1e"; const primary = light ? "#000000" : "#ffffff"; - const secondary = light ? "#8e8e93" : "#98989f"; + const secondary = light ? "#66666b" : "#98989f"; const phaseColor = (phase: AgentActivityPhase): string => { switch (phase) { case "waiting_for_approval": @@ -64,8 +64,11 @@ export function AgentActivityWidget( const shown = rows.slice(0, 4); return ( - // Android lacks widgetURL; one card Button routes taps to the app listener. - ))} diff --git a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts index df77a58ae7437..02d5ffb271601 100644 --- a/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts +++ b/apps/mobile/src/widgets/agentActivityWidgetTimeline.ts @@ -65,6 +65,7 @@ export function agentActivityWidgetTimeline(source: AgentActivityWidgetSource, n export const AGENT_ACTIVITY_ANDROID_WIDGET_STALE_AFTER_MS = 2 * 60 * 60_000; export interface AgentActivityWidgetSnapshot extends AgentActivityProps { + readonly appUrl?: string; // Android has no timeline, so its layout swaps to these rows once `at` passes. readonly stale?: { readonly at: number; readonly activities: AgentActivityProps["activities"] }; } diff --git a/apps/mobile/src/widgets/publishAgentActivityWidget.android.ts b/apps/mobile/src/widgets/publishAgentActivityWidget.android.ts index 9f33d5b667b30..989b58cfbffa4 100644 --- a/apps/mobile/src/widgets/publishAgentActivityWidget.android.ts +++ b/apps/mobile/src/widgets/publishAgentActivityWidget.android.ts @@ -6,25 +6,13 @@ import { type AgentActivityWidgetSource, } from "./agentActivityWidgetTimeline"; -let tapUrl: string | undefined; -let listening = false; - export async function publishAgentActivityWidget(source: AgentActivityWidgetSource) { if (!requireOptionalNativeModule("ExpoWidgets")) return; - const [{ default: widget }, { addUserInteractionListener }] = await Promise.all([ - import("./AgentActivityWidget"), - import("expo-widgets"), - ]); - const snapshot = agentActivityWidgetSnapshot(source, Date.now()); - const deepLink = snapshot.activities[0]?.deepLink; - tapUrl = Linking.createURL(deepLink?.startsWith("/") ? deepLink.slice(1) : ""); - if (!listening) { - // expo-widgets lacks Android widgetURL. Button events need a live JS runtime to open the app. - listening = true; - addUserInteractionListener((event) => { - if (event.source === "AgentActivity" && tapUrl) void Linking.openURL(tapUrl); - }); - } + const { default: widget } = await import("./AgentActivityWidget"); + const snapshot = { + ...agentActivityWidgetSnapshot(source, Date.now()), + appUrl: Linking.createURL(""), + }; widget.updateSnapshot(snapshot); // Android lacks timelines; an alarm re-renders the snapshot when unfinished rows go out of date. requireOptionalNativeModule<{ schedule: (name: string, deadlines: number[]) => void }>( diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index f1ce50d5832b6..d578f719290c3 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -551,6 +551,7 @@ describe("Android delivery routing", () => { Array.from({ length: 5 }, (_, i) => ({ ...state, threadId: ThreadId.make(`thread-${i}`), + deepLink: `/threads/env/thread-${i}`, threadTitle: longTitle, projectTitle: longTitle, })), @@ -568,9 +569,12 @@ describe("Android delivery routing", () => { }); expect(new TextEncoder().encode(JSON.stringify(data)).length).toBeLessThanOrEqual(3800); expect(data.alert_path).toBe("/threads/env/thread"); - expect(data.activity_path).toBe("/threads/env/thread"); + expect(data.activity_path).toBe("/threads/env/thread-0"); expect(data.alert_id).toBe("a".repeat(64)); expect(data.activity_line_4).toContain("Working\t"); + for (let index = 0; index < 5; index++) { + expect(data[`activity_row_path_${index}`]).toBe(`/threads/env/thread-${index}`); + } for (const value of Object.values(data)) expect(new TextDecoder().decode(new TextEncoder().encode(value))).toBe(value); }); diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index 6c813235e4f61..525624f1f4723 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -42,6 +42,9 @@ export function androidActivityData(aggregate: RelayAgentActivityAggregateState ? `${hero.status}: ${clean(hero.threadTitle)} ยท ${clean(hero.projectTitle)}` : "", ...Object.fromEntries(lines.map((line, index) => [`activity_line_${index}`, line])), + ...Object.fromEntries( + rows.slice(0, 5).map((row, index) => [`activity_row_path_${index}`, row.deepLink]), + ), activity_path: rows[0]?.deepLink ?? "/", activity_expires_at: String(expiresAt), }; diff --git a/patches/expo-widgets@58.0.27.patch b/patches/expo-widgets@58.0.27.patch index 21252dcd848aa..e5400029a9b46 100644 --- a/patches/expo-widgets@58.0.27.patch +++ b/patches/expo-widgets@58.0.27.patch @@ -264,3 +264,30 @@ index 690d069..c60dd79 100644 getInstances(): NativeLiveActivity[]; + dismissEnded(): void; } +diff --git a/android/src/main/java/expo/modules/widgets/WidgetInteractionAction.kt b/android/src/main/java/expo/modules/widgets/WidgetInteractionAction.kt +--- a/android/src/main/java/expo/modules/widgets/WidgetInteractionAction.kt ++++ b/android/src/main/java/expo/modules/widgets/WidgetInteractionAction.kt +@@ -5,6 +5,7 @@ + import android.net.Uri + import androidx.glance.action.Action + import androidx.glance.appwidget.action.actionSendBroadcast ++import androidx.glance.appwidget.action.actionStartActivity + import expo.modules.widgets.notifications.ExpoWidgetsNotificationReceiver + + internal const val WIDGET_INTERACTION_ACTION = "expo.modules.widgets.ACTION_WIDGET_INTERACTION" +@@ -27,6 +28,15 @@ + ) + + internal fun WidgetInteraction.toGlanceAction(context: Context): Action { ++ if (source is InteractionSource.Widget && target.startsWith("url:")) { ++ val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ++ if (launch != null) { ++ val url = target.removePrefix("url:") ++ if (url.isNotEmpty()) launch.setAction(Intent.ACTION_VIEW).setData(Uri.parse(url)) ++ launch.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) ++ return actionStartActivity(launch) ++ } ++ } + val intent = when (source) { + is InteractionSource.Widget -> Intent(WIDGET_INTERACTION_ACTION) + .setComponent(widgetProviderComponentName(context, source.name)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05026f5eb7123..dc3c9de71b6d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -385,7 +385,7 @@ patchedDependencies: expo-network@58.0.4: 159c2d760d1cd5102e95df51c20600111e7a72096ef0daea8215d50cae48bd98 expo-sharing@58.0.22: 321a86e1471e34c53f1f2937fbb4304ea3f7b7a78ad9e4d35febc2b9e73e9688 expo-video@58.0.13: e80ec3ef8c4af50a607beea1c0917e71ba679844efdb702b45d17a5f03cf466c - expo-widgets@58.0.27: 8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171 + expo-widgets@58.0.27: e7cd8b9e57973f18e30c5adcc20440d5e223b50852671c68109173441766a9a2 mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c mdast-util-to-hast@13.2.1: 57ea46c73aea3c876ab8d351fe0482ddd081dcb4a0610576e399dbe5eb59d2b1 micromark-extension-gfm-table@2.1.1: 9ad23a530e45d9f83cba1da8654fe16d5af8b59d5c73d5c7a7c91fd25b9be1d8 @@ -738,7 +738,7 @@ importers: version: 58.0.7(expo@58.0.23)(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6)) expo-widgets: specifier: 58.0.27 - version: 58.0.27(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.27(patch_hash=e7cd8b9e57973f18e30c5adcc20440d5e223b50852671c68109173441766a9a2)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) gt-react-native: specifier: 11.4.18 version: 11.4.18(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -18317,7 +18317,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 58.0.23(d226faf1ca90e630d8c56a2108dee32d) - expo-widgets: 58.0.27(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + expo-widgets: 58.0.27(patch_hash=e7cd8b9e57973f18e30c5adcc20440d5e223b50852671c68109173441766a9a2)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - supports-color @@ -19602,7 +19602,7 @@ snapshots: expo: 58.0.23(d226faf1ca90e630d8c56a2108dee32d) react-native: 0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) - ? expo-widgets@58.0.27(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + ? expo-widgets@58.0.27(patch_hash=e7cd8b9e57973f18e30c5adcc20440d5e223b50852671c68109173441766a9a2)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) : dependencies: '@expo/plist': 0.10.1 '@expo/ui': 58.0.27(patch_hash=9f2547678939f4ec78fe45163b6455aad63d3c80353d7e0cc783f6a61a7ccc2f)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.23)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.1(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) From f7733fd592d868c52b2ed50006c19dd387289db6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 04:36:13 +0000 Subject: [PATCH 5/6] fix(mobile): preserve widget card navigation --- apps/mobile/src/widgets/AgentActivityWidget.android.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/widgets/AgentActivityWidget.android.tsx b/apps/mobile/src/widgets/AgentActivityWidget.android.tsx index 1dba8344e1221..e428635ce8faa 100644 --- a/apps/mobile/src/widgets/AgentActivityWidget.android.tsx +++ b/apps/mobile/src/widgets/AgentActivityWidget.android.tsx @@ -62,10 +62,13 @@ export function AgentActivityWidget( ? "Done" : ""; const shown = rows.slice(0, 4); + const firstPath = props.appUrl ? (rows[0]?.deepLink ?? "").replace(/^\//, "") : ""; return (