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
16 changes: 13 additions & 3 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,22 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
initialLayout: "./src/widgets/SubscriptionUsage.android.tsx",
},
},
// One name backs the Live Activity and the Agent status widget; expo-widgets stores their layouts apart.
{
name: "AgentActivity",
displayName: "Agent Activity",
description: "Shows the current state of active T3 Code agents.",
displayName: "Agent status",
description: "Which agent needs you, which are working, and which finished.",
android: null,
ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] },
ios: {
supportedFamilies: [
"systemSmall",
"systemMedium",
"systemLarge",
"accessoryCircular",
"accessoryRectangular",
"accessoryInline",
],
},
},
],
},
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
} from "./lib/foundation-fast-refresh";
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
import { AgentStatusCoordinator } from "./widgets/AgentStatusCoordinator";
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage";
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
Expand Down Expand Up @@ -226,6 +227,7 @@ function AppContent() {
<>
<SplashScreenCoordinator />
<SubscriptionUsageCoordinator />
<AgentStatusCoordinator />
<GestureHandlerRootView className="flex-1">
<KeyboardProvider statusBarTranslucent>
<SafeAreaProvider>
Expand Down
36 changes: 33 additions & 3 deletions apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,19 @@ 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,
showcaseAgentStatusRowsAtom,
type ShowcaseAgentActivityFixture,
} from "./showcaseAgentActivity";
import {
isShowcaseThemeApplied,
SHOWCASE_THREAD_ID,
Expand Down Expand Up @@ -100,6 +105,7 @@ function showcaseNavigationAction(
case "threads":
return StackActions.popToTop();
case "agent-activity":
case "agent-status":
return CommonActions.reset({ index: 0, routes: [{ name: "Home" }] });
case "environments":
return CommonActions.reset(settingsNavigationState(rootState, "SettingsEnvironments"));
Expand Down Expand Up @@ -127,6 +133,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 @@ -188,6 +195,13 @@ export function installShowcaseCapture(
// Draws belong to their scene request; revisiting review must wait for its new surface.
clearShowcaseRenderSignal();
agentActivity = cancelRun(agentActivity);
// Hands the widget back to live data.
if (scene !== "agent-status") appAtomRegistry.set(showcaseAgentStatusRowsAtom, null);
}
const fixture = getNativeShowcaseAgentActivityFixture();
if (fixture !== requestedFixture) {
requestedFixture = fixture;
agentActivity = cancelRun(agentActivity);
}
};

Expand Down Expand Up @@ -228,11 +242,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 All @@ -248,6 +263,19 @@ export function installShowcaseCapture(
});
};

// The widget coordinator publishes these rows through the live path, with the app's theme.
const stageAgentStatus = () => {
const activity = buildShowcaseAgentActivity(
appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
appAtomRegistry.get(environmentProjects.projectsAtom),
Date.now(),
requestedFixture,
);
if (activity === null) return;
appAtomRegistry.set(showcaseAgentStatusRowsAtom, activity.activities);
agentActivity = { cancelled: false, done: true };
};

const updateReadiness = (key: string | null, scene: ShowcaseScene | null) => {
if (readiness?.key === key) return;
readiness?.cancel();
Expand Down Expand Up @@ -327,7 +355,9 @@ export function installShowcaseCapture(
}
}

if (scene === "agent-activity" && hasFixture) {
if (scene === "agent-status" && hasFixture) {
if (agentActivity === null) stageAgentStatus();
} else if (scene === "agent-activity" && hasFixture) {
if (agentActivity === null) stageAgentActivity();
} else if (agentActivity?.done === false) {
agentActivity = cancelRun(agentActivity);
Expand Down
25 changes: 21 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 All @@ -9,6 +13,7 @@ const SHOWCASE_SCENES = [
"review",
"environments",
"agent-activity",
"agent-status",
] as const;
export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];

Expand Down Expand Up @@ -55,15 +60,27 @@ export function getNativeShowcasePairingUrls(): ReadonlyArray<string> {
}
}

export function getNativeShowcaseScene(): ShowcaseScene | null {
// `agent-activity:handoff-approval` requests the agent-activity scene with a named fixture;
// `agent-status:<fixture>` stages the same fixture on the Agent status widget.
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
78 changes: 62 additions & 16 deletions apps/mobile/src/features/showcase/showcaseAgentActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,67 @@ import type {
EnvironmentProject,
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import { Atom } from "effect/reactivity";

import type {
AgentActivityPhase,
AgentActivityProps,
AgentActivityRowProps,
} from "../../widgets/AgentActivity";
import type { AgentStatusRow } from "../../widgets/agentStatusSnapshot";

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<{
/** The agent-status scene's fixture rows; the widget coordinator publishes them in place of live rows. */
export const showcaseAgentStatusRowsAtom = Atom.make<ReadonlyArray<AgentStatusRow> | null>(
null,
).pipe(Atom.keepAlive);

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.
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")],
"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")],
empty: [],
} 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 +74,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 +106,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
17 changes: 10 additions & 7 deletions apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@ const ready: ShowcaseReadinessInput = {
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("agent-status", "/")).toBe("agent-status");
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-status", "/threads/env/thread")).toBe("agent-status");
expect(showcaseSceneOnScreen("agent-activity", "/threads/env/thread/review")).toBe("review");
expect(showcaseSceneOnScreen(null, "/threads/env/thread/review?x=1")).toBe("review");
});
});
Expand Down Expand Up @@ -60,12 +67,8 @@ describe("showcaseReadinessKey", () => {
expect(showcaseReadinessKey({ ...ready, themeApplied: false })).toBeNull();
});

it("waits for agent activity to be staged", () => {
const agentActivity = {
...ready,
scene: "agent-activity",
requestedScene: "agent-activity",
} as const;
it.each(["agent-activity", "agent-status"] as const)("waits for %s to be staged", (scene) => {
const agentActivity = { ...ready, scene, requestedScene: scene } as const;
expect(showcaseReadinessKey(agentActivity)).toBeNull();
expect(showcaseReadinessKey({ ...agentActivity, agentActivityStaged: true })).not.toBeNull();
});
Expand Down
13 changes: 9 additions & 4 deletions apps/mobile/src/features/showcase/showcaseCaptureModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@ 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 and agent status are staged on the threads list, so that route counts as the
* scene. Tapping the Live Activity or the widget 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"
? "agent-activity"
return (requestedScene === "agent-activity" || requestedScene === "agent-status") &&
(routeScene === "threads" || routeScene === "thread")
? requestedScene
: routeScene;
}

Expand Down Expand Up @@ -88,7 +93,7 @@ export function showcaseReadinessKey(input: ShowcaseReadinessInput): string | nu
!input.hasFixture ||
!input.orientationSettled ||
!input.themeApplied ||
(scene === "agent-activity" && !input.agentActivityStaged) ||
((scene === "agent-activity" || scene === "agent-status") && !input.agentActivityStaged) ||
!isShowcaseNativeContentReady({
scene,
themeId: input.themeId,
Expand Down
Loading
Loading