Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/mobile-showcase-screenshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ 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.
# 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 }}"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -157,6 +147,36 @@ object AgentNotifications {
}
}

private fun postAlert(
context: Context,
scheme: String,
data: Map<String, String>,
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<String, String>) {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class T3AgentNotificationsModule : Module() {
appContext.reactContext?.let { AgentNotifications.clear(it) }
}

Function("showShowcaseActivity") { scheme: String, data: Map<String, String> ->
appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) }
}

Function("openLiveUpdateSettings") {
val context = appContext.reactContext
if (context == null || Build.VERSION.SDK_INT < 36) {
Expand Down
21 changes: 14 additions & 7 deletions apps/mobile/src/features/agent-awareness/androidNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): void;
}

const native =
Expand All @@ -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<string, string>): boolean {
if (!native?.showShowcaseActivity) return false;
native.showShowcaseActivity(appScheme(), data);
return true;
}

export function clearAndroidAgentNotifications(): void {
Expand Down
67 changes: 65 additions & 2 deletions apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import {
buildShowcasePendingTasks,
SHOWCASE_PENDING_TASK_DEFINITIONS,
} from "./showcasePendingTasks";
import { buildShowcaseAgentActivity } from "./showcaseAgentActivity";
import { retryShowcaseOperation } from "./showcaseRetry";
import {
clearShowcaseRenderSignal,
getShowcaseRenderSignal,
isShowcaseNativeContentReady,
subscribeToShowcaseRenderSignal,
} from "./showcaseRenderSignal";
import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity";

const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
const SHOWCASE_THREAD_ID = "remote-command-center";
Expand Down Expand Up @@ -75,7 +77,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
const [themeRequestSettled, setThemeRequestSettled] = useState(false);
const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);
const [orientationSettled, setOrientationSettled] = useState(false);
const [agentActivityStaged, setAgentActivityStaged] = useState(false);
const requestedSceneRef = useRef<ShowcaseScene | null>(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,
Expand Down Expand Up @@ -109,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) => {
Expand All @@ -129,6 +139,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();
Expand Down Expand Up @@ -176,7 +187,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 &&
Expand Down Expand Up @@ -228,6 +243,11 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
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({
Expand Down Expand Up @@ -264,6 +284,39 @@ 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;
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);
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.
{ isCancelled: () => cancelled, attemptTimeoutMs: 60_000 },
).then((staged) => {
if (!cancelled && staged) setAgentActivityStaged(true);
});
return () => {
cancelled = true;
};
}, [agentActivityStaged, hasFixture, scene]);

useEffect(() => {
if (
!SHOWCASE_ENABLED ||
Expand All @@ -276,6 +329,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);
Expand All @@ -298,7 +352,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;

Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/features/showcase/nativeShowcaseScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
93 changes: 93 additions & 0 deletions apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading
Loading