From 4daf64003e458eb90ec89ac4ac66193fe8b1def7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 23 Sep 2026 13:27:29 -0700 Subject: [PATCH 1/7] feat(mobile): capture agent activity in showcase screenshots Adds an agent-activity scene: the iOS Live Activity on the lock screen with an approval alert, and Android's Live Update plus alert in the notification shade. Co-Authored-By: Claude Opus 5.5 --- .../workflows/mobile-showcase-screenshots.yml | 5 + .../AgentNotifications.kt | 37 +++-- .../T3AgentNotificationsModule.kt | 4 + .../agent-awareness/androidNotifications.ts | 21 ++- .../showcase/ShowcaseCaptureCoordinator.tsx | 53 ++++++- .../features/showcase/nativeShowcaseScene.ts | 9 +- .../showcase/showcaseAgentActivity.test.ts | 93 ++++++++++++ .../showcase/showcaseAgentActivity.ts | 138 ++++++++++++++++++ .../showcase/stageShowcaseAgentActivity.ts | 38 +++++ .../mobile-app-store-screenshots.md | 27 ++-- scripts/mobile-showcase-environment.ts | 9 +- scripts/mobile-showcase.config.ts | 12 +- scripts/mobile-showcase.ts | 120 ++++++++++++++- 13 files changed, 527 insertions(+), 39 deletions(-) create mode 100644 apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts create mode 100644 apps/mobile/src/features/showcase/showcaseAgentActivity.ts create mode 100644 apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index c64bccacdca8..29e18ea71926 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -76,6 +76,11 @@ jobs: echo "$vp_pnpm_bin" >> "$GITHUB_PATH" "$vp_pnpm_bin/pnpm" --version + - name: Install AXe + # Locks the simulator and answers the notification prompt for the + # agent-activity scene. + run: brew tap cameroncooke/axe && brew install axe + - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" 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 35086bbd75f9..a5dafb253cfe 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 @@ -138,17 +138,7 @@ object AgentNotifications { // Match iOS foreground presentation. Consume suppressed alerts as well, // so a delivery retry cannot surface them after the app backgrounds. if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - val title = data["alert_title"].orEmpty().take(120) - // Grouped alerts list up to five 120-character thread titles. - val body = data["alert_body"].orEmpty().take(608) - val id = alertId.hashCode() - val notification = base(context, ALERT_CHANNEL) - .setContentTitle(title).setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) - .setAutoCancel(true) - .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) - .build() - manager(context).notify(ALERT_TAG, id, notification) + postAlert(context, scheme, data, alertId) } prefs.edit().remove("seenAlerts").putString( "seenAlertsOrdered", @@ -157,6 +147,31 @@ object AgentNotifications { } } + private fun postAlert(context: Context, scheme: String, data: Map, alertId: String) { + val title = data["alert_title"].orEmpty().take(120) + // Grouped alerts list up to five 120-character thread titles. + val body = data["alert_body"].orEmpty().take(608) + val id = alertId.hashCode() + val notification = base(context, ALERT_CHANNEL) + .setContentTitle(title).setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) + .build() + manager(context).notify(ALERT_TAG, id, notification) + } + + /** + * Renders a relay-shaped payload without the registration, freshness and + * foreground checks, for the showcase capture's staged notifications. + */ + @Synchronized + fun showcase(context: Context, scheme: String, data: Map) { + channels(context) + data["alert_id"]?.let { postAlert(context, scheme, data, it) } + showActivity(context, scheme, data, data["active"] == "true", RUNNING_LIFETIME_MS) + } + private fun updateActivity( context: Context, prefs: SharedPreferences, diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt index 246db274a195..0a2d2b30589f 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -26,6 +26,10 @@ class T3AgentNotificationsModule : Module() { appContext.reactContext?.let { AgentNotifications.clear(it) } } + Function("showShowcaseActivity") { scheme: String, data: Map -> + appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) } + } + Function("openLiveUpdateSettings") { val context = appContext.reactContext if (context == null || Build.VERSION.SDK_INT < 36) { diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts index 9ffe586ecb81..a2a0945faaf1 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -6,6 +6,7 @@ interface AndroidAgentNotifications { configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; clear(): void; openLiveUpdateSettings?(): boolean; + showShowcaseActivity?(scheme: string, data: Record): void; } const native = @@ -17,18 +18,24 @@ export function supportsAndroidAgentNotifications(): boolean { return typeof native?.configure === "function" && typeof native?.clear === "function"; } +function appScheme(): string { + const scheme = Constants.expoConfig?.scheme; + return (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code"; +} + export function configureAndroidAgentNotifications( deviceId: string, userId: string, ongoingEnabled: boolean, ): void { - const scheme = Constants.expoConfig?.scheme; - native?.configure?.( - deviceId, - userId, - (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code", - ongoingEnabled, - ); + native?.configure?.(deviceId, userId, appScheme(), ongoingEnabled); +} + +/** Posts a staged relay payload for the showcase capture; false when unsupported. */ +export function showAndroidShowcaseAgentActivity(data: Record): boolean { + if (!native?.showShowcaseActivity) return false; + native.showShowcaseActivity(appScheme(), data); + return true; } export function clearAndroidAgentNotifications(): void { diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 557c3b190359..a21c45e0d8df 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -29,6 +29,7 @@ import { buildShowcasePendingTasks, SHOWCASE_PENDING_TASK_DEFINITIONS, } from "./showcasePendingTasks"; +import { buildShowcaseAgentActivity } from "./showcaseAgentActivity"; import { retryShowcaseOperation } from "./showcaseRetry"; import { clearShowcaseRenderSignal, @@ -36,6 +37,7 @@ import { isShowcaseNativeContentReady, subscribeToShowcaseRenderSignal, } from "./showcaseRenderSignal"; +import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; @@ -75,7 +77,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const [themeRequestSettled, setThemeRequestSettled] = useState(false); const [readyScene, setReadyScene] = useState(null); const [orientationSettled, setOrientationSettled] = useState(false); + const [agentActivityStaged, setAgentActivityStaged] = useState(false); const requestedSceneRef = useRef(null); + // Staging reads the latest entities without restarting on every shell + // update, which would re-enter a permission prompt that is still open. + const entitiesRef = useRef({ threads, projects }); + useEffect(() => { + entitiesRef.current = { threads, projects }; + }, [projects, threads]); const renderSignal = useSyncExternalStore( subscribeToShowcaseRenderSignal, getShowcaseRenderSignal, @@ -129,6 +138,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) // A native draw belongs only to the scene request that produced it. In // particular, revisiting review must wait for its newly mounted surface. clearShowcaseRenderSignal(); + setAgentActivityStaged(false); setRequestedScene(value); }; readRequestedScene(); @@ -176,7 +186,11 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) }; }, [connectPairingUrl, pairingUrls]); - const scene = sceneFromPathname(props.pathname); + const routeScene = sceneFromPathname(props.pathname); + // Agent activity is captured over the thread list: the runner locks the + // simulator or opens the notification shade on top of it. + const scene = + requestedScene === "agent-activity" && routeScene === "threads" ? "agent-activity" : routeScene; const hasServerFixture = workspace.state.hasReadyEnvironment && workspace.environments.length >= 3 && @@ -224,7 +238,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) environmentId: String(showcaseThread.environmentId), threadId: SHOWCASE_THREAD_ID, }; - if (requestedScene === "threads") { + if (requestedScene === "threads" || requestedScene === "agent-activity") { navigation.dispatch(StackActions.popToTop()); return; } @@ -264,6 +278,29 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) ); }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); + useEffect(() => { + if (!SHOWCASE_ENABLED || scene !== "agent-activity" || !hasFixture || agentActivityStaged) { + return; + } + let cancelled = false; + void retryShowcaseOperation( + async () => { + const now = Date.now(); + const { threads: latestThreads, projects: latestProjects } = entitiesRef.current; + const activity = buildShowcaseAgentActivity(latestThreads, latestProjects, now); + return activity !== null && (await stageShowcaseAgentActivity(activity, now)); + }, + // The first attempt waits on the notification permission prompt until + // the runner answers it. + { isCancelled: () => cancelled, attemptTimeoutMs: 60_000 }, + ).then((staged) => { + if (!cancelled && staged) setAgentActivityStaged(true); + }); + return () => { + cancelled = true; + }; + }, [agentActivityStaged, hasFixture, scene]); + useEffect(() => { if ( !SHOWCASE_ENABLED || @@ -276,6 +313,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) !orientationSettled || // Likewise for the palette: an early screenshot shows the default theme. !themeApplied || + (scene === "agent-activity" && !agentActivityStaged) || !isShowcaseNativeContentReady({ scene, themeId, renderSignal }) ) { setReadyScene(null); @@ -298,7 +336,16 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) if (renderFrame !== null) cancelAnimationFrame(renderFrame); if (readyFrame !== null) cancelAnimationFrame(readyFrame); }; - }, [hasFixture, orientationSettled, renderSignal, requestedScene, scene, themeApplied, themeId]); + }, [ + agentActivityStaged, + hasFixture, + orientationSettled, + renderSignal, + requestedScene, + scene, + themeApplied, + themeId, + ]); if (!SHOWCASE_ENABLED || readyScene === null) return null; diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 11618291932d..33839b2fe473 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -2,7 +2,14 @@ import { requireOptionalNativeModule } from "expo"; import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme"; -export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; +export const SHOWCASE_SCENES = [ + "threads", + "thread", + "terminal", + "review", + "environments", + "agent-activity", +] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; export type ShowcaseOrientation = "portrait" | "landscape"; diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts new file mode 100644 index 000000000000..94aaa2fc5e9f --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts @@ -0,0 +1,93 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import { + buildShowcaseAgentActivity, + SHOWCASE_AGENT_ACTIVITY_ROWS, + showcaseAndroidActivityData, +} from "./showcaseAgentActivity"; + +const NOW = Date.parse("2026-07-16T09:00:00.000Z"); + +const project = (environmentId: string, id: string, title: string) => + ({ + environmentId: EnvironmentId.make(environmentId), + id: ProjectId.make(id), + title, + }) as EnvironmentProject; + +const thread = (environmentId: string, id: string, projectId: string, title: string) => + ({ + environmentId: EnvironmentId.make(environmentId), + id: ThreadId.make(id), + projectId: ProjectId.make(projectId), + title, + }) as EnvironmentThreadShell; + +const projects = [ + project("moonbase-terminal", "t3code", "T3 Code"), + project("suspense-station", "react", "React"), + project("kernel-cabin", "linux", "Linux"), +]; + +const threads = [ + thread("moonbase-terminal", "remote-command-center", "t3code", "Make remote coding feel local"), + thread( + "moonbase-terminal", + "pocket-command-center", + "t3code", + "Put the command center in your pocket", + ), + thread("suspense-station", "buttery-suspense", "react", "Make Suspense transitions buttery"), + thread("kernel-cabin", "beautiful-boot", "linux", "Make boot logs oddly beautiful"), +]; + +it("waits until every staged thread and its project have loaded", () => { + assert.isNull(buildShowcaseAgentActivity(threads.slice(1), projects, NOW)); + assert.isNull(buildShowcaseAgentActivity(threads, projects.slice(0, 2), NOW)); +}); + +it("stages relay-shaped rows against the seeded threads", () => { + const activity = buildShowcaseAgentActivity(threads, projects, NOW); + assert.isNotNull(activity); + if (!activity) return; + + assert.strictEqual(activity.activeCount, 3); + assert.deepStrictEqual( + activity.activities.map((row) => [row.threadId, row.status, row.projectTitle, row.updatedAt]), + [ + ["pocket-command-center", "Approval", "T3 Code", "2026-07-16T08:59:00.000Z"], + ["beautiful-boot", "Input", "Linux", "2026-07-16T08:56:00.000Z"], + ["buttery-suspense", "Working", "React", "2026-07-16T08:58:00.000Z"], + ["remote-command-center", "Done", "T3 Code", "2026-07-16T08:57:00.000Z"], + ], + ); + assert.strictEqual( + activity.activities[0]?.deepLink, + "/threads/moonbase-terminal/pocket-command-center", + ); + assert.strictEqual(activity.activities.length, SHOWCASE_AGENT_ACTIVITY_ROWS.length); +}); + +it("encodes the Android Live Update and its alert like a relay push", () => { + const activity = buildShowcaseAgentActivity(threads, projects, NOW); + assert.isNotNull(activity); + if (!activity) return; + const data = showcaseAndroidActivityData(activity, NOW); + + assert.strictEqual(data.active, "true"); + assert.strictEqual(data.activity_chip, "Review"); + assert.strictEqual(data.activity_title, "3 active agents · 2 need attention"); + assert.strictEqual( + data.activity_line_0, + "Approval\tPut the command center in your pocket\tT3 Code", + ); + assert.strictEqual(data.activity_line_3, "Done\tMake remote coding feel local\tT3 Code"); + assert.strictEqual(data.alert_title, "Put the command center in your pocket"); + assert.strictEqual(data.alert_body, "Approval: T3 Code"); + assert.strictEqual(data.alert_path, "/threads/moonbase-terminal/pocket-command-center"); +}); diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts new file mode 100644 index 000000000000..736c672dff0c --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts @@ -0,0 +1,138 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; + +import type { + AgentActivityPhase, + AgentActivityProps, + AgentActivityRowProps, +} from "../../widgets/AgentActivity"; + +/** + * Agent work shown by the agent-activity scene. The rows point at + * seeded showcase threads so the copy matches the thread list, but the phases + * are staged: the relay that normally derives them is not part of the capture. + * Rows follow the relay's order: attention first, then running, then finished. + */ +export const SHOWCASE_AGENT_ACTIVITY_ROWS = [ + { threadId: "pocket-command-center", phase: "waiting_for_approval", minutesAgo: 1 }, + { threadId: "beautiful-boot", phase: "waiting_for_input", minutesAgo: 4 }, + { threadId: "buttery-suspense", phase: "running", minutesAgo: 2 }, + { threadId: "remote-command-center", phase: "completed", minutesAgo: 3 }, +] as const satisfies ReadonlyArray<{ + readonly threadId: string; + readonly phase: AgentActivityPhase; + readonly minutesAgo: number; +}>; + +// Matches the relay's row wording (AgentActivityPublisher.statusForPhase). +const STATUS_BY_PHASE: Record = { + starting: "Connecting", + running: "Working", + waiting_for_approval: "Approval", + waiting_for_input: "Input", + stale: "Waiting", + completed: "Done", + failed: "Failed", +}; + +const ACTIVE_PHASES: ReadonlySet = new Set([ + "starting", + "running", + "waiting_for_approval", + "waiting_for_input", +]); + +/** Returns null until every staged thread and its project have loaded. */ +export function buildShowcaseAgentActivity( + threads: ReadonlyArray, + projects: ReadonlyArray, + now: number, +): AgentActivityProps | null { + const rows: AgentActivityRowProps[] = []; + for (const definition of SHOWCASE_AGENT_ACTIVITY_ROWS) { + const thread = threads.find((candidate) => String(candidate.id) === definition.threadId); + const project = thread + ? projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, + ) + : undefined; + if (!thread || !project) return null; + const environmentId = String(thread.environmentId); + rows.push({ + environmentId, + threadId: definition.threadId, + projectTitle: project.title, + threadTitle: thread.title, + modelTitle: "", + phase: definition.phase, + status: STATUS_BY_PHASE[definition.phase], + updatedAt: new Date(now - definition.minutesAgo * 60_000).toISOString(), + deepLink: `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(definition.threadId)}`, + }); + } + return { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: rows.filter((row) => ACTIVE_PHASES.has(row.phase)).length, + updatedAt: new Date(now).toISOString(), + activities: rows, + }; +} + +/** The alert the relay sends when the hero row starts waiting on the user. */ +export function showcaseAgentAlert(activity: AgentActivityProps) { + const row = activity.activities[0]; + if (!row) return null; + return { + title: row.threadTitle, + body: `${row.status}: ${row.projectTitle}`, + path: row.deepLink, + environmentId: row.environmentId, + threadId: row.threadId, + }; +} + +/** + * The FCM data map Android's native receiver renders, mirroring the relay's + * androidActivityData so the shade shows exactly what a real push produces. + */ +export function showcaseAndroidActivityData( + activity: AgentActivityProps, + now: number, +): Record { + const attentionCount = activity.activities.filter( + (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + ).length; + const hero = activity.activities[0]; + const alert = showcaseAgentAlert(activity); + return { + t3_kind: "agent_activity", + updated_at: String(now), + active: String(activity.activeCount > 0), + activity_chip: attentionCount > 0 ? "Review" : "Active", + activity_title: `${activity.activeCount} active agents · ${attentionCount} need attention`, + activity_phase: hero?.phase ?? "", + activity_active_count: String(activity.activeCount), + activity_attention_count: String(attentionCount), + activity_body: hero ? `${hero.status}: ${hero.threadTitle} · ${hero.projectTitle}` : "", + ...Object.fromEntries( + activity.activities.map((row, index) => [ + `activity_line_${index}`, + [row.status, row.threadTitle, row.projectTitle].join("\t"), + ]), + ), + activity_path: hero?.deepLink ?? "/", + activity_expires_at: String(now + 2 * 60 * 60_000), + ...(alert + ? { + alert_id: "showcase-alert", + alert_title: alert.title, + alert_body: alert.body, + alert_path: alert.path, + } + : {}), + }; +} diff --git a/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts new file mode 100644 index 000000000000..a8de0b8718d9 --- /dev/null +++ b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts @@ -0,0 +1,38 @@ +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; + +import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import { + getAgentLiveActivities, + startAgentLiveActivity, +} from "../agent-awareness/agentLiveActivity"; +import { showAndroidShowcaseAgentActivity } from "../agent-awareness/androidNotifications"; +import { showcaseAndroidActivityData } from "./showcaseAgentActivity"; + +/** + * Puts the staged agent activity on screen for the capture runner, which then + * locks the simulator (iOS) or opens the notification shade (Android). + * Resolves false when the build cannot show it yet, so the caller retries. + */ +export async function stageShowcaseAgentActivity( + activity: AgentActivityProps, + now: number, +): Promise { + // The runner answers the iOS prompt and pre-grants Android's, so this only + // settles the permission the runner's alert delivery depends on. + const permission = await Notifications.requestPermissionsAsync({ + ios: { allowAlert: true, allowBadge: true, allowSound: true }, + }); + if (!permission.granted) return false; + + if (Platform.OS === "android") { + return showAndroidShowcaseAgentActivity(showcaseAndroidActivityData(activity, now)); + } + if (Platform.OS !== "ios") return false; + + // A retried or revisited scene must not stack a second card. + await Promise.all(getAgentLiveActivities().map((existing) => existing.end("immediate"))); + // ActivityKit only starts activities while the app is foreground, which + // holds here: the runner locks the device after the scene reports ready. + return startAgentLiveActivity(activity) !== null; +} diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index 0e3c1784429b..5d6fff051550 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -52,8 +52,8 @@ verify process ownership. Concurrent screenshot harnesses in different worktrees collide or attach to the wrong Metro process. Every configured device defaults to dark appearance and the `t3-code` palette, so plain -`pnpm screenshots:mobile` produces 30 dark PNGs. Pass `--appearance light`, `--appearance dark`, or -`--appearance both` to override the configured appearance; `both` produces 60 PNGs. +`pnpm screenshots:mobile` produces 35 dark PNGs. Pass `--appearance light`, `--appearance dark`, or +`--appearance both` to override the configured appearance; `both` produces 70 PNGs. Pass `--theme ` (repeatable) or `--theme all` to capture the app's other palettes: `t3-code`, `t3-chat`, `grove`, `ocean`, `ember`, and `iris`. The runner hands the palette to the app as a launch @@ -72,23 +72,32 @@ The default matrix is: | `google-play/tablet-7/dark/t3-code/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | | `google-play/tablet-10/dark/t3-code/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | -Each target captures thread, terminal, review, thread list, and environments. Each palette folder's -five screenshots satisfy the configured Apple limit of 1–10, Google +Each target captures thread, terminal, review, thread list, and environments, and every target but +the iPad also captures agent activity. Each palette folder's five or six screenshots satisfy the configured Apple limit of 1–10, Google phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8. Every palette gets its own leaf folder so one upload slot never mixes themes and each folder keeps a store-legal screenshot count. +The agent-activity scene shows what a user sees away from the app. The app stages the same Live +Activity (iOS) or ongoing Live Update (Android) the relay would publish for four seeded threads. On +iOS the runner then locks the simulator and pushes the matching approval alert with `simctl push`; +on Android the staged update carries the alert and the runner opens the notification shade. Locking +the simulator and answering the notification permission prompt use +[AXe](https://github.com/cameroncooke/AXe), so install it (`brew tap cameroncooke/axe && brew +install axe`) or set `AXE_PATH` before capturing iOS. The iPad skips the scene because the lock +screen does not follow the app's landscape self-rotation. + The generated tree is deliberately aligned with the store upload fields: artifacts/app-store/screenshots/ ├── apple/ - │ ├── iphone-6.9/dark/t3-code/{thread,terminal,review,threads,environments}.png - │ ├── iphone-6.5/dark/t3-code/{thread,terminal,review,threads,environments}.png + │ ├── iphone-6.9/dark/t3-code/{thread,terminal,review,threads,environments,agent-activity}.png + │ ├── iphone-6.5/dark/t3-code/{thread,terminal,review,threads,environments,agent-activity}.png │ └── ipad-13/dark/t3-code/{thread,terminal,review,threads,environments}.png └── google-play/ - ├── phone/dark/t3-code/{thread,terminal,review,threads,environments}.png - ├── tablet-7/dark/t3-code/{thread,terminal,review,threads,environments}.png - └── tablet-10/dark/t3-code/{thread,terminal,review,threads,environments}.png + ├── phone/dark/t3-code/{thread,terminal,review,threads,environments,agent-activity}.png + ├── tablet-7/dark/t3-code/{thread,terminal,review,threads,environments,agent-activity}.png + └── tablet-10/dark/t3-code/{thread,terminal,review,threads,environments,agent-activity}.png A light-only run writes the same tree under `light/`; `--appearance both` writes both appearance folders, and each requested theme adds a sibling folder next to `t3-code/`. diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index 93738b61325a..209df77bf808 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -11,7 +11,14 @@ export const SHOWCASE_PROJECT_ID = "t3code"; export const SHOWCASE_THREAD_ID = "remote-command-center"; export const SHOWCASE_TERMINAL_ID = "term-1"; -export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; +export const SHOWCASE_SCENES = [ + "threads", + "thread", + "terminal", + "review", + "environments", + "agent-activity", +] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; const PROJECTOR_NAMES = [ diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 45a9f474fa84..c52d1f1dd116 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -108,7 +108,7 @@ const config: ShowcaseConfig = { simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max", appearance: "dark", theme: DEFAULT_SHOWCASE_THEME, - scenes: ["thread", "terminal", "review", "threads", "environments"], + scenes: ["thread", "terminal", "review", "threads", "environments", "agent-activity"], storeAsset: { store: "apple", directory: "apple/iphone-6.9", @@ -125,7 +125,7 @@ const config: ShowcaseConfig = { simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus", appearance: "dark", theme: DEFAULT_SHOWCASE_THEME, - scenes: ["thread", "terminal", "review", "threads", "environments"], + scenes: ["thread", "terminal", "review", "threads", "environments", "agent-activity"], storeAsset: { store: "apple", directory: "apple/iphone-6.5", @@ -143,6 +143,8 @@ const config: ShowcaseConfig = { appearance: "dark", theme: DEFAULT_SHOWCASE_THEME, orientation: "landscape", + // The lock screen does not follow the app's self-rotation, so a + // headless landscape capture of it would come out sideways. scenes: ["thread", "terminal", "review", "threads", "environments"], storeAsset: { store: "apple", @@ -167,7 +169,7 @@ const config: ShowcaseConfig = { height: 1920, density: 420, }, - scenes: ["thread", "terminal", "review", "threads", "environments"], + scenes: ["thread", "terminal", "review", "threads", "environments", "agent-activity"], storeAsset: { store: "google-play", directory: "google-play/phone", @@ -190,7 +192,7 @@ const config: ShowcaseConfig = { height: 1920, density: 288, }, - scenes: ["thread", "terminal", "review", "threads", "environments"], + scenes: ["thread", "terminal", "review", "threads", "environments", "agent-activity"], storeAsset: { store: "google-play", directory: "google-play/tablet-7", @@ -213,7 +215,7 @@ const config: ShowcaseConfig = { height: 2560, density: 288, }, - scenes: ["thread", "terminal", "review", "threads", "environments"], + scenes: ["thread", "terminal", "review", "threads", "environments", "agent-activity"], storeAsset: { store: "google-play", directory: "google-play/tablet-10", diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index ccce919a7443..0e5281819fda 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -26,6 +26,7 @@ import showcaseConfig, { import { SHOWCASE_ENVIRONMENTS, SHOWCASE_PROJECTS, + SHOWCASE_THREADS, seedShowcaseEnvironment, } from "./mobile-showcase-environment.ts"; @@ -934,6 +935,98 @@ async function waitForIosShowcaseScene( throw new Error(`iOS showcase scene '${scene}' did not render within ${timeoutMs}ms.`); } +function iosAxe(): string { + return NodeProcess.env.AXE_PATH ?? "axe"; +} + +async function runAxe(udid: string, args: ReadonlyArray): Promise { + // HID events sent right after the simulator settles are dropped without it. + await runCommand(iosAxe(), [...args, "--udid", udid], { + env: { ...NodeProcess.env, AXE_HID_STABILIZATION_MS: "3000" }, + }).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error( + "The agent-activity scene drives the simulator with AXe. Install it with `brew tap cameroncooke/axe && brew install axe`, or set AXE_PATH.", + ); + } + throw error; + }); +} + +async function iosSimulatorLocked(udid: string): Promise { + const state = await commandOutput("xcrun", [ + "simctl", + "spawn", + udid, + "notifyutil", + "-g", + "com.apple.springboard.lockstate", + ]).catch(() => ""); + return state.trim().endsWith(" 1"); +} + +/** + * The staging app asks for notification permission; simctl has no privacy + * service for it, so answer the prompt until the scene reports ready. + */ +async function allowIosNotificationsUntilReady(udid: string, ready: Promise): Promise { + const state = { settled: false }; + const tapping = (async () => { + while (!state.settled) { + await runAxe(udid, ["tap", "--label", "Allow"]).catch(() => undefined); + if (!state.settled) await delay(1_000); + } + })(); + try { + await ready; + } finally { + state.settled = true; + await tapping; + } +} + +/** + * Locks the simulator over the staged Live Activity and delivers the alert a + * relay push would, so the lock screen shows both. + */ +async function presentIosLockScreen(udid: string): Promise { + await runAxe(udid, ["button", "lock"]); + const deadline = Date.now() + 15_000; + while (!(await iosSimulatorLocked(udid))) { + if (Date.now() > deadline) throw new Error(`Simulator ${udid} did not lock.`); + await delay(500); + } + const alert = showcaseAgentAlert(); + await new Promise((resolve, reject) => { + const child = NodeChildProcess.execFile( + "xcrun", + ["simctl", "push", udid, ANDROID_PACKAGE, "-"], + { cwd: REPO_ROOT }, + (error) => (error ? reject(error) : resolve()), + ); + child.stdin?.end( + JSON.stringify({ + aps: { alert: { title: alert.title, body: alert.body }, sound: "default" }, + }), + ); + }); +} + +async function unlockIosSimulator(udid: string): Promise { + // The first press wakes the display, the second dismisses the lock screen. + await runAxe(udid, ["button", "home"]); + await delay(2_000); + await runAxe(udid, ["button", "home"]); +} + +/** Mirrors the app's staged hero row (showcaseAgentActivity.ts). */ +function showcaseAgentAlert(): { readonly title: string; readonly body: string } { + const thread = SHOWCASE_THREADS.find((candidate) => candidate.id === "pocket-command-center"); + const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === thread?.projectId); + if (!thread || !project) throw new Error("The showcase fixture lost its agent-activity thread."); + return { title: thread.title, body: `Approval: ${project.title}` }; +} + async function captureIos( capture: ShowcaseCapture & { readonly device: ShowcaseIosDevice }, appPath: string | null, @@ -1021,6 +1114,12 @@ async function captureIos( for (const [sceneIndex, scene] of capture.scenes.entries()) { if (sceneIndex > 0) await NodeFSP.rm(readyPath, { force: true }); await NodeFSP.writeFile(scenePath, scene); + const waitForScene = (timeoutMs?: number) => { + const ready = waitForIosShowcaseScene(simulator.udid, scene, timeoutMs); + return scene === "agent-activity" + ? allowIosNotificationsUntilReady(simulator.udid, ready) + : ready; + }; if (sceneIndex === 0) { for (let attempt = 0; attempt < 2; attempt += 1) { const isLastAttempt = attempt === 1; @@ -1028,7 +1127,7 @@ async function captureIos( // A freshly installed Expo development build can spend well over 30s // applying an already-bundled update after it reaches 100%. Killing it // at that point sends the next capture back to the dev launcher. - await waitForIosShowcaseScene(simulator.udid, scene, 120_000); + await waitForScene(120_000); break; } catch (error) { if (isLastAttempt) throw error; @@ -1036,8 +1135,9 @@ async function captureIos( } } } else { - await waitForIosShowcaseScene(simulator.udid, scene); + await waitForScene(); } + if (scene === "agent-activity") await presentIosLockScreen(simulator.udid); await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs); const destination = NodePath.join( showcaseCaptureDirectory(outputDirectory, capture), @@ -1053,6 +1153,7 @@ async function captureIos( await runCommand("sips", ["--rotate", "270", destination]); } } + if (scene === "agent-activity") await unlockIosSimulator(simulator.udid); await finalizeCapture(destination, capture.device); } } @@ -1248,6 +1349,15 @@ async function captureAndroid( await runAdb(serial, ["install", "-r", apkPath]); } await runAdb(serial, ["shell", "pm", "clear", ANDROID_PACKAGE]); + // The agent-activity scene posts notifications; granting up front keeps the + // runtime prompt off every capture. + await runAdb(serial, [ + "shell", + "pm", + "grant", + ANDROID_PACKAGE, + "android.permission.POST_NOTIFICATIONS", + ]); await prepareAndroidShowcaseApp(serial); await runAdb(serial, ["reverse", `tcp:${config.metroPort}`, `tcp:${config.metroPort}`]); const metroUrl = encodeURIComponent(`http://127.0.0.1:${config.metroPort}?disableOnboarding=1`); @@ -1275,6 +1385,9 @@ async function captureAndroid( for (const [sceneIndex, scene] of capture.scenes.entries()) { if (sceneIndex > 0) await writeAndroidShowcaseScene(serial, scene); await waitForAndroidShowcaseScene(serial, scene); + if (scene === "agent-activity") { + await runAdb(serial, ["shell", "cmd", "statusbar", "expand-notifications"]); + } await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000)); const destination = NodePath.join( showcaseCaptureDirectory(outputDirectory, capture), @@ -1292,6 +1405,9 @@ async function captureAndroid( ); }); await NodeFSP.writeFile(destination, png); + if (scene === "agent-activity") { + await runAdb(serial, ["shell", "cmd", "statusbar", "collapse"]); + } await finalizeCapture(destination, capture.device); } } From 568ba476d3e02ec6e11f45ee464c03728d3b6cea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 23 Sep 2026 13:36:20 -0700 Subject: [PATCH 2/7] ci(mobile): trust the AXe tap before installing it Co-Authored-By: Claude Opus 5.5 --- .github/workflows/mobile-showcase-screenshots.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 29e18ea71926..fc75c447c6db 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -79,7 +79,13 @@ jobs: - name: Install AXe # Locks the simulator and answers the notification prompt for the # agent-activity scene. - run: brew tap cameroncooke/axe && brew install axe + # Homebrew refuses third-party tap formulae until they are trusted; + # older releases have no trust command and install them as is. + run: | + brew tap cameroncooke/axe + if brew commands | grep -qx trust; then brew trust --formula cameroncooke/axe/axe; fi + brew install cameroncooke/axe/axe + axe --version - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" From 8e7910bc22281e372dc53cdedb49405b2c3f4746 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 23 Sep 2026 13:40:45 -0700 Subject: [PATCH 3/7] fix(mobile): reset to Home for the agent-activity showcase scene The scene follows environments, whose settings sheet popToTop does not close. Staging failures now log their reason to Metro. Co-Authored-By: Claude Opus 5.5 --- .../showcase/ShowcaseCaptureCoordinator.tsx | 20 +++++++++++++++++-- .../showcase/stageShowcaseAgentActivity.ts | 16 +++++++++------ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index a21c45e0d8df..716f4edd4166 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -118,6 +118,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) } let cancelled = false; + let lastOutcome: string | null = null; void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { isCancelled: () => cancelled, }).then((applied) => { @@ -238,10 +239,15 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) environmentId: String(showcaseThread.environmentId), threadId: SHOWCASE_THREAD_ID, }; - if (requestedScene === "threads" || requestedScene === "agent-activity") { + if (requestedScene === "threads") { navigation.dispatch(StackActions.popToTop()); return; } + // Follows the environments scene, whose settings sheet popToTop leaves open. + if (requestedScene === "agent-activity") { + navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: "Home" }] })); + return; + } const routes: ShowcaseResetRoute[] = [{ name: "Home" }]; if (requestedScene === "environments") { routes.push({ @@ -283,12 +289,22 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) return; } let cancelled = false; + let lastOutcome: string | null = null; void retryShowcaseOperation( async () => { const now = Date.now(); const { threads: latestThreads, projects: latestProjects } = entitiesRef.current; const activity = buildShowcaseAgentActivity(latestThreads, latestProjects, now); - return activity !== null && (await stageShowcaseAgentActivity(activity, now)); + const outcome = + activity === null + ? "fixture threads not loaded" + : await stageShowcaseAgentActivity(activity, now); + if (outcome === true) return true; + // Surfaces in the runner's Metro output when the scene never turns ready. + if (outcome !== lastOutcome) + console.warn(`[showcase] agent activity not staged: ${outcome}`); + lastOutcome = outcome; + return false; }, // The first attempt waits on the notification permission prompt until // the runner answers it. diff --git a/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts index a8de0b8718d9..6b1f61815b37 100644 --- a/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts +++ b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts @@ -12,27 +12,31 @@ import { showcaseAndroidActivityData } from "./showcaseAgentActivity"; /** * Puts the staged agent activity on screen for the capture runner, which then * locks the simulator (iOS) or opens the notification shade (Android). - * Resolves false when the build cannot show it yet, so the caller retries. + * Resolves true once shown, otherwise the reason it could not be, so the + * caller can retry and report. */ export async function stageShowcaseAgentActivity( activity: AgentActivityProps, now: number, -): Promise { +): Promise { // The runner answers the iOS prompt and pre-grants Android's, so this only // settles the permission the runner's alert delivery depends on. const permission = await Notifications.requestPermissionsAsync({ ios: { allowAlert: true, allowBadge: true, allowSound: true }, }); - if (!permission.granted) return false; + if (!permission.granted) return `notification permission ${permission.status}`; if (Platform.OS === "android") { - return showAndroidShowcaseAgentActivity(showcaseAndroidActivityData(activity, now)); + return ( + showAndroidShowcaseAgentActivity(showcaseAndroidActivityData(activity, now)) || + "native showShowcaseActivity missing" + ); } - if (Platform.OS !== "ios") return false; + if (Platform.OS !== "ios") return `unsupported platform ${Platform.OS}`; // A retried or revisited scene must not stack a second card. await Promise.all(getAgentLiveActivities().map((existing) => existing.end("immediate"))); // ActivityKit only starts activities while the app is foreground, which // holds here: the runner locks the device after the scene reports ready. - return startAgentLiveActivity(activity) !== null; + return startAgentLiveActivity(activity) !== null || "Live Activity did not start"; } From ef4797f2066792884370eb93c1324f6b9e6ef942 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 23 Sep 2026 14:01:03 -0700 Subject: [PATCH 4/7] fix(mobile): wake the lock screen and allow Live Activities in the showcase Co-Authored-By: Claude Opus 5.5 --- scripts/mobile-showcase.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 0e5281819fda..5737daf46087 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -1010,6 +1010,41 @@ async function presentIosLockScreen(udid: string): Promise { }), ); }); + await wakeIosLockScreen(udid); + // The first Live Activity on the lock screen asks to keep allowing them. + await delay(2_000); + await runAxe(udid, ["tap", "--label", "Allow"]).catch(() => undefined); +} + +/** + * The alert usually wakes the display, but not always. A home press on a dark + * display only wakes it, so press only after a screenshot proves it is dark; + * pressing on a lit lock screen would unlock the device instead. + */ +async function wakeIosLockScreen(udid: string): Promise { + const probe = NodePath.join(NodeOS.tmpdir(), `t3-showcase-wake-${udid}.png`); + try { + for (let attempt = 0; attempt < 5; attempt += 1) { + await delay(2_000); + await runCommand("xcrun", ["simctl", "io", udid, "screenshot", probe]); + if (!pngIsBlack(await NodeFSP.readFile(probe))) return; + await runAxe(udid, ["button", "home"]); + } + throw new Error(`Simulator ${udid} lock screen stayed dark.`); + } finally { + await NodeFSP.rm(probe, { force: true }); + } +} + +function pngIsBlack(bytes: Uint8Array): boolean { + const { data } = PNG.sync.read(Buffer.from(bytes)); + // Sample a sparse grid; a sleeping display is uniformly black. + for (let offset = 0; offset < data.length; offset += 4 * 997) { + if ((data[offset] ?? 0) > 8 || (data[offset + 1] ?? 0) > 8 || (data[offset + 2] ?? 0) > 8) { + return false; + } + } + return true; } async function unlockIosSimulator(udid: string): Promise { From 4742b456db948c21a5ada0b0a6895278915f9702 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 23 Sep 2026 14:18:08 -0700 Subject: [PATCH 5/7] fix(mobile): keep the agent-activity showcase free of dimming and emulator notices Co-Authored-By: Claude Opus 5.5 --- scripts/mobile-showcase.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 5737daf46087..4a748552124d 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -1036,6 +1036,16 @@ async function wakeIosLockScreen(udid: string): Promise { } } +/** + * Devices with an always-on display dim the lock screen within seconds. A tap + * on bare wallpaper, between the clock and the Live Activity, lights it back + * up without unlocking or opening anything. + */ +async function brightenIosLockScreen(udid: string): Promise { + await runAxe(udid, ["tap", "-x", "200", "-y", "420"]); + await delay(1_000); +} + function pngIsBlack(bytes: Uint8Array): boolean { const { data } = PNG.sync.read(Buffer.from(bytes)); // Sample a sparse grid; a sleeping display is uniformly black. @@ -1174,6 +1184,7 @@ async function captureIos( } if (scene === "agent-activity") await presentIosLockScreen(simulator.udid); await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs); + if (scene === "agent-activity") await brightenIosLockScreen(simulator.udid); const destination = NodePath.join( showcaseCaptureDirectory(outputDirectory, capture), `${scene}.png`, @@ -1205,6 +1216,22 @@ async function runAdb(serial: string, args: ReadonlyArray): Promise { + const keys = (await adbOutput(serial, ["shell", "cmd", "notification", "list"])) + .split("\n") + .map((line) => line.trim()) + .filter((key) => key.includes("|") && !key.includes(`|${ANDROID_PACKAGE}|`)); + for (const key of keys) { + // adb joins shell args with spaces, so the key needs quoting for the pipes. + await runAdb(serial, ["shell", `cmd notification snooze --for 3600000 '${key}'`]); + } +} + async function runningAndroidAvds(): Promise> { const adb = androidSdkTool("platform-tools/adb"); const devices = (await commandOutput(adb, ["devices"])) @@ -1421,6 +1448,7 @@ async function captureAndroid( if (sceneIndex > 0) await writeAndroidShowcaseScene(serial, scene); await waitForAndroidShowcaseScene(serial, scene); if (scene === "agent-activity") { + await snoozeAndroidSystemNotifications(serial); await runAdb(serial, ["shell", "cmd", "statusbar", "expand-notifications"]); } await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000)); From 25b206fbbf4380c718b37fc54a1f5c62fe1c9ed1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 23 Sep 2026 14:49:42 -0700 Subject: [PATCH 6/7] fix(mobile): drop the lock-screen tap that did not undim the 6.9-inch capture Co-Authored-By: Claude Opus 5.5 --- scripts/mobile-showcase.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 4a748552124d..1fa8e3009f4e 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -1036,16 +1036,6 @@ async function wakeIosLockScreen(udid: string): Promise { } } -/** - * Devices with an always-on display dim the lock screen within seconds. A tap - * on bare wallpaper, between the clock and the Live Activity, lights it back - * up without unlocking or opening anything. - */ -async function brightenIosLockScreen(udid: string): Promise { - await runAxe(udid, ["tap", "-x", "200", "-y", "420"]); - await delay(1_000); -} - function pngIsBlack(bytes: Uint8Array): boolean { const { data } = PNG.sync.read(Buffer.from(bytes)); // Sample a sparse grid; a sleeping display is uniformly black. @@ -1184,7 +1174,6 @@ async function captureIos( } if (scene === "agent-activity") await presentIosLockScreen(simulator.udid); await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs); - if (scene === "agent-activity") await brightenIosLockScreen(simulator.udid); const destination = NodePath.join( showcaseCaptureDirectory(outputDirectory, capture), `${scene}.png`, From 82a9d41cf9d9fd56ee7969203bd198404d1bf2af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 23 Sep 2026 14:53:52 -0700 Subject: [PATCH 7/7] style(mobile): wrap the Android postAlert signature for ktlint Co-Authored-By: Claude Opus 5.5 --- .../modules/t3agentnotifications/AgentNotifications.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 a5dafb253cfe..00af98ac4b28 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 @@ -147,7 +147,12 @@ object AgentNotifications { } } - private fun postAlert(context: Context, scheme: String, data: Map, alertId: String) { + private fun postAlert( + context: Context, + scheme: String, + data: Map, + alertId: String + ) { val title = data["alert_title"].orEmpty().take(120) // Grouped alerts list up to five 120-character thread titles. val body = data["alert_body"].orEmpty().take(608)