From ee6bd07bd9cbcecd6ff78aec52ede57e1ec699f3 Mon Sep 17 00:00:00 2001 From: Neil Date: Sun, 1 Nov 2026 02:19:07 +0800 Subject: [PATCH 1/2] feat(mobile): see which agent thread needs you from the Live Activity --- .../showcase/ShowcaseCaptureCoordinator.tsx | 15 +- .../features/showcase/nativeShowcaseScene.ts | 23 +- .../showcase/showcaseAgentActivity.ts | 70 ++- .../showcase/showcaseCaptureModel.test.ts | 7 +- .../features/showcase/showcaseCaptureModel.ts | 8 +- .../stageShowcaseAgentActivity.test.ts | 63 ++ .../showcase/stageShowcaseAgentActivity.ts | 10 +- apps/mobile/src/widgets/AgentActivity.test.ts | 285 +++++++++ apps/mobile/src/widgets/AgentActivity.tsx | 564 ++++++++++-------- scripts/mobile-showcase-environment.ts | 21 + 10 files changed, 800 insertions(+), 266 deletions(-) create mode 100644 apps/mobile/src/features/showcase/stageShowcaseAgentActivity.test.ts create mode 100644 apps/mobile/src/widgets/AgentActivity.test.ts diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index f59f53fc591ee..e3631e606f0b3 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -27,6 +27,7 @@ import { workspaceConnections } from "../../state/workspace"; import { resolveThemeAppearance } from "../settings/appearance/AppearancePreferencesProvider"; import { applyNativeShowcaseOrientation, + getNativeShowcaseAgentActivityFixture, getNativeShowcaseOrientation, getNativeShowcasePairingUrls, getNativeShowcaseScene, @@ -34,7 +35,10 @@ import { markNativeShowcaseReady, type ShowcaseScene, } from "./nativeShowcaseScene"; -import { buildShowcaseAgentActivity } from "./showcaseAgentActivity"; +import { + buildShowcaseAgentActivity, + type ShowcaseAgentActivityFixture, +} from "./showcaseAgentActivity"; import { isShowcaseThemeApplied, SHOWCASE_THREAD_ID, @@ -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; @@ -189,6 +194,11 @@ export function installShowcaseCapture( clearShowcaseRenderSignal(); agentActivity = cancelRun(agentActivity); } + const fixture = getNativeShowcaseAgentActivityFixture(); + if (fixture !== requestedFixture) { + requestedFixture = fixture; + agentActivity = cancelRun(agentActivity); + } }; const seedPendingTasks = () => { @@ -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}`); diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 3d7bdd004cec4..0e37361a472cc 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -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", @@ -55,15 +59,26 @@ export function getNativeShowcasePairingUrls(): ReadonlyArray { } } -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(); diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts index 784875fd6253b..ada9072d19870 100644 --- a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts @@ -9,16 +9,52 @@ 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. +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")], +} as const satisfies Record>; + +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 = { starting: "Connecting", @@ -30,21 +66,17 @@ const STATUS_BY_PHASE: Record = { failed: "Failed", }; -const ACTIVE_PHASES: ReadonlySet = new Set([ - "starting", - "running", - "waiting_for_approval", - "waiting_for_input", -]); +const TERMINAL_PHASES: ReadonlySet = new Set(["completed", "failed"]); /** Returns null until every staged thread and its project have loaded. */ export function buildShowcaseAgentActivity( threads: ReadonlyArray, projects: ReadonlyArray, 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( @@ -66,10 +98,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, }; diff --git a/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts b/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts index 729dbcabe4292..70717f8a4d35c 100644 --- a/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts +++ b/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts @@ -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"); }); }); diff --git a/apps/mobile/src/features/showcase/showcaseCaptureModel.ts b/apps/mobile/src/features/showcase/showcaseCaptureModel.ts index c47c58621c5ad..5a87e40662568 100644 --- a/apps/mobile/src/features/showcase/showcaseCaptureModel.ts +++ b/apps/mobile/src/features/showcase/showcaseCaptureModel.ts @@ -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; } diff --git a/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.test.ts b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.test.ts new file mode 100644 index 0000000000000..452753eafc4d6 --- /dev/null +++ b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.test.ts @@ -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 }>, + started: [] as Array, +})); + +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((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"]); + }); +}); diff --git a/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts index dc50a30841019..620fa18397902 100644 --- a/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts +++ b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts @@ -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 { 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 ( @@ -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"; } diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts new file mode 100644 index 0000000000000..bfab8f21dc960 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { + AgentActivityPhase, + AgentActivityProps, + AgentActivityRowProps, +} from "./AgentActivity"; + +vi.mock("expo-widgets", () => ({ createLiveActivity: (_name: string, layout: unknown) => layout })); +vi.mock("@expo/ui/swift-ui", () => ({ + HStack: "HStack", + Image: "Image", + Spacer: "Spacer", + Text: "Text", + VStack: "VStack", +})); +vi.mock("@expo/ui/swift-ui/modifiers", () => { + const modifier = + (name: string) => + (...args: ReadonlyArray) => ({ name, args }); + return { + Animation: { easeOut: (params: unknown) => ({ animation: "easeOut", params }) }, + ...Object.fromEntries( + [ + "accessibilityElement", + "accessibilityLabel", + "activityBackgroundTint", + "animation", + "contentTransition", + "font", + "foregroundStyle", + "frame", + "layoutPriority", + "lineLimit", + "padding", + "resizable", + "truncationMode", + "widgetURL", + ].map((name) => [name, modifier(name)]), + ), + }; +}); + +const { default: AgentActivity } = await import("./AgentActivity"); +const layout = AgentActivity as unknown as ( + props: AgentActivityProps, + environment: Record, +) => Record; + +interface Node { + readonly type: unknown; + readonly props: { + readonly children?: unknown; + readonly modifiers?: ReadonlyArray<{ name: string; args: ReadonlyArray }>; + readonly systemName?: string; + }; +} + +function* walk(node: unknown): Generator { + if (Array.isArray(node)) { + for (const child of node) yield* walk(child); + return; + } + if (!node || typeof node !== "object" || !("props" in node)) return; + yield node as Node; + yield* walk((node as Node).props.children); +} + +function modifierArgs(node: unknown, name: string): ReadonlyArray { + return [...walk(node)].flatMap((element) => + (element.props.modifiers ?? []).filter((m) => m.name === name).map((m) => m.args[0]), + ); +} + +function text(node: unknown): string { + return [...walk(node)] + .filter((element) => element.type === "Text") + .map((element) => [element.props.children].flat().join("")) + .join(" "); +} + +const dark = { colorScheme: "dark" }; + +function row(threadId: string, threadTitle: string, phase: AgentActivityPhase, status: string) { + return { + environmentId: "moonbase-terminal", + threadId, + projectTitle: "T3 Code", + threadTitle, + modelTitle: "GPT-6.1", + phase, + status, + updatedAt: "2026-10-31T17:41:00.000Z", + deepLink: `/threads/moonbase-terminal/${threadId}`, + } satisfies AgentActivityRowProps; +} + +function activity( + activeCount: number, + activities: ReadonlyArray, +): AgentActivityProps { + return { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount, + updatedAt: "2026-10-31T17:42:00.000Z", + activities, + }; +} + +const SECTIONS = ["banner", "bannerSmall", "compactLeading", "compactTrailing", "minimal"]; + +describe("AgentActivity", () => { + it.each([ + ["approval", "waiting_for_approval", "Approval", "Fix login redirect needs approval"], + ["input", "waiting_for_input", "Input", "Fix login redirect needs your input"], + ] as const)( + "leads every presentation with the thread that needs %s when another finishes", + (_name, phase, status, spoken) => { + const sections = layout( + activity(1, [ + row("update-snapshots", "Update snapshots", "completed", "Done"), + row("fix-login-redirect", "Fix login redirect", phase, status), + ]), + dark, + ); + + for (const section of [...SECTIONS, "expandedLeading", "expandedBottom"]) { + expect(modifierArgs(sections[section], "widgetURL"), section).toEqual([ + "t3code://threads/moonbase-terminal/fix-login-redirect", + ]); + } + expect(modifierArgs(sections.minimal, "accessibilityLabel")).toEqual([ + `${spoken}. 1 needs you, 1 done`, + ]); + expect(text(sections.compactTrailing)).toContain(status); + expect(modifierArgs(sections.banner, "accessibilityLabel")[0]).toBe( + `T3 Code. ${spoken}, T3 Code. Update snapshots is done, T3 Code`, + ); + }, + ); + + it("gives each of the seven phases its own resting mark", () => { + const phases: ReadonlyArray = [ + "starting", + "running", + "waiting_for_approval", + "waiting_for_input", + "completed", + "failed", + "stale", + ]; + const marks = phases.map((phase) => { + const minimal = layout(activity(1, [row("t", "Thread", phase, "Status")]), { + ...dark, + widgetRenderingMode: "vibrant", + }).minimal; + const symbol = [...walk(minimal)].find((element) => element.props.systemName); + return symbol?.props.systemName ?? text(minimal); + }); + expect(new Set(marks).size).toBe(phases.length); + }); + + it("stops claiming agents work once the content is stale", () => { + const sections = layout( + activity(2, [ + row("fix-login-redirect", "Fix login redirect", "running", "Working"), + row("update-snapshots", "Update snapshots", "waiting_for_input", "Input"), + ]), + { ...dark, isStale: true }, + ); + + expect(text(sections.banner)).toContain("Out of date"); + expect(text(sections.banner)).not.toMatch(/working|needs you/i); + expect(modifierArgs(sections.minimal, "accessibilityLabel")[0]).toContain( + "status is out of date", + ); + }); + + it("does not count threads the relay reports as stale as working", () => { + const sections = layout( + activity(2, [ + row("fix-login-redirect", "Fix login redirect", "stale", "Waiting"), + row("update-snapshots", "Update snapshots", "stale", "Waiting"), + ]), + dark, + ); + + expect(text(sections.banner)).toContain("Out of date"); + expect(text(sections.banner)).not.toMatch(/working|waiting/i); + }); + + it("counts working agents only from the rows the relay sent", () => { + const sections = layout( + activity(7, [ + row("a", "A", "waiting_for_approval", "Approval"), + row("b", "B", "waiting_for_input", "Input"), + row("c", "C", "running", "Working"), + row("d", "D", "running", "Working"), + row("e", "E", "running", "Working"), + ]), + dark, + ); + + expect(text(sections.banner)).toContain("2 need you · 3 working"); + expect(text(sections.banner)).toContain("+3 more"); + expect(modifierArgs(sections.banner, "accessibilityLabel")[0]).toMatch(/\. 2 more$/); + }); + + it("does not call threads the relay left out working when every row needs the user", () => { + const sections = layout( + activity( + 6, + ["a", "b", "c", "d", "e"].map((id) => + row(id, id.toUpperCase(), "waiting_for_approval", "Approval"), + ), + ), + dark, + ); + + expect(text(sections.banner)).toContain("5 need you"); + expect(text(sections.banner)).not.toMatch(/working/i); + expect(modifierArgs(sections.minimal, "accessibilityLabel")).toEqual([ + "A needs approval. 5 need you", + ]); + // Six threads: the banner renders four rows and the expanded island three. + expect(text(sections.banner)).toContain("+2 more"); + expect(text(sections.expandedBottom)).toContain("+3 more"); + expect(modifierArgs(sections.banner, "accessibilityLabel")[0]).toMatch(/\. 1 more$/); + }); + + it("counts hidden threads from the same total as the header in every presentation", () => { + const sections = layout( + activity(8, [ + row("a", "A", "waiting_for_approval", "Approval"), + row("b", "B", "running", "Working"), + row("c", "C", "running", "Working"), + row("d", "D", "running", "Working"), + row("e", "E", "running", "Working"), + ]), + dark, + ); + + expect(text(sections.banner)).toContain("1 needs you · 4 working"); + expect(text(sections.banner)).toContain("+4 more"); + expect(text(sections.expandedBottom)).toContain("+5 more"); + }); + + it("counts only rendered rows as shown when fewer rows arrive than fit", () => { + const sections = layout( + activity(6, [ + row("a", "A", "waiting_for_approval", "Approval"), + row("b", "B", "running", "Working"), + ]), + dark, + ); + + expect(text(sections.banner)).toContain("+4 more"); + expect(text(sections.expandedBottom)).toContain("+4 more"); + }); + + it("renders a phase from a newer relay without claiming work or a need", () => { + const sections = layout( + activity(1, [ + row("a", "Fix login redirect", "cancelled" as AgentActivityPhase, "Cancelled"), + row("b", "Update snapshots", "running", "Working"), + ]), + dark, + ); + + expect(text(sections.banner)).toContain("Cancelled"); + expect(modifierArgs(sections.minimal, "accessibilityLabel")[0]).toMatch( + /^Update snapshots is working\./, + ); + expect(modifierArgs(sections.banner, "accessibilityLabel")[0]).toContain( + "Fix login redirect status is out of date", + ); + }); + + it("names an untitled thread instead of speaking a bare status", () => { + const sections = layout(activity(1, [row("a", " ", "running", "Working")]), dark); + + expect(modifierArgs(sections.minimal, "accessibilityLabel")).toEqual(["A thread is working"]); + }); +}); diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 609beceac0cdf..5b571f931e81e 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -1,17 +1,20 @@ -import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; +import { HStack, Image, Spacer, Text, VStack } from "@expo/ui/swift-ui"; import type { ComponentProps } from "react"; import { + Animation, accessibilityElement, accessibilityLabel, activityBackgroundTint, + animation, + contentTransition, font, foregroundStyle, frame, layoutPriority, lineLimit, - minimumScaleFactor, padding, resizable, + truncationMode, widgetURL, } from "@expo/ui/swift-ui/modifiers"; import { @@ -58,161 +61,268 @@ function AgentActivity( ): LiveActivityLayout { "widget"; - // Hierarchical foregrounds inherit system tint and vibrancy. type Foreground = Parameters[0]; + type SFName = NonNullable["systemName"]>; + // Hierarchical foregrounds inherit system tint and vibrancy. const primaryForeground = { type: "hierarchical", style: "primary" } as const; const secondaryForeground = { type: "hierarchical", style: "secondary" } as const; - const monochrome = - environment.widgetRenderingMode === "accented" || environment.widgetRenderingMode === "vibrant"; - + const tertiaryForeground = { type: "hierarchical", style: "tertiary" } as const; + // Monochrome and Always-On drop the phase colors; each mark's shape still names the state. + const colorless = + environment.isLuminanceReduced === true || + environment.widgetRenderingMode === "accented" || + environment.widgetRenderingMode === "vibrant"; // iPhone uses dark Live Activity material, macOS light; choose the web palette variant by color scheme. const isLightScheme = environment.colorScheme === "light"; - const phaseTint = (phase: AgentActivityPhase | undefined): Foreground => { - if (environment.isLuminanceReduced) { - return secondaryForeground; - } - if (monochrome) { - return primaryForeground; - } - switch (phase) { - case "waiting_for_approval": - return isLightScheme ? "#d97706" : "#fcd34d"; - case "waiting_for_input": - return isLightScheme ? "#4f46e5" : "#a5b4fc"; - case "failed": - return isLightScheme ? "#dc2626" : "#fca5a5"; - case "completed": - return isLightScheme ? "#059669" : "#6ee7b7"; - case "stale": - return secondaryForeground; - case "starting": - case "running": - default: - return isLightScheme ? "#0284c7" : "#7dd3fc"; + + // A terminal prompt that changes its cursor: ›_ works, ›? asks, a shield asks permission. + const SIGNALS: Record< + AgentActivityPhase, + { + readonly mark: { readonly text: string } | { readonly symbol: SFName }; + readonly light: string | null; + readonly dark: string | null; + readonly spoken: string; + readonly need: string | null; + readonly rank: number; + readonly index: number; } + > = { + waiting_for_approval: { + mark: { symbol: "exclamationmark.shield.fill" }, + light: "#d97706", + dark: "#fcd34d", + spoken: "needs approval", + need: "Needs approval", + rank: 0, + index: 3, + }, + waiting_for_input: { + mark: { text: "›?" }, + light: "#4f46e5", + dark: "#a5b4fc", + spoken: "needs your input", + need: "Needs your input", + rank: 0, + index: 4, + }, + failed: { + mark: { symbol: "xmark.octagon.fill" }, + light: "#dc2626", + dark: "#fca5a5", + spoken: "failed", + need: null, + rank: 1, + index: 6, + }, + starting: { + mark: { text: "›" }, + light: "#0284c7", + dark: "#7dd3fc", + spoken: "is starting", + need: null, + rank: 2, + index: 1, + }, + running: { + mark: { text: "›_" }, + light: "#0284c7", + dark: "#7dd3fc", + spoken: "is working", + need: null, + rank: 2, + index: 2, + }, + completed: { + mark: { symbol: "checkmark" }, + light: "#059669", + dark: "#6ee7b7", + spoken: "is done", + need: null, + rank: 3, + index: 5, + }, + stale: { + mark: { symbol: "clock.arrow.circlepath" }, + light: null, + dark: null, + spoken: "status is out of date", + need: null, + rank: 3, + index: 7, + }, }; + // A phase from a newer relay reads as stale: it claims neither work nor a need, + // and its row keeps the relay's own status text. + const signalFor = (phase: AgentActivityPhase) => SIGNALS[phase] ?? SIGNALS.stale; - const phasePriority = (phase: AgentActivityPhase): number => { - if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; - if (phase === "failed") return 1; - if (phase === "running" || phase === "starting") return 2; - return 3; + const tintFor = (phase: AgentActivityPhase): Foreground => { + const signal = signalFor(phase); + const color = isLightScheme ? signal.light : signal.dark; + if (color === null) return secondaryForeground; + return colorless ? primaryForeground : color; }; - // Mark in-flight rows stale after expiry so they cannot claim agents still work. - const activities: ReadonlyArray = environment.isStale - ? props.activities.map((row) => - row.phase === "completed" || row.phase === "failed" - ? row - : { ...row, phase: "stale", status: "Out of date" }, - ) - : props.activities; - const ordered = [...activities].sort((a, b) => phasePriority(a.phase) - phasePriority(b.phase)); - const row0 = ordered[0]; - const row1 = ordered[1]; - const row2 = ordered[2]; - const row3 = ordered[3]; - const row4 = ordered[4]; - const attentionRows = activities.filter( - (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + // Mark in-flight rows stale after expiry so they cannot claim agents still work. + const activities: ReadonlyArray = props.activities.map((row) => + row.phase === "stale" || + (environment.isStale && row.phase !== "completed" && row.phase !== "failed") + ? { ...row, phase: "stale", status: "Out of date" } + : row, ); - const attentionRow = attentionRows[0]; - const failedRow = activities.find((row) => row.phase === "failed"); - const heroRow = attentionRow ?? failedRow ?? row0; - const tint = phaseTint(heroRow?.phase); - const headerTint = attentionRow - ? phaseTint(attentionRow.phase) - : failedRow - ? phaseTint(failedRow.phase) - : tint; + // Stable sort: the thread that needs the user leads, then failures, work, and outcomes. + const ordered = [...activities].sort((a, b) => signalFor(a.phase).rank - signalFor(b.phase).rank); + const hero = ordered[0]; + const others = ordered.slice(1); + const other0 = others[0]; + const other1 = others[1]; + const other2 = others[2]; - const allDone = props.activeCount === 0; - const doneLabel = failedRow ? "Failed" : "Done"; - const outcomeLabel = failedRow ? "Agent work failed" : "Agent work completed"; + const needsYouCount = activities.filter((row) => signalFor(row.phase).rank === 0).length; + // Count only rows the relay sent: it caps rows after putting needs-you first, so a thread + // it left out may be waiting on the user. Those threads show only as "+N more". + const workingCount = activities.filter((row) => signalFor(row.phase).rank === 2).length; + const staleCount = activities.filter((row) => row.phase === "stale").length; + const failedCount = activities.filter((row) => row.phase === "failed").length; + const doneCount = activities.filter((row) => row.phase === "completed").length; + const summaryParts = [ + needsYouCount > 0 ? `${needsYouCount} need${needsYouCount === 1 ? "s" : ""} you` : null, + workingCount > 0 ? `${workingCount} working` : null, + staleCount > 0 ? "Out of date" : null, + failedCount > 0 ? `${failedCount} failed` : null, + doneCount > 0 ? `${doneCount} done` : null, + ].filter((part): part is string => part !== null); + const summary = summaryParts.join(" · "); + // Count threads, not rows: the relay sends only the rows that fit. + const threadCount = Math.max(props.activeCount + failedCount + doneCount, ordered.length); + const unsentCount = threadCount - ordered.length; - const agentWord = props.activeCount === 1 ? "agent" : "agents"; - const agentsLabel = allDone - ? outcomeLabel - : environment.isStale - ? "Agent status out of date" - : `${props.activeCount} active ${agentWord}`; - const attentionSuffix = - attentionRows.length > 0 - ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention` - : ""; - const activeLabel = allDone - ? doneLabel - : environment.isStale - ? "Out of date" - : `${props.activeCount} active`; - const showCompactGlyph = !attentionRow && !failedRow && !allDone && !environment.isStale; - const summary = attentionSuffix || activeLabel; + const describe = (row: AgentActivityRowProps) => + `${row.threadTitle.trim() || "A thread"} ${signalFor(row.phase).spoken}`; + const glanceLabel = hero + ? ordered.length > 1 + ? `${describe(hero)}. ${summaryParts.join(", ")}` + : describe(hero) + : "T3 Code"; + const cardLabel = [ + "T3 Code", + ...ordered.map((row) => `${describe(row)}, ${row.projectTitle}`), + ...(unsentCount > 0 ? [`${unsentCount} more`] : []), + ].join(". "); // Widget taps target the containing app, so its production scheme is safe in every build. - const deepLinkRow = attentionRow ?? row0; const deepLink = - deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") - ? `t3code://${deepLinkRow.deepLink.slice(1)}` + hero && hero.deepLink.startsWith("/") && !hero.deepLink.startsWith("//") + ? `t3code://${hero.deepLink.slice(1)}` : null; const linkModifiers = deepLink ? [widgetURL(deepLink)] : []; - type SFName = NonNullable["systemName"]>; - const phaseSymbol = (phase: AgentActivityPhase): SFName => { - switch (phase) { - case "waiting_for_approval": - return "exclamationmark.circle.fill"; - case "waiting_for_input": - return "questionmark.circle.fill"; - case "failed": - return "xmark.octagon.fill"; - case "completed": - return "checkmark.circle.fill"; - case "starting": - return "circle.dotted"; - case "stale": - return "clock.arrow.circlepath"; - case "running": - default: - // Matches the thread list's Working indicator. - return "circle.dashed"; - } + // SF Symbols and the logo ignore frame/foregroundStyle applied directly to the + // image; size + tint them through a container the resizable image fills. + const renderMark = (row: AgentActivityRowProps, size: number, animated: boolean) => { + const mark = signalFor(row.phase).mark; + const tint = tintFor(row.phase); + // One quick in-place swap when the phase changes; nothing moves or repeats. + const motion = animated + ? [animation(Animation.easeOut({ duration: 0.3 }), signalFor(row.phase).index)] + : []; + return ( + + {"text" in mark ? ( + + {mark.text} + + ) : ( + + + + )} + + ); }; - // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to - // the image; size + tint them through a container the resizable symbol fills. - const renderGlyph = ( - systemName: SFName, - size: number, - color: Foreground, - modifiers: typeof linkModifiers = [], - ) => ( + const renderLogo = (height: number) => ( - + ); - const renderCompactRow = (row: AgentActivityRowProps) => ( - + const renderHero = (row: AgentActivityRowProps, markSize: number, titleSize: number) => ( + + {renderMark(row, markSize, true)} + + + {row.threadTitle} + + + + {signalFor(row.phase).need ?? row.status} + + + {`· ${row.projectTitle}`} + + + + + + + ); + + const renderOther = (row: AgentActivityRowProps) => ( + + {renderMark(row, 12, false)} {row.threadTitle} - {/* Bare texts share shrinkage proportionally; project frames or layoutPriority would prevent balanced truncation. */} - - {row.projectTitle} - @@ -221,147 +331,115 @@ function AgentActivity( ); - // Image views only honor `resizable` directly (frame/foregroundStyle are - // dropped), so we size it via a container frame the resizable image fills and - // tint it through the container's foreground style, which the template image - // inherits. - const renderLogo = (height: number, color: Foreground, modifiers: typeof linkModifiers = []) => ( - - - - ); + const hiddenCount = (capacity: number) => + Math.max(threadCount - 1 - Math.min(capacity, others.length), 0); + const renderMore = (capacity: number) => + hiddenCount(capacity) > 0 ? ( + + {`+${hiddenCount(capacity)} more`} + + ) : null; + + const renderSummary = (size: number) => + summary ? ( + + {summary} + + ) : null; + + // While agents only work, the compact island stays a quiet prompt; words appear + // only when a thread needs the user or failed. + const heroSpeaks = hero ? signalFor(hero.phase).rank <= 1 : false; return { banner: ( - - - {renderLogo(13, primaryForeground)} - - - - - - {agentsLabel} - - {attentionSuffix ? ( - · - ) : null} - {attentionSuffix ? ( - - {attentionSuffix} - - ) : null} - - - - {row0 ? renderCompactRow(row0) : null} - {row1 ? renderCompactRow(row1) : null} - {row2 ? renderCompactRow(row2) : null} - {row3 ? renderCompactRow(row3) : null} - {row4 ? renderCompactRow(row4) : null} + + {renderLogo(12)} + {renderSummary(12)} + + + {hero ? renderHero(hero, 24, 15) : null} + {other0 || other1 || other2 ? ( + + {other0 ? renderOther(other0) : null} + {other1 ? renderOther(other1) : null} + {other2 ? renderOther(other2) : null} + {renderMore(3)} + + ) : null} ), bannerSmall: ( - - - {renderLogo(14, primaryForeground)} + + {hero ? renderHero(hero, 18, 13) : renderLogo(14)} + {renderSummary(11)} + + ), + compactLeading: ( + {renderLogo(14)} + ), + compactTrailing: hero ? ( + + {renderMark(hero, 14, true)} + {heroSpeaks ? ( - {attentionRows.length > 0 ? summary : activeLabel} + {hero.status} - - - {row0 ? ( - - - {row0.threadTitle} - - - - {row0.status} - - ) : null} - - ), - compactLeading: renderLogo(14, tint, linkModifiers), - // While agents are only working, stay quiet: the thread list's Working - // glyph marks the activity without a count. Labels appear only for - // attention or outcomes. - compactTrailing: showCompactGlyph ? ( + + ) : null, + minimal: hero ? ( - {renderGlyph(phaseSymbol(heroRow?.phase ?? "running"), 14, tint)} + {renderMark(hero, 14, true)} ) : ( - - {attentionRow - ? attentionRow.phase === "waiting_for_approval" - ? "Approval" - : "Input" - : failedRow - ? "Failed" - : activeLabel} - + {renderLogo(11)} ), - minimal: heroRow - ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase), linkModifiers) - : renderLogo(11, tint, linkModifiers), expandedLeading: ( - - {renderLogo(15, tint)} - - {allDone ? doneLabel : `${props.activeCount}`} - + + {renderLogo(15)} ), expandedCenter: null, @@ -369,16 +447,22 @@ function AgentActivity( expandedBottom: ( - {row0 ? renderCompactRow(row0) : null} - {row1 ? renderCompactRow(row1) : null} - {row2 ? renderCompactRow(row2) : null} + {hero ? renderHero(hero, 24, 15) : null} + {other0 || other1 ? ( + + {other0 ? renderOther(other0) : null} + {other1 ? renderOther(other1) : null} + {renderMore(2)} + + ) : null} ), }; diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index e7e2c76b6a2f2..42d12980480e3 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -246,6 +246,27 @@ export const SHOWCASE_THREADS = [ response: "Kills now report the winning heuristic and the runner-up alongside the usual dump, assembled entirely from data the path already had.", }, + // Staged by the Live Activity handoff fixtures; settled, so they stay in the collapsed Settled group. + { + id: "fix-login-redirect", + projectId: "t3code", + title: "Fix login redirect", + branch: "fix/login-redirect", + minutesAgo: 3 * 24 * 60, + settled: true, + request: "After signing in from a deep link, land on the page the link pointed to.", + response: "Sign-in now returns to the original deep link instead of the home screen.", + }, + { + id: "update-snapshots", + projectId: "t3code", + title: "Update snapshots", + branch: "test/update-snapshots", + minutesAgo: 3 * 24 * 60 + 5, + settled: true, + request: "Refresh the visual snapshots after the header change.", + response: "Snapshots now match the new header; no other screens changed.", + }, ] as const; function minutesBefore(now: number, minutes: number): string { From a39f157576ff81b865a532e19a1cb8402bc0b9bf Mon Sep 17 00:00:00 2001 From: Neil Date: Sun, 1 Nov 2026 03:36:23 +0800 Subject: [PATCH 2/2] feat(mobile): see agent status from home screen and Lock Screen widgets The "Agent Activity" gallery entry rendered a blank card: it shares the Live Activity's name, but nothing ever stored a widget layout for it. It now renders an Agent status widget in small, medium, large, and the three Lock Screen families, with the Live Activity's marks, colors, and "needs you first" order. The app publishes a snapshot from live environments whenever thread status changes and when it enters or leaves the foreground. A second timeline entry switches the widget to an out-of-date presentation five minutes later, because widgets get no relay pushes. --- apps/mobile/app.config.ts | 16 +- apps/mobile/src/App.tsx | 2 + .../showcase/ShowcaseCaptureCoordinator.tsx | 21 +- .../features/showcase/nativeShowcaseScene.ts | 4 +- .../showcase/showcaseAgentActivity.ts | 8 + .../showcase/showcaseCaptureModel.test.ts | 10 +- .../features/showcase/showcaseCaptureModel.ts | 11 +- apps/mobile/src/widgets/AgentStatus.test.ts | 353 +++++++++++++ apps/mobile/src/widgets/AgentStatus.tsx | 479 ++++++++++++++++++ .../src/widgets/AgentStatusCoordinator.tsx | 77 +++ .../src/widgets/agentStatusSnapshot.test.ts | 181 +++++++ .../mobile/src/widgets/agentStatusSnapshot.ts | 144 ++++++ .../widgets/publishAgentStatus.ios.test.ts | 38 ++ .../src/widgets/publishAgentStatus.ios.ts | 21 + apps/mobile/src/widgets/publishAgentStatus.ts | 4 + 15 files changed, 1353 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/src/widgets/AgentStatus.test.ts create mode 100644 apps/mobile/src/widgets/AgentStatus.tsx create mode 100644 apps/mobile/src/widgets/AgentStatusCoordinator.tsx create mode 100644 apps/mobile/src/widgets/agentStatusSnapshot.test.ts create mode 100644 apps/mobile/src/widgets/agentStatusSnapshot.ts create mode 100644 apps/mobile/src/widgets/publishAgentStatus.ios.test.ts create mode 100644 apps/mobile/src/widgets/publishAgentStatus.ios.ts create mode 100644 apps/mobile/src/widgets/publishAgentStatus.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e6d388b9a9856..b2f8f55e6c007 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -186,12 +186,22 @@ const widgetsPlugin: NonNullable[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", + ], + }, }, ], }, diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 225496bb7a407..43a7cbc066903 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -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"; @@ -226,6 +227,7 @@ function AppContent() { <> + diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index e3631e606f0b3..34c06b362f4e6 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -37,6 +37,7 @@ import { } from "./nativeShowcaseScene"; import { buildShowcaseAgentActivity, + showcaseAgentStatusRowsAtom, type ShowcaseAgentActivityFixture, } from "./showcaseAgentActivity"; import { @@ -104,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")); @@ -193,6 +195,8 @@ 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) { @@ -259,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(); @@ -338,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); diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 0e37361a472cc..705b01df2c051 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -13,6 +13,7 @@ const SHOWCASE_SCENES = [ "review", "environments", "agent-activity", + "agent-status", ] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; @@ -59,7 +60,8 @@ export function getNativeShowcasePairingUrls(): ReadonlyArray { } } -// `agent-activity:handoff-approval` requests the agent-activity scene with a named Live Activity fixture. +// `agent-activity:handoff-approval` requests the agent-activity scene with a named fixture; +// `agent-status:` stages the same fixture on the Agent status widget. function nativeShowcaseSceneRequest(): readonly [string | undefined, string | undefined] { try { const [scene, fixture] = diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts index ada9072d19870..29727aa276ea2 100644 --- a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts @@ -2,12 +2,19 @@ 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"; + +/** The agent-status scene's fixture rows; the widget coordinator publishes them in place of live rows. */ +export const showcaseAgentStatusRowsAtom = Atom.make | null>( + null, +).pipe(Atom.keepAlive); interface ShowcaseAgentActivityRow { readonly threadId: string; @@ -44,6 +51,7 @@ const SHOWCASE_AGENT_ACTIVITY_FIXTURES = { "phase-completed": [fixLoginRedirect("completed")], "phase-failed": [fixLoginRedirect("failed")], "phase-stale": [fixLoginRedirect("stale"), updateSnapshots("stale")], + empty: [], } as const satisfies Record>; export type ShowcaseAgentActivityFixture = keyof typeof SHOWCASE_AGENT_ACTIVITY_FIXTURES; diff --git a/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts b/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts index 70717f8a4d35c..a72505c3d0495 100644 --- a/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts +++ b/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts @@ -23,12 +23,14 @@ 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("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"); }); @@ -65,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(); }); diff --git a/apps/mobile/src/features/showcase/showcaseCaptureModel.ts b/apps/mobile/src/features/showcase/showcaseCaptureModel.ts index 5a87e40662568..b71fbcd4e0da8 100644 --- a/apps/mobile/src/features/showcase/showcaseCaptureModel.ts +++ b/apps/mobile/src/features/showcase/showcaseCaptureModel.ts @@ -17,17 +17,18 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null { } /** - * 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. + * 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" && + return (requestedScene === "agent-activity" || requestedScene === "agent-status") && (routeScene === "threads" || routeScene === "thread") - ? "agent-activity" + ? requestedScene : routeScene; } @@ -92,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, diff --git a/apps/mobile/src/widgets/AgentStatus.test.ts b/apps/mobile/src/widgets/AgentStatus.test.ts new file mode 100644 index 0000000000000..3c2c51b073c6b --- /dev/null +++ b/apps/mobile/src/widgets/AgentStatus.test.ts @@ -0,0 +1,353 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { AgentActivityPhase } from "./AgentActivity"; +import type { AgentStatusSnapshot, AgentStatusThread } from "./agentStatusSnapshot"; + +vi.mock("expo-widgets", () => ({ + createWidget: (_name: string, layout: unknown) => layout, + createLiveActivity: (_name: string, layout: unknown) => layout, +})); +vi.mock("@expo/ui/swift-ui", () => ({ + AccessoryWidgetBackground: "AccessoryWidgetBackground", + HStack: "HStack", + Image: "Image", + Link: "Link", + Spacer: "Spacer", + Text: "Text", + VStack: "VStack", + ZStack: "ZStack", +})); +vi.mock("@expo/ui/swift-ui/modifiers", () => { + const modifier = + (name: string) => + (...args: ReadonlyArray) => ({ name, args }); + return { + Animation: { easeOut: (params: unknown) => ({ animation: "easeOut", params }) }, + shapes: { roundedRectangle: (params: unknown) => ({ shape: "roundedRectangle", params }) }, + ...Object.fromEntries( + [ + "accessibilityElement", + "accessibilityLabel", + "activityBackgroundTint", + "animation", + "background", + "containerBackground", + "contentTransition", + "environment", + "font", + "foregroundStyle", + "frame", + "layoutPriority", + "lineLimit", + "padding", + "resizable", + "truncationMode", + "widgetURL", + ].map((name) => [name, modifier(name)]), + ), + }; +}); + +const { default: AgentStatus } = await import("./AgentStatus"); +const { default: AgentActivity } = await import("./AgentActivity"); +const layout = AgentStatus as unknown as ( + props: Partial | undefined, + environment: Record, +) => unknown; + +interface Node { + readonly type: unknown; + readonly props: { + readonly children?: unknown; + readonly modifiers?: ReadonlyArray<{ name: string; args: ReadonlyArray }>; + readonly systemName?: string; + readonly destination?: string; + }; +} + +function* walk(node: unknown): Generator { + if (Array.isArray(node)) { + for (const child of node) yield* walk(child); + return; + } + if (!node || typeof node !== "object" || !("props" in node)) return; + yield node as Node; + yield* walk((node as Node).props.children); +} + +function modifierArgs(node: unknown, name: string): ReadonlyArray { + return [...walk(node)].flatMap((element) => + (element.props.modifiers ?? []).filter((m) => m.name === name).map((m) => m.args[0]), + ); +} + +function text(node: unknown): string { + return [...walk(node)] + .filter((element) => element.type === "Text") + .map((element) => [element.props.children].flat().join("")) + .join(" "); +} + +function firstMark(node: unknown): string { + const element = [...walk(node)].find( + (candidate) => candidate.props.systemName || candidate.type === "Text", + ); + return element?.props.systemName ?? text(element); +} + +const CHECKED_AT = Date.parse("2026-10-31T18:41:00.000Z"); +const FRESH = new Date(CHECKED_AT + 60_000); +const EXPIRED = new Date(CHECKED_AT + 5 * 60_000); +// A time of day in any timezone, not a date: the snapshot is minutes old. +const AS_OF = String.raw`As of \d{1,2}:\d{2}`; + +const GLANCE_FAMILIES = [ + "systemSmall", + "accessoryCircular", + "accessoryRectangular", + "accessoryInline", +] as const; +const FAMILIES = [ + "systemSmall", + "systemMedium", + "systemLarge", + "accessoryCircular", + "accessoryRectangular", + "accessoryInline", +] as const; + +function env(widgetFamily: (typeof FAMILIES)[number], date = FRESH) { + return { widgetFamily, date, colorScheme: "dark", widgetRenderingMode: "fullColor" }; +} + +function thread(title: string, phase: AgentActivityPhase): AgentStatusThread { + const slug = title.toLowerCase().replaceAll(" ", "-"); + return { title, project: "T3 Code", phase, url: `t3code-dev://threads/moonbase/${slug}` }; +} + +function snapshot( + threads: ReadonlyArray, + counts: AgentStatusSnapshot["counts"], +): AgentStatusSnapshot { + return { + url: "t3code-dev://", + checkedAt: CHECKED_AT, + expiresAt: CHECKED_AT + 5 * 60_000, + counts, + threads, + }; +} + +const handoff = (phase: AgentActivityPhase) => + snapshot([thread("Fix login redirect", phase), thread("Update snapshots", "completed")], { + needsYou: 1, + working: 0, + failed: 0, + done: 1, + }); + +describe("AgentStatus widget", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(CHECKED_AT); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + ["approval", "waiting_for_approval", "Needs approval", "Fix login redirect needs approval"], + ["input", "waiting_for_input", "Needs your input", "Fix login redirect needs your input"], + ] as const)( + "leads every family with the thread that needs %s and opens it", + (_name, phase, need, spoken) => { + const fixLogin = "t3code-dev://threads/moonbase/fix-login-redirect"; + for (const family of ["systemSmall", "accessoryCircular", "accessoryRectangular"] as const) { + const view = layout(handoff(phase), env(family)); + expect(modifierArgs(view, "widgetURL"), family).toEqual([fixLogin]); + const labels = modifierArgs(view, "accessibilityLabel"); + expect(labels, family).toHaveLength(1); + expect(labels[0], family).toMatch( + new RegExp(`^${spoken}\\. 1 needs you, 1 done\\. ${AS_OF}`), + ); + } + expect(text(layout(handoff(phase), env("systemSmall")))).toContain(need); + expect(text(layout(handoff(phase), env("accessoryInline")))).toContain( + "1 needs you · 1 done", + ); + + const stack = layout(handoff(phase), env("systemMedium")); + const rows = [...walk(stack)].filter((element) => element.type === "Link"); + expect(rows.map((element) => element.props.destination)).toEqual([ + fixLogin, + "t3code-dev://threads/moonbase/update-snapshots", + ]); + // The thread that needs the user is the raised tab, not only a colored row. + expect(modifierArgs(rows[0], "background")).toHaveLength(1); + expect(modifierArgs(rows[1], "background")).toHaveLength(0); + expect(modifierArgs(rows[0], "accessibilityLabel")).toEqual([`${spoken}, T3 Code`]); + expect(modifierArgs(rows[1], "accessibilityLabel")).toEqual([ + "Update snapshots is done, T3 Code", + ]); + }, + ); + + it("gives VoiceOver a label in every family and state", () => { + const states = { + handoff: handoff("waiting_for_approval"), + empty: snapshot([], { needsYou: 0, working: 0, failed: 0, done: 0 }), + unpublished: undefined, + }; + for (const family of FAMILIES) { + for (const [state, props] of Object.entries(states)) { + const labels = modifierArgs(layout(props, env(family)), "accessibilityLabel"); + expect(labels.length, `${family} ${state}`).toBeGreaterThan(0); + expect(labels.every((label) => typeof label === "string" && label.length > 0)).toBe(true); + } + } + }); + + it("shows the empty state instead of a blank card", () => { + const empty = snapshot([], { needsYou: 0, working: 0, failed: 0, done: 0 }); + for (const family of FAMILIES) { + const view = layout(empty, env(family)); + expect(text(view), family).toContain("›"); + expect(modifierArgs(view, "accessibilityLabel").join(" "), family).toContain( + "No agents running", + ); + } + expect(modifierArgs(layout(empty, env("systemSmall")), "widgetURL")).toEqual(["t3code-dev://"]); + expect(text(layout(undefined, env("systemSmall")))).toContain( + "Open T3 Code to see your agents", + ); + }); + + it("stops claiming work or requests once the snapshot is old", () => { + const props = snapshot( + [ + thread("Fix login redirect", "waiting_for_approval"), + thread("Polish copy", "running"), + thread("Update snapshots", "completed"), + ], + { needsYou: 1, working: 1, failed: 0, done: 1 }, + ); + for (const family of FAMILIES) { + const view = layout(props, env(family, EXPIRED)); + const words = `${text(view)} ${modifierArgs(view, "accessibilityLabel").join(" ")}`; + expect(words, family).not.toMatch(/working|needs you|needs approval/i); + expect(words, family).toMatch(/out of date/i); + const symbols = [...walk(view)].map((element) => element.props.systemName); + expect(symbols, family).toContain("clock.arrow.circlepath"); + expect(symbols, family).not.toContain("exclamationmark.shield.fill"); + } + const stack = text(layout(props, env("systemLarge", EXPIRED))); + expect(stack).toContain("Done"); + expect(stack).toMatch(new RegExp(AS_OF)); + }); + + it("takes counts from the data and says how many rows did not fit", () => { + const threads = [ + thread("Fix login redirect", "waiting_for_input"), + ...Array.from({ length: 7 }, (_, index) => thread(`Working ${index}`, "running")), + ]; + const props = snapshot(threads, { needsYou: 1, working: 10, failed: 0, done: 0 }); + + const medium = layout(props, env("systemMedium")); + expect(text(medium)).toContain("1 needs you · 10 working"); + expect([...walk(medium)].filter((element) => element.type === "Link")).toHaveLength(2); + expect(text(medium)).toContain("+9 more"); + + const large = layout(props, env("systemLarge")); + expect([...walk(large)].filter((element) => element.type === "Link")).toHaveLength(8); + expect(text(large)).toContain("+3 more"); + + expect(text(layout(props, env("accessoryCircular")))).toBe("›? 1"); + }); + + it("uses the Live Activity's mark for each of the seven phases", () => { + const phases: ReadonlyArray = [ + "starting", + "running", + "waiting_for_approval", + "waiting_for_input", + "completed", + "failed", + "stale", + ]; + const liveActivity = AgentActivity as unknown as ( + props: unknown, + environment: Record, + ) => Record; + for (const phase of phases) { + const widgetMark = firstMark( + layout( + snapshot([thread("Thread", phase)], { needsYou: 0, working: 0, failed: 0, done: 0 }), + env("systemSmall"), + ), + ); + const activityMark = firstMark( + liveActivity( + { + title: "T3 Code", + subtitle: "", + activeCount: 1, + updatedAt: "", + activities: [ + { + environmentId: "moonbase", + threadId: "thread", + projectTitle: "T3 Code", + threadTitle: "Thread", + modelTitle: "", + phase, + status: "Status", + updatedAt: "", + deepLink: "/threads/moonbase/thread", + }, + ], + }, + { colorScheme: "dark" }, + ).minimal, + ); + expect(widgetMark, phase).toBe(activityMark); + } + }); + + it("leads with a failure once the snapshot is old, as the Live Activity does", () => { + const props = snapshot( + [ + thread("Fix login redirect", "waiting_for_approval"), + thread("Flaky test", "failed"), + thread("Update snapshots", "completed"), + ], + { needsYou: 1, working: 0, failed: 1, done: 1 }, + ); + for (const family of GLANCE_FAMILIES) { + const view = layout(props, env(family, EXPIRED)); + expect(modifierArgs(view, "accessibilityLabel")[0], family).toMatch(/^Flaky test failed/); + } + const rows = [...walk(layout(props, env("systemMedium", EXPIRED)))].filter( + (element) => element.type === "Link", + ); + expect(rows[0]?.props.destination).toBe("t3code-dev://threads/moonbase/flaky-test"); + }); + + it("renders a phase this build does not know as out of date in every family", () => { + const props = snapshot([thread("Fix login redirect", "cancelled" as AgentActivityPhase)], { + needsYou: 0, + working: 0, + failed: 0, + done: 2, + }); + for (const family of GLANCE_FAMILIES) { + const view = layout(props, env(family)); + expect(modifierArgs(view, "accessibilityLabel")[0], family).toMatch( + /^Fix login redirect status is out of date/, + ); + } + for (const family of ["systemMedium", "systemLarge"] as const) { + expect(text(layout(props, env(family))), family).toContain("Out of date"); + } + expect(text(layout(props, env("accessoryCircular")))).not.toContain("2"); + }); +}); diff --git a/apps/mobile/src/widgets/AgentStatus.tsx b/apps/mobile/src/widgets/AgentStatus.tsx new file mode 100644 index 0000000000000..54eb43fc9e48b --- /dev/null +++ b/apps/mobile/src/widgets/AgentStatus.tsx @@ -0,0 +1,479 @@ +import { + AccessoryWidgetBackground, + HStack, + Image, + Link, + Spacer, + Text, + VStack, + ZStack, +} from "@expo/ui/swift-ui"; +import type { ComponentProps } from "react"; +import { + accessibilityElement, + accessibilityLabel, + background, + containerBackground, + environment, + font, + foregroundStyle, + frame, + layoutPriority, + lineLimit, + padding, + resizable, + shapes, + truncationMode, + widgetURL, +} from "@expo/ui/swift-ui/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; + +import type { AgentActivityPhase } from "./AgentActivity"; +import type { AgentStatusSnapshot, AgentStatusThread } from "./agentStatusSnapshot"; + +// Widget serialization excludes module helpers; use only imported view/modifier factories. +function AgentStatus( + props: Partial | undefined, + widgetEnvironment: WidgetEnvironment & { showsContainerBackground?: boolean }, +) { + "widget"; + + // Before the app's first publish the widget renders with no props. + const snapshot: Partial = props ?? {}; + type Foreground = Parameters[0]; + type SFName = NonNullable["systemName"]>; + const primaryForeground = { type: "hierarchical", style: "primary" } as const; + const secondaryForeground = { type: "hierarchical", style: "secondary" } as const; + const tertiaryForeground = { type: "hierarchical", style: "tertiary" } as const; + + const family = widgetEnvironment.widgetFamily ?? "systemSmall"; + const accessory = family.startsWith("accessory"); + // Gallery snapshots can render an old timeline entry after it has expired. + const now = Math.max(widgetEnvironment.date?.getTime() ?? 0, Date.now()); + // Tinted, clear, Lock Screen, and Always-On drop the phase colors; each mark's shape still names the state. + const monochrome = + widgetEnvironment.widgetRenderingMode !== "fullColor" || + widgetEnvironment.isLuminanceReduced === true; + const mode = snapshot.theme?.mode; + const appearance = + mode === "light" || mode === "dark" + ? mode + : widgetEnvironment.colorScheme === "dark" + ? "dark" + : "light"; + // StandBy can remove the card even in full color; text must then follow the host. + const palette = + !accessory && !monochrome && widgetEnvironment.showsContainerBackground !== false + ? snapshot.theme?.[appearance] + : undefined; + + // The Live Activity's Terminal cursor marks, colors, and words, so both read as one system. + const SIGNALS: Record< + AgentActivityPhase, + { + readonly mark: { readonly text: string } | { readonly symbol: SFName }; + readonly light: string | null; + readonly dark: string | null; + readonly spoken: string; + readonly label: string; + readonly rank: number; + } + > = { + waiting_for_approval: { + mark: { symbol: "exclamationmark.shield.fill" }, + light: "#d97706", + dark: "#fcd34d", + spoken: "needs approval", + label: "Needs approval", + rank: 0, + }, + waiting_for_input: { + mark: { text: "›?" }, + light: "#4f46e5", + dark: "#a5b4fc", + spoken: "needs your input", + label: "Needs your input", + rank: 0, + }, + failed: { + mark: { symbol: "xmark.octagon.fill" }, + light: "#dc2626", + dark: "#fca5a5", + spoken: "failed", + label: "Failed", + rank: 1, + }, + starting: { + mark: { text: "›" }, + light: "#0284c7", + dark: "#7dd3fc", + spoken: "is starting", + label: "Starting", + rank: 2, + }, + running: { + mark: { text: "›_" }, + light: "#0284c7", + dark: "#7dd3fc", + spoken: "is working", + label: "Working", + rank: 2, + }, + completed: { + mark: { symbol: "checkmark" }, + light: "#059669", + dark: "#6ee7b7", + spoken: "is done", + label: "Done", + rank: 3, + }, + stale: { + mark: { symbol: "clock.arrow.circlepath" }, + light: null, + dark: null, + spoken: "status is out of date", + label: "Out of date", + rank: 3, + }, + }; + + const tintFor = (phase: AgentActivityPhase): Foreground => { + const signal = SIGNALS[phase]; + const color = appearance === "light" ? signal.light : signal.dark; + if (color === null) return secondaryForeground; + return monochrome ? primaryForeground : color; + }; + + const published = snapshot.threads !== undefined && snapshot.checkedAt !== undefined; + const checkedAt = snapshot.checkedAt ?? 0; + const stale = published && now >= (snapshot.expiresAt ?? 0); + // Once stale, in-flight rows cannot claim work or a pending request; outcomes stay true. + // A phase this build does not know claims nothing either. + const threads: ReadonlyArray = (snapshot.threads ?? []) + .map((thread) => + SIGNALS[thread.phase] === undefined || + (stale && thread.phase !== "completed" && thread.phase !== "failed") + ? { ...thread, phase: "stale" as const } + : thread, + ) + // Rank again after the stale mapping so failures lead, as in the Live Activity. + .sort((a, b) => SIGNALS[a.phase].rank - SIGNALS[b.phase].rank); + const hero = threads[0]; + const others = threads.slice(1); + + const counts = snapshot.counts ?? { needsYou: 0, working: 0, failed: 0, done: 0 }; + const total = counts.needsYou + counts.working + counts.failed + counts.done; + const summaryParts = [ + stale ? "Out of date" : null, + !stale && counts.needsYou > 0 + ? `${counts.needsYou} need${counts.needsYou === 1 ? "s" : ""} you` + : null, + !stale && counts.working > 0 ? `${counts.working} working` : null, + counts.failed > 0 ? `${counts.failed} failed` : null, + counts.done > 0 ? `${counts.done} done` : null, + ].filter((part): part is string => part !== null); + const summary = summaryParts.join(" · "); + // A stale leading thread already says "Out of date"; its summary line need not repeat it. + const heroSummary = ( + hero?.phase === "stale" ? summaryParts.filter((part) => part !== "Out of date") : summaryParts + ).join(" · "); + const asOf = published + ? `As of ${new Date(checkedAt).toLocaleString( + undefined, + now - checkedAt < 12 * 60 * 60_000 + ? { hour: "numeric", minute: "2-digit" } + : { month: "short", day: "numeric" }, + )}` + : ""; + const emptyTitle = published ? "No agents running" : "Open T3 Code to see your agents"; + + const describe = (thread: AgentStatusThread) => + `${thread.title.trim() || "A thread"} ${SIGNALS[thread.phase].spoken}`; + const glanceLabel = [ + hero ? describe(hero) : published ? emptyTitle : `T3 Code. ${emptyTitle}`, + summaryParts.length > 0 ? summaryParts.join(", ") : null, + published ? asOf : null, + ] + .filter((part): part is string => part !== null) + .join(". "); + // The mark's own category, so a lone number beside it never needs a label. + const heroCount = hero + ? [counts.needsYou, counts.failed, counts.working, counts.done][SIGNALS[hero.phase].rank] + : undefined; + const heroCountShown = hero && hero.phase !== "stale" && heroCount ? heroCount : null; + + const tapURL = hero?.url ?? snapshot.url; + const rootModifiers = [ + ...(tapURL ? [widgetURL(tapURL)] : []), + ...(palette + ? [containerBackground(palette.background, "widget"), environment("colorScheme", appearance)] + : []), + ]; + + // SF Symbols ignore frame/foregroundStyle applied directly to the image; size and + // tint them through a container the resizable image fills. + const renderMark = (phase: AgentActivityPhase, size: number) => { + const mark = SIGNALS[phase].mark; + return ( + + {"text" in mark ? ( + + {mark.text} + + ) : ( + + + + )} + + ); + }; + const emptyPhase: AgentActivityPhase = stale ? "stale" : "starting"; + + const renderLine = (text: string, size: number, foreground: Foreground, weight?: "semibold") => ( + + {text} + + ); + + if (family === "accessoryInline") { + const mark = SIGNALS[hero?.phase ?? emptyPhase].mark; + const words = summary || (hero ? SIGNALS[hero.phase].label : emptyTitle); + return ( + + {"symbol" in mark ? : null} + {"text" in mark ? `${mark.text} ${words}` : words} + + ); + } + + if (family === "accessoryCircular") { + return ( + + + + {renderMark(hero?.phase ?? emptyPhase, heroCountShown === null ? 22 : 17)} + {heroCountShown === null ? null : ( + + {`${heroCountShown}`} + + )} + + + ); + } + + if (family === "accessoryRectangular") { + return ( + + + {renderMark(hero?.phase ?? emptyPhase, 13)} + {renderLine(hero?.title ?? emptyTitle, 15, primaryForeground, "semibold")} + + {hero ? renderLine(SIGNALS[hero.phase].label, 13, primaryForeground, "semibold") : null} + {renderLine(heroSummary || asOf, 13, secondaryForeground)} + + ); + } + + if (family === "systemSmall") { + return ( + + {renderMark(hero?.phase ?? emptyPhase, 30)} + + + {hero?.title ?? emptyTitle} + + {hero ? renderLine(SIGNALS[hero.phase].label, 13, tintFor(hero.phase), "semibold") : null} + {hero && heroSummary ? renderLine(heroSummary, 12, secondaryForeground) : null} + {published ? renderLine(asOf, 11, tertiaryForeground) : null} + + ); + } + + // Thread stack: each thread is an editor tab; the one that needs the user comes forward. + const slots = family === "systemMedium" ? 2 : 7; + const shown = others.length <= slots ? others : others.slice(0, slots - 1); + // Counts cover every thread, including those the snapshot left out. + const hidden = Math.max(total - 1 - shown.length, others.length - shown.length, 0); + const renderLeadingTab = (thread: AgentStatusThread) => ( + + + {renderMark(thread.phase, 22)} + + {renderLine(thread.title, 15, primaryForeground, "semibold")} + + + {SIGNALS[thread.phase].label} + + {renderLine(`· ${thread.project}`, 12, secondaryForeground)} + + + + + + ); + const renderTab = (thread: AgentStatusThread) => ( + + + {renderMark(thread.phase, 12)} + {renderLine(thread.title, 13, secondaryForeground)} + + + {SIGNALS[thread.phase].label} + + + + ); + + return ( + + 0 ? summaryParts.join(", ") : emptyTitle, asOf] + .filter(Boolean) + .join(". "), + ), + ]} + > + + + + {renderLine(summary || "T3 Code", 12, secondaryForeground, "semibold")} + + {published ? renderLine(asOf, 11, tertiaryForeground) : null} + + {hero ? ( + renderLeadingTab(hero) + ) : ( + + {renderMark(emptyPhase, 22)} + {renderLine(emptyTitle, 15, primaryForeground, "semibold")} + + )} + {shown.map((thread) => renderTab(thread))} + {hidden > 0 ? ( + + {`+${hidden} more`} + + ) : null} + + ); +} + +export default createWidget>("AgentActivity", AgentStatus); diff --git a/apps/mobile/src/widgets/AgentStatusCoordinator.tsx b/apps/mobile/src/widgets/AgentStatusCoordinator.tsx new file mode 100644 index 0000000000000..aec9481157b47 --- /dev/null +++ b/apps/mobile/src/widgets/AgentStatusCoordinator.tsx @@ -0,0 +1,77 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { useEffect, useSyncExternalStore } from "react"; +import { AppState } from "react-native"; + +import { showcaseAgentStatusRowsAtom } from "../features/showcase/showcaseAgentActivity"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { environmentProjects } from "../state/projects"; +import { environmentShell } from "../state/shell"; +import { environmentThreadShells } from "../state/threads"; +import { workspaceConnections } from "../state/workspace"; +import { + agentStatusDisplayKey, + agentStatusRows, + buildAgentStatusSnapshot, + type AgentStatusRow, +} from "./agentStatusSnapshot"; +import { publishAgentStatus } from "./publishAgentStatus"; +import { createWidgetTheme } from "./widgetTheme"; + +// Null while no environment is live: the widget keeps its last snapshot and lets it expire. +const rowsAtom = Atom.make((get) => { + const liveEnvironmentIds = new Set( + get(workspaceConnections.environmentsAtom) + .filter( + (environment) => + environment.isEnabled && + environment.connectionState === "connected" && + get(environmentShell.stateValueAtom(environment.environmentId)).status === "live", + ) + .map((environment) => environment.environmentId), + ); + if (liveEnvironmentIds.size === 0) return null; + return agentStatusRows({ + threads: get(environmentThreadShells.threadShellsAtom), + projects: get(environmentProjects.projectsAtom), + liveEnvironmentIds, + }); +}).pipe( + Atom.withEquality | null>( + (a, b) => agentStatusDisplayKey(a) === agentStatusDisplayKey(b), + ), +); + +function subscribeToAppState(listener: () => void) { + const subscription = AppState.addEventListener("change", listener); + return () => subscription.remove(); +} + +// Notification Center and the app switcher pass through inactive; only leaving or returning republishes. +function currentAppState() { + return AppState.currentState === "background" ? "background" : "active"; +} + +export function AgentStatusCoordinator() { + const { themeMode, themeVariablesByAppearance, isReady } = useAppearancePreferences(); + const liveRows = useAtomValue(rowsAtom); + const rows = useAtomValue(showcaseAgentStatusRowsAtom) ?? liveRows; + const appState = useSyncExternalStore(subscribeToAppState, currentAppState, currentAppState); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes agent status to the native home screen widget + useEffect(() => { + if (!isReady || rows === null) return; + void Promise.resolve() + .then(() => + publishAgentStatus({ + ...buildAgentStatusSnapshot(rows, { now: Date.now() }), + theme: createWidgetTheme(themeMode, themeVariablesByAppearance), + }), + ) + .catch((error: unknown) => { + console.warn("Could not update agent status widget", error); + }); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Leaving or entering the app restarts the snapshot's freshness window while the connection is still live. + }, [isReady, rows, themeMode, themeVariablesByAppearance, appState]); + return null; +} diff --git a/apps/mobile/src/widgets/agentStatusSnapshot.test.ts b/apps/mobile/src/widgets/agentStatusSnapshot.test.ts new file mode 100644 index 0000000000000..9ad55808a484c --- /dev/null +++ b/apps/mobile/src/widgets/agentStatusSnapshot.test.ts @@ -0,0 +1,181 @@ +import { + presentThreadShell, + type EnvironmentProject, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentId, + ProjectId, + RuntimeRequestId, + ThreadId, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { makeRawThreadShell } from "../test-fixtures"; +import type { AgentActivityPhase } from "./AgentActivity"; +import { + AGENT_STATUS_FRESH_MS, + agentStatusRows, + agentStatusDisplayKey, + agentStatusTimeline, + buildAgentStatusSnapshot, + type AgentStatusRow, +} from "./agentStatusSnapshot"; + +const now = Date.parse("2026-10-31T18:42:00.000Z"); +const live = EnvironmentId.make("moonbase"); +const offline = EnvironmentId.make("laptop"); +const projectId = ProjectId.make("t3code"); + +function project(environmentId: EnvironmentId): EnvironmentProject { + return { environmentId, id: projectId, title: "T3 Code" } as EnvironmentProject; +} + +function shell( + id: string, + raw: Partial, + presented: Partial = {}, + environmentId = live, +): EnvironmentThreadShell { + const threadId = ThreadId.make(id); + return { + ...presentThreadShell( + environmentId, + makeRawThreadShell({ id: threadId, projectId, title: id, ...raw }), + ), + ...presented, + }; +} + +function row(title: string, phase: AgentActivityPhase, minutesAgo: number): AgentStatusRow { + return { + threadTitle: title, + projectTitle: "T3 Code", + phase, + updatedAt: new Date(now - minutesAgo * 60_000).toISOString(), + deepLink: `/threads/moonbase/${encodeURIComponent(title)}`, + }; +} + +const finished = (completedAt: string, lastVisitedAt: string) => ({ + latestRun: { + runId: "run" as never, + status: "completed" as const, + requestedAt: null, + startedAt: null, + completedAt, + assistantMessageId: null, + }, + lastVisitedAt, +}); + +describe("agent status widget snapshots", () => { + it("projects threads the way the relay does, from live environments only", () => { + const rows = agentStatusRows({ + threads: [ + shell("approval", { + status: "running", + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request"), + kind: "command", + createdAt: DateTime.makeUnsafe(now), + }, + }), + shell("running", { status: "running" }), + shell( + "unseen-done", + { status: "completed" }, + finished("2026-10-31T18:40:00Z", "2026-10-31T18:00:00Z"), + ), + shell( + "seen-done", + { status: "completed" }, + finished("2026-10-31T18:00:00Z", "2026-10-31T18:30:00Z"), + ), + shell("idle", { status: "idle" }), + shell("archived", { status: "running" }, { archivedAt: "2026-10-31T18:00:00Z" }), + shell("offline", { status: "running" }, {}, offline), + ], + projects: [project(live), project(offline)], + liveEnvironmentIds: new Set([live]), + }); + + expect(rows.map((candidate) => [candidate.threadTitle, candidate.phase])).toEqual([ + ["approval", "waiting_for_approval"], + ["running", "running"], + ["unseen-done", "completed"], + ]); + expect(rows[0]?.deepLink).toBe("/threads/moonbase/approval"); + }); + + it("leads with the thread that needs the user and opens it", () => { + const snapshot = buildAgentStatusSnapshot( + [ + row("Update snapshots", "completed", 0), + row("Polish copy", "running", 0), + row("Fix login redirect", "waiting_for_approval", 3), + row("Flaky test", "failed", 1), + row("Answer me", "waiting_for_input", 1), + ], + { now }, + ); + + expect(snapshot.threads.map((thread) => thread.title)).toEqual([ + "Answer me", + "Fix login redirect", + "Flaky test", + "Polish copy", + "Update snapshots", + ]); + expect(snapshot.threads[0]?.url).toBe("t3code://threads/moonbase/Answer%20me"); + expect(snapshot.url).toBe("t3code://"); + }); + + it("counts every thread, including those past the widget's rows", () => { + const rows = [ + ...Array.from({ length: 9 }, (_, index) => row(`Working ${index}`, "running", index)), + row("Fix login redirect", "waiting_for_input", 20), + row("Update snapshots", "completed", 0), + row("Flaky test", "failed", 0), + ]; + const snapshot = buildAgentStatusSnapshot(rows, { now }); + + expect(snapshot.counts).toEqual({ needsYou: 1, working: 9, failed: 1, done: 1 }); + expect(snapshot.threads).toHaveLength(8); + expect(snapshot.threads[0]?.title).toBe("Fix login redirect"); + }); + + it("re-renders the widget once the snapshot can no longer vouch for running work", () => { + const snapshot = buildAgentStatusSnapshot([row("Polish copy", "running", 0)], { now }); + + expect(snapshot.checkedAt).toBe(now); + expect(agentStatusTimeline(snapshot).map((entry) => entry.date.getTime())).toEqual([ + now, + now + AGENT_STATUS_FRESH_MS, + ]); + expect(snapshot.expiresAt).toBe(now + AGENT_STATUS_FRESH_MS); + }); + + it("opens a thread whose ID needs escaping exactly once", () => { + const [threadRow] = agentStatusRows({ + threads: [shell("fix%2Flogin redirect", { status: "running" })], + projects: [project(live)], + liveEnvironmentIds: new Set([live]), + }); + const url = buildAgentStatusSnapshot(threadRow ? [threadRow] : [], { now }).threads[0]?.url; + + expect(url).toBe("t3code://threads/moonbase/fix%252Flogin%20redirect"); + expect(decodeURIComponent(url?.split("/").at(-1) ?? "")).toBe("fix%2Flogin redirect"); + }); + + it("republishes only when what the widget shows changes", () => { + const rows = [row("Polish copy", "running", 0), row("Fix login redirect", "running", 5)]; + const touched = rows.map((entry) => ({ ...entry, updatedAt: new Date(now).toISOString() })); + + expect(agentStatusDisplayKey(touched)).toBe(agentStatusDisplayKey(rows)); + const failed = [row("Polish copy", "running", 0), row("Fix login redirect", "failed", 5)]; + expect(agentStatusDisplayKey(failed)).not.toBe(agentStatusDisplayKey(rows)); + }); +}); diff --git a/apps/mobile/src/widgets/agentStatusSnapshot.ts b/apps/mobile/src/widgets/agentStatusSnapshot.ts new file mode 100644 index 0000000000000..d4eff3bf05b36 --- /dev/null +++ b/apps/mobile/src/widgets/agentStatusSnapshot.ts @@ -0,0 +1,144 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; + +import { threadHasUnseenCompletion } from "../features/threads/threadListV2"; +import type { AgentActivityPhase, AgentActivityRowProps } from "./AgentActivity"; +import type { WidgetTheme } from "./widgetTheme"; + +// Widgets get no relay pushes, and iOS suspends the app soon after it leaves the screen; +// past this age a snapshot cannot vouch that an agent is still working. +export const AGENT_STATUS_FRESH_MS = 5 * 60_000; +// The large family shows the leading thread and seven more. +const MAX_THREADS = 8; +// Widget taps open the containing app, so its production scheme is safe in every build, as in the Live Activity. +const APP_URL = "t3code://"; + +export type AgentStatusRow = Pick< + AgentActivityRowProps, + "threadTitle" | "projectTitle" | "phase" | "updatedAt" | "deepLink" +>; + +export interface AgentStatusThread { + readonly title: string; + readonly project: string; + readonly phase: AgentActivityPhase; + readonly url: string; +} + +export interface AgentStatusCounts { + readonly needsYou: number; + readonly working: number; + readonly failed: number; + readonly done: number; +} + +export interface AgentStatusSnapshot { + readonly theme?: WidgetTheme; + readonly url: string; + readonly checkedAt: number; + readonly expiresAt: number; + readonly counts: AgentStatusCounts; + readonly threads: ReadonlyArray; +} + +// The Live Activity's order: threads that need the user, failures, work, then outcomes. +const PHASE_RANK: Record = { + waiting_for_approval: 0, + waiting_for_input: 0, + failed: 1, + starting: 2, + running: 2, + completed: 3, + stale: 3, +}; + +/** Rows from environments whose live shell this client holds; cached shells may be old. */ +export function agentStatusRows(input: { + readonly threads: ReadonlyArray; + readonly projects: ReadonlyArray; + readonly liveEnvironmentIds: ReadonlySet; +}): ReadonlyArray { + const projectByKey = new Map( + input.projects.map((project) => [`${project.environmentId}\u0000${project.id}`, project]), + ); + return input.threads.flatMap((thread) => { + if (thread.archivedAt !== null || !input.liveEnvironmentIds.has(thread.environmentId)) { + return []; + } + const project = projectByKey.get(`${thread.environmentId}\u0000${thread.projectId}`); + if (!project) return []; + // The relay projects Live Activity rows with this same function. + const state = projectThreadAwarenessV2({ + environmentId: thread.environmentId, + project, + thread: thread.source, + }); + if (state === null) return []; + // Finished work stays until the user opens the thread, like the thread list's Done badge. + if ( + (state.phase === "completed" || state.phase === "failed") && + !threadHasUnseenCompletion(thread) + ) { + return []; + } + return [ + { + threadTitle: state.threadTitle, + projectTitle: state.projectTitle, + phase: state.phase, + updatedAt: state.updatedAt, + deepLink: state.deepLink, + }, + ]; + }); +} + +export function buildAgentStatusSnapshot( + rows: ReadonlyArray, + input: { readonly now: number }, +): AgentStatusSnapshot { + const ordered = [...rows].sort( + (a, b) => + PHASE_RANK[a.phase] - PHASE_RANK[b.phase] || + Date.parse(b.updatedAt) - Date.parse(a.updatedAt), + ); + const count = (...phases: ReadonlyArray) => + rows.filter((row) => phases.includes(row.phase)).length; + return { + url: APP_URL, + checkedAt: input.now, + expiresAt: input.now + AGENT_STATUS_FRESH_MS, + counts: { + needsYou: count("waiting_for_approval", "waiting_for_input"), + working: count("starting", "running"), + failed: count("failed"), + done: count("completed"), + }, + threads: ordered.slice(0, MAX_THREADS).map((row) => ({ + title: row.threadTitle, + project: row.projectTitle, + phase: row.phase, + // deepLink is already percent-encoded; a URL builder would encode it a second time. + url: `${APP_URL}${row.deepLink.replace(/^\//u, "")}`, + })), + }; +} + +/** What the widget shows; a thread's updatedAt changes on every event but only orders rows. */ +export function agentStatusDisplayKey(rows: ReadonlyArray | null): string { + if (rows === null) return "null"; + const { counts, threads } = buildAgentStatusSnapshot(rows, { now: 0 }); + return JSON.stringify({ counts, threads }); +} + +/** A second entry re-renders the widget when the snapshot expires, with no app running. */ +export function agentStatusTimeline(snapshot: AgentStatusSnapshot) { + return [snapshot.checkedAt, snapshot.expiresAt].map((at) => ({ + date: new Date(at), + props: snapshot, + })); +} diff --git a/apps/mobile/src/widgets/publishAgentStatus.ios.test.ts b/apps/mobile/src/widgets/publishAgentStatus.ios.test.ts new file mode 100644 index 0000000000000..4a0867e6aee12 --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentStatus.ios.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +const native = vi.hoisted(() => ({ + layoutLoaded: false, + widget: { reload: vi.fn(), updateTimeline: vi.fn() }, +})); +vi.mock("expo", () => ({ requireOptionalNativeModule: () => ({}) })); +vi.mock("./AgentStatus", () => { + native.layoutLoaded = true; + return { default: native.widget }; +}); + +describe("publishAgentStatus on iOS", () => { + it("registers and reloads the widget at launch, before any environment connects", async () => { + await import("./publishAgentStatus.ios"); + + await vi.waitFor(() => expect(native.widget.reload).toHaveBeenCalledTimes(1)); + expect(native.layoutLoaded).toBe(true); + expect(native.widget.updateTimeline).not.toHaveBeenCalled(); + }); + + it("logs a widget that fails to load once, then publishes nothing", async () => { + vi.resetModules(); + native.widget.reload.mockImplementationOnce(() => { + throw new Error("widget layout missing"); + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { publishAgentStatus } = await import("./publishAgentStatus.ios"); + const { buildAgentStatusSnapshot } = await import("./agentStatusSnapshot"); + const snapshot = buildAgentStatusSnapshot([], { now: 0 }); + + await expect(publishAgentStatus(snapshot)).resolves.toBeUndefined(); + await expect(publishAgentStatus(snapshot)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(native.widget.updateTimeline).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/apps/mobile/src/widgets/publishAgentStatus.ios.ts b/apps/mobile/src/widgets/publishAgentStatus.ios.ts new file mode 100644 index 0000000000000..894d5ca86de58 --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentStatus.ios.ts @@ -0,0 +1,21 @@ +import { requireOptionalNativeModule } from "expo"; + +import { agentStatusTimeline, type AgentStatusSnapshot } from "./agentStatusSnapshot"; + +// Loading the module stores the widget's layout; reloading then shows the +// "Open T3 Code" state until an environment connects and the first snapshot lands. +const widget = requireOptionalNativeModule("ExpoWidgets") + ? import("./AgentStatus") + .then(({ default: agentStatus }) => { + agentStatus.reload(); + return agentStatus; + }) + .catch((error: unknown) => { + console.warn("Could not load agent status widget", error); + return null; + }) + : null; + +export async function publishAgentStatus(snapshot: AgentStatusSnapshot) { + (await widget)?.updateTimeline(agentStatusTimeline(snapshot)); +} diff --git a/apps/mobile/src/widgets/publishAgentStatus.ts b/apps/mobile/src/widgets/publishAgentStatus.ts new file mode 100644 index 0000000000000..c4e46a6cf1223 --- /dev/null +++ b/apps/mobile/src/widgets/publishAgentStatus.ts @@ -0,0 +1,4 @@ +import type { AgentStatusSnapshot } from "./agentStatusSnapshot"; + +// The Agent status widget is iOS-only; Android shows agent work in its ongoing notification. +export function publishAgentStatus(_snapshot: AgentStatusSnapshot) {}