Skip to content
Closed
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
15 changes: 13 additions & 2 deletions apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,18 @@ import { workspaceConnections } from "../../state/workspace";
import { resolveThemeAppearance } from "../settings/appearance/AppearancePreferencesProvider";
import {
applyNativeShowcaseOrientation,
getNativeShowcaseAgentActivityFixture,
getNativeShowcaseOrientation,
getNativeShowcasePairingUrls,
getNativeShowcaseScene,
getNativeShowcaseTheme,
markNativeShowcaseReady,
type ShowcaseScene,
} from "./nativeShowcaseScene";
import { buildShowcaseAgentActivity } from "./showcaseAgentActivity";
import {
buildShowcaseAgentActivity,
type ShowcaseAgentActivityFixture,
} from "./showcaseAgentActivity";
import {
isShowcaseThemeApplied,
SHOWCASE_THREAD_ID,
Expand Down Expand Up @@ -127,6 +131,7 @@ export function installShowcaseCapture(
let requestedTheme: MobileThemeId | null = null;
let orientationSettled = false;
let requestedScene: ShowcaseScene | null = null;
let requestedFixture: ShowcaseAgentActivityFixture = "store";
let pendingTasks: Run | null = null;
let agentActivity: Run | null = null;
let lastNavigation: ShowcaseNavigationTarget | null = null;
Expand Down Expand Up @@ -189,6 +194,11 @@ export function installShowcaseCapture(
clearShowcaseRenderSignal();
agentActivity = cancelRun(agentActivity);
}
const fixture = getNativeShowcaseAgentActivityFixture();
if (fixture !== requestedFixture) {
requestedFixture = fixture;
agentActivity = cancelRun(agentActivity);
}
};

const seedPendingTasks = () => {
Expand Down Expand Up @@ -228,11 +238,12 @@ export function installShowcaseCapture(
appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
appAtomRegistry.get(environmentProjects.projectsAtom),
now,
requestedFixture,
);
const outcome =
activity === null
? "fixture threads not loaded"
: await stageShowcaseAgentActivity(activity, now);
: await stageShowcaseAgentActivity(activity, now, isCancelled(run));
if (outcome === true) return true;
if (outcome !== lastOutcome) {
console.warn(`[showcase] agent activity not staged: ${outcome}`);
Expand Down
23 changes: 19 additions & 4 deletions apps/mobile/src/features/showcase/nativeShowcaseScene.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { requireOptionalNativeModule } from "expo";

import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme";
import {
parseShowcaseAgentActivityFixture,
type ShowcaseAgentActivityFixture,
} from "./showcaseAgentActivity";

const SHOWCASE_SCENES = [
"threads",
Expand Down Expand Up @@ -55,15 +59,26 @@ export function getNativeShowcasePairingUrls(): ReadonlyArray<string> {
}
}

export function getNativeShowcaseScene(): ShowcaseScene | null {
// `agent-activity:handoff-approval` requests the agent-activity scene with a named Live Activity fixture.
function nativeShowcaseSceneRequest(): readonly [string | undefined, string | undefined] {
try {
const scene = nativeShowcaseControls()?.getShowcaseScene?.()?.trim();
return SHOWCASE_SCENES.find((candidate) => candidate === scene) ?? null;
const [scene, fixture] =
nativeShowcaseControls()?.getShowcaseScene?.()?.trim().split(":") ?? [];
return [scene, fixture];
} catch {
return null;
return [undefined, undefined];
}
}

export function getNativeShowcaseScene(): ShowcaseScene | null {
const [scene] = nativeShowcaseSceneRequest();
return SHOWCASE_SCENES.find((candidate) => candidate === scene) ?? null;
}

export function getNativeShowcaseAgentActivityFixture(): ShowcaseAgentActivityFixture {
return parseShowcaseAgentActivityFixture(nativeShowcaseSceneRequest()[1]);
}

export function getNativeShowcaseTheme(): MobileThemeId | null {
try {
const theme = nativeShowcaseControls()?.getShowcaseTheme?.()?.trim();
Expand Down
76 changes: 60 additions & 16 deletions apps/mobile/src/features/showcase/showcaseAgentActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,58 @@ import type {
AgentActivityRowProps,
} from "../../widgets/AgentActivity";

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<{
interface ShowcaseAgentActivityRow {
readonly threadId: string;
readonly phase: AgentActivityPhase;
readonly minutesAgo: number;
}>;
}

const fixLoginRedirect = (phase: AgentActivityPhase): ShowcaseAgentActivityRow => ({
threadId: "fix-login-redirect",
phase,
minutesAgo: 1,
});
const updateSnapshots = (phase: AgentActivityPhase): ShowcaseAgentActivityRow => ({
threadId: "update-snapshots",
phase,
minutesAgo: 1,
});

// Rows follow the relay's order: threads that need the user, failures, work, then outcomes.
// With nothing in flight the relay lists the newest outcome first, so the widget must lead with failures.
const SHOWCASE_AGENT_ACTIVITY_FIXTURES = {
store: [
{ 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 },
],
"handoff-working": [fixLoginRedirect("running"), updateSnapshots("running")],
"handoff-approval": [fixLoginRedirect("waiting_for_approval"), updateSnapshots("completed")],
"handoff-input": [fixLoginRedirect("waiting_for_input"), updateSnapshots("completed")],
"all-done": [updateSnapshots("completed"), { ...fixLoginRedirect("completed"), minutesAgo: 4 }],
"done-with-failure": [
updateSnapshots("completed"),
{ ...fixLoginRedirect("failed"), minutesAgo: 4 },
],
"phase-starting": [fixLoginRedirect("starting")],
"phase-running": [fixLoginRedirect("running")],
"phase-waiting_for_approval": [fixLoginRedirect("waiting_for_approval")],
"phase-waiting_for_input": [fixLoginRedirect("waiting_for_input")],
"phase-completed": [fixLoginRedirect("completed")],
"phase-failed": [fixLoginRedirect("failed")],
"phase-stale": [fixLoginRedirect("stale"), updateSnapshots("stale")],
} as const satisfies Record<string, ReadonlyArray<ShowcaseAgentActivityRow>>;

export type ShowcaseAgentActivityFixture = keyof typeof SHOWCASE_AGENT_ACTIVITY_FIXTURES;

export function parseShowcaseAgentActivityFixture(
value: string | undefined,
): ShowcaseAgentActivityFixture {
return value !== undefined && Object.hasOwn(SHOWCASE_AGENT_ACTIVITY_FIXTURES, value)
? (value as ShowcaseAgentActivityFixture)
: "store";
}

const STATUS_BY_PHASE: Record<AgentActivityPhase, string> = {
starting: "Connecting",
Expand All @@ -30,21 +72,17 @@ const STATUS_BY_PHASE: Record<AgentActivityPhase, string> = {
failed: "Failed",
};

const ACTIVE_PHASES: ReadonlySet<AgentActivityPhase> = new Set([
"starting",
"running",
"waiting_for_approval",
"waiting_for_input",
]);
const TERMINAL_PHASES: ReadonlySet<AgentActivityPhase> = new Set(["completed", "failed"]);

/** Returns null until every staged thread and its project have loaded. */
export function buildShowcaseAgentActivity(
threads: ReadonlyArray<EnvironmentThreadShell>,
projects: ReadonlyArray<EnvironmentProject>,
now: number,
fixture: ShowcaseAgentActivityFixture = "store",
): AgentActivityProps | null {
const rows: AgentActivityRowProps[] = [];
for (const definition of SHOWCASE_AGENT_ACTIVITY_ROWS) {
for (const definition of SHOWCASE_AGENT_ACTIVITY_FIXTURES[fixture]) {
const thread = threads.find((candidate) => String(candidate.id) === definition.threadId);
const project = thread
? projects.find(
Expand All @@ -66,10 +104,16 @@ export function buildShowcaseAgentActivity(
deepLink: `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(definition.threadId)}`,
});
}
const activeCount = rows.filter((row) => !TERMINAL_PHASES.has(row.phase)).length;
return {
title: "T3 Code",
subtitle: "Agent work in progress",
activeCount: rows.filter((row) => ACTIVE_PHASES.has(row.phase)).length,
subtitle:
activeCount > 0
? "Agent work in progress"
: rows.some((row) => row.phase === "failed")
? "Agent work failed"
: "Agent work completed",
activeCount,
updatedAt: new Date(now).toISOString(),
activities: rows,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ describe("showcaseSceneOnScreen", () => {
it("treats the threads list as the agent activity scene only when that scene is requested", () => {
expect(showcaseSceneOnScreen("agent-activity", "/")).toBe("agent-activity");
expect(showcaseSceneOnScreen("threads", "/")).toBe("threads");
expect(showcaseSceneOnScreen("agent-activity", "/threads/env/thread")).toBe("thread");
expect(showcaseSceneOnScreen("thread", "/threads/env/thread")).toBe("thread");
});

it("keeps the thread a Live Activity tap opened inside the agent activity scene", () => {
expect(showcaseSceneOnScreen("agent-activity", "/threads/env/thread")).toBe("agent-activity");
expect(showcaseSceneOnScreen("agent-activity", "/threads/env/thread/review")).toBe("review");
expect(showcaseSceneOnScreen(null, "/threads/env/thread/review?x=1")).toBe("review");
});
});
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/features/showcase/showcaseCaptureModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null {
return null;
}

/** Agent activity is staged on the threads list, so that route counts as the scene. */
/**
* Agent activity is staged on the threads list, so that route counts as the scene. Tapping the
* Live Activity opens a thread, which stays part of the scene instead of navigating back.
*/
export function showcaseSceneOnScreen(
requestedScene: ShowcaseScene | null,
pathname: string,
): ShowcaseScene | null {
const routeScene = sceneFromPathname(pathname);
return requestedScene === "agent-activity" && routeScene === "threads"
return requestedScene === "agent-activity" &&
(routeScene === "threads" || routeScene === "thread")
? "agent-activity"
: routeScene;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

import type { AgentActivityProps } from "../../widgets/AgentActivity";

const native = vi.hoisted(() => ({
activities: [] as Array<{ end: () => Promise<void> }>,
started: [] as Array<string>,
}));

vi.mock("react-native", () => ({ Platform: { OS: "ios" } }));
vi.mock("expo-notifications", () => ({
requestPermissionsAsync: async () => ({ granted: true, status: "granted" }),
dismissAllNotificationsAsync: async () => {},
}));
vi.mock("../agent-awareness/androidNotifications", () => ({
showAndroidShowcaseAgentActivity: () => true,
}));
vi.mock("../agent-awareness/agentLiveActivity", () => ({
getAgentLiveActivities: () => [...native.activities],
startAgentLiveActivity: (props: AgentActivityProps) => {
const activity = { end: async () => {} };
native.started.push(props.title);
native.activities = [activity];
return activity;
},
}));

const { stageShowcaseAgentActivity } = await import("./stageShowcaseAgentActivity");

function fixture(title: string): AgentActivityProps {
return {
title,
subtitle: "Agent work in progress",
activeCount: 1,
updatedAt: "2026-10-31T17:42:00.000Z",
activities: [],
};
}

describe("stageShowcaseAgentActivity", () => {
beforeEach(() => {
native.activities = [];
native.started = [];
});

it("does not start a replaced fixture after its native end call settles", async () => {
let finishEnding = () => {};
const ending = new Promise<void>((resolve) => (finishEnding = resolve));
let endCalls = 0;
native.activities = [{ end: () => (endCalls++ === 0 ? ending : Promise.resolve()) }];
let replaced = false;

const first = stageShowcaseAgentActivity(fixture("store"), 0, () => replaced);
await vi.waitFor(() => expect(endCalls).toBe(1));
replaced = true;
const second = await stageShowcaseAgentActivity(fixture("handoff-approval"), 0, () => false);
finishEnding();

expect(second).toBe(true);
expect(await first).not.toBe(true);
expect(native.started).toEqual(["handoff-approval"]);
});
});
10 changes: 9 additions & 1 deletion apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import { showcaseAndroidActivityData } from "./showcaseAgentActivity";
export async function stageShowcaseAgentActivity(
activity: AgentActivityProps,
now: number,
// A newer request may replace this one while it awaits; it must not start after that.
isCancelled: () => boolean,
): Promise<true | string> {
const permission = await Notifications.requestPermissionsAsync({
ios: { allowAlert: true, allowBadge: true, allowSound: true },
});
if (!permission.granted) return `notification permission ${permission.status}`;
await Notifications.dismissAllNotificationsAsync();
if (isCancelled()) return "replaced by a newer request";

if (Platform.OS === "android") {
return (
Expand All @@ -27,7 +30,12 @@ export async function stageShowcaseAgentActivity(
}
if (Platform.OS !== "ios") return `unsupported platform ${Platform.OS}`;

// A stale fixture is staged with a staleDate that passes almost at once, as a silent relay would leave it.
const staleDate = activity.activities.some((row) => row.phase === "stale")
? new Date(now + 2_000)
: undefined;
await Promise.all(getAgentLiveActivities().map((existing) => existing.end("immediate")));
if (isCancelled()) return "replaced by a newer request";
// ActivityKit needs foreground startup; the runner locks only after this scene is ready.
return startAgentLiveActivity(activity) !== null || "Live Activity did not start";
return startAgentLiveActivity(activity, staleDate) !== null || "Live Activity did not start";
}
Loading
Loading