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 f59f53fc591ee..34c06b362f4e6 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,11 @@ import { markNativeShowcaseReady, type ShowcaseScene, } from "./nativeShowcaseScene"; -import { buildShowcaseAgentActivity } from "./showcaseAgentActivity"; +import { + buildShowcaseAgentActivity, + showcaseAgentStatusRowsAtom, + type ShowcaseAgentActivityFixture, +} from "./showcaseAgentActivity"; import { isShowcaseThemeApplied, SHOWCASE_THREAD_ID, @@ -100,6 +105,7 @@ function showcaseNavigationAction( case "threads": return StackActions.popToTop(); case "agent-activity": + case "agent-status": return CommonActions.reset({ index: 0, routes: [{ name: "Home" }] }); case "environments": return CommonActions.reset(settingsNavigationState(rootState, "SettingsEnvironments")); @@ -127,6 +133,7 @@ export function installShowcaseCapture( let requestedTheme: MobileThemeId | null = null; let orientationSettled = false; let requestedScene: ShowcaseScene | null = null; + let requestedFixture: ShowcaseAgentActivityFixture = "store"; let pendingTasks: Run | null = null; let agentActivity: Run | null = null; let lastNavigation: ShowcaseNavigationTarget | null = null; @@ -188,6 +195,13 @@ export function installShowcaseCapture( // Draws belong to their scene request; revisiting review must wait for its new surface. clearShowcaseRenderSignal(); agentActivity = cancelRun(agentActivity); + // Hands the widget back to live data. + if (scene !== "agent-status") appAtomRegistry.set(showcaseAgentStatusRowsAtom, null); + } + const fixture = getNativeShowcaseAgentActivityFixture(); + if (fixture !== requestedFixture) { + requestedFixture = fixture; + agentActivity = cancelRun(agentActivity); } }; @@ -228,11 +242,12 @@ export function installShowcaseCapture( appAtomRegistry.get(environmentThreadShells.threadShellsAtom), appAtomRegistry.get(environmentProjects.projectsAtom), now, + requestedFixture, ); const outcome = activity === null ? "fixture threads not loaded" - : await stageShowcaseAgentActivity(activity, now); + : await stageShowcaseAgentActivity(activity, now, isCancelled(run)); if (outcome === true) return true; if (outcome !== lastOutcome) { console.warn(`[showcase] agent activity not staged: ${outcome}`); @@ -248,6 +263,19 @@ export function installShowcaseCapture( }); }; + // The widget coordinator publishes these rows through the live path, with the app's theme. + const stageAgentStatus = () => { + const activity = buildShowcaseAgentActivity( + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + appAtomRegistry.get(environmentProjects.projectsAtom), + Date.now(), + requestedFixture, + ); + if (activity === null) return; + appAtomRegistry.set(showcaseAgentStatusRowsAtom, activity.activities); + agentActivity = { cancelled: false, done: true }; + }; + const updateReadiness = (key: string | null, scene: ShowcaseScene | null) => { if (readiness?.key === key) return; readiness?.cancel(); @@ -327,7 +355,9 @@ export function installShowcaseCapture( } } - if (scene === "agent-activity" && hasFixture) { + if (scene === "agent-status" && hasFixture) { + if (agentActivity === null) stageAgentStatus(); + } else if (scene === "agent-activity" && hasFixture) { if (agentActivity === null) stageAgentActivity(); } else if (agentActivity?.done === false) { agentActivity = cancelRun(agentActivity); diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 3d7bdd004cec4..705b01df2c051 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", @@ -9,6 +13,7 @@ const SHOWCASE_SCENES = [ "review", "environments", "agent-activity", + "agent-status", ] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; @@ -55,15 +60,27 @@ export function getNativeShowcasePairingUrls(): ReadonlyArray { } } -export function getNativeShowcaseScene(): ShowcaseScene | null { +// `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 = 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..29727aa276ea2 100644 --- a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts @@ -2,23 +2,67 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { Atom } from "effect/reactivity"; import type { AgentActivityPhase, AgentActivityProps, AgentActivityRowProps, } from "../../widgets/AgentActivity"; +import type { AgentStatusRow } from "../../widgets/agentStatusSnapshot"; -const SHOWCASE_AGENT_ACTIVITY_ROWS = [ - { threadId: "pocket-command-center", phase: "waiting_for_approval", minutesAgo: 1 }, - { threadId: "beautiful-boot", phase: "waiting_for_input", minutesAgo: 4 }, - { threadId: "buttery-suspense", phase: "running", minutesAgo: 2 }, - { threadId: "remote-command-center", phase: "completed", minutesAgo: 3 }, -] as const satisfies ReadonlyArray<{ +/** The agent-status scene's fixture rows; the widget coordinator publishes them in place of live rows. */ +export const showcaseAgentStatusRowsAtom = Atom.make | null>( + null, +).pipe(Atom.keepAlive); + +interface ShowcaseAgentActivityRow { readonly threadId: string; readonly phase: AgentActivityPhase; readonly minutesAgo: number; -}>; +} + +const fixLoginRedirect = (phase: AgentActivityPhase): ShowcaseAgentActivityRow => ({ + threadId: "fix-login-redirect", + phase, + minutesAgo: 1, +}); +const updateSnapshots = (phase: AgentActivityPhase): ShowcaseAgentActivityRow => ({ + threadId: "update-snapshots", + phase, + minutesAgo: 1, +}); + +// Rows follow the relay's order: threads that need the user, failures, work, then outcomes. +const SHOWCASE_AGENT_ACTIVITY_FIXTURES = { + store: [ + { threadId: "pocket-command-center", phase: "waiting_for_approval", minutesAgo: 1 }, + { threadId: "beautiful-boot", phase: "waiting_for_input", minutesAgo: 4 }, + { threadId: "buttery-suspense", phase: "running", minutesAgo: 2 }, + { threadId: "remote-command-center", phase: "completed", minutesAgo: 3 }, + ], + "handoff-working": [fixLoginRedirect("running"), updateSnapshots("running")], + "handoff-approval": [fixLoginRedirect("waiting_for_approval"), updateSnapshots("completed")], + "handoff-input": [fixLoginRedirect("waiting_for_input"), updateSnapshots("completed")], + "phase-starting": [fixLoginRedirect("starting")], + "phase-running": [fixLoginRedirect("running")], + "phase-waiting_for_approval": [fixLoginRedirect("waiting_for_approval")], + "phase-waiting_for_input": [fixLoginRedirect("waiting_for_input")], + "phase-completed": [fixLoginRedirect("completed")], + "phase-failed": [fixLoginRedirect("failed")], + "phase-stale": [fixLoginRedirect("stale"), updateSnapshots("stale")], + empty: [], +} as const satisfies Record>; + +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 +74,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 +106,16 @@ export function buildShowcaseAgentActivity( deepLink: `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(definition.threadId)}`, }); } + const activeCount = rows.filter((row) => !TERMINAL_PHASES.has(row.phase)).length; return { title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: rows.filter((row) => ACTIVE_PHASES.has(row.phase)).length, + subtitle: + activeCount > 0 + ? "Agent work in progress" + : rows.some((row) => row.phase === "failed") + ? "Agent work failed" + : "Agent work completed", + activeCount, updatedAt: new Date(now).toISOString(), activities: rows, }; diff --git a/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts b/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts index 729dbcabe4292..a72505c3d0495 100644 --- a/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts +++ b/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts @@ -23,8 +23,15 @@ const ready: ShowcaseReadinessInput = { describe("showcaseSceneOnScreen", () => { it("treats the threads list as the agent activity scene only when that scene is requested", () => { expect(showcaseSceneOnScreen("agent-activity", "/")).toBe("agent-activity"); + expect(showcaseSceneOnScreen("agent-status", "/")).toBe("agent-status"); expect(showcaseSceneOnScreen("threads", "/")).toBe("threads"); - expect(showcaseSceneOnScreen("agent-activity", "/threads/env/thread")).toBe("thread"); + expect(showcaseSceneOnScreen("thread", "/threads/env/thread")).toBe("thread"); + }); + + it("keeps the thread a Live Activity tap opened inside the agent activity scene", () => { + expect(showcaseSceneOnScreen("agent-activity", "/threads/env/thread")).toBe("agent-activity"); + expect(showcaseSceneOnScreen("agent-status", "/threads/env/thread")).toBe("agent-status"); + expect(showcaseSceneOnScreen("agent-activity", "/threads/env/thread/review")).toBe("review"); expect(showcaseSceneOnScreen(null, "/threads/env/thread/review?x=1")).toBe("review"); }); }); @@ -60,12 +67,8 @@ describe("showcaseReadinessKey", () => { expect(showcaseReadinessKey({ ...ready, themeApplied: false })).toBeNull(); }); - it("waits for agent activity to be staged", () => { - const agentActivity = { - ...ready, - scene: "agent-activity", - requestedScene: "agent-activity", - } as const; + it.each(["agent-activity", "agent-status"] as const)("waits for %s to be staged", (scene) => { + const agentActivity = { ...ready, scene, requestedScene: scene } as const; expect(showcaseReadinessKey(agentActivity)).toBeNull(); expect(showcaseReadinessKey({ ...agentActivity, agentActivityStaged: true })).not.toBeNull(); }); diff --git a/apps/mobile/src/features/showcase/showcaseCaptureModel.ts b/apps/mobile/src/features/showcase/showcaseCaptureModel.ts index c47c58621c5ad..b71fbcd4e0da8 100644 --- a/apps/mobile/src/features/showcase/showcaseCaptureModel.ts +++ b/apps/mobile/src/features/showcase/showcaseCaptureModel.ts @@ -16,14 +16,19 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null { return null; } -/** Agent activity is staged on the threads list, so that route counts as the scene. */ +/** + * Agent activity and agent status are staged on the threads list, so that route counts as the + * scene. Tapping the Live Activity or the widget opens a thread, which stays part of the scene + * instead of navigating back. + */ export function showcaseSceneOnScreen( requestedScene: ShowcaseScene | null, pathname: string, ): ShowcaseScene | null { const routeScene = sceneFromPathname(pathname); - return requestedScene === "agent-activity" && routeScene === "threads" - ? "agent-activity" + return (requestedScene === "agent-activity" || requestedScene === "agent-status") && + (routeScene === "threads" || routeScene === "thread") + ? requestedScene : routeScene; } @@ -88,7 +93,7 @@ export function showcaseReadinessKey(input: ShowcaseReadinessInput): string | nu !input.hasFixture || !input.orientationSettled || !input.themeApplied || - (scene === "agent-activity" && !input.agentActivityStaged) || + ((scene === "agent-activity" || scene === "agent-status") && !input.agentActivityStaged) || !isShowcaseNativeContentReady({ scene, themeId: input.themeId, 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/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) {} 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 {