@@ -561,6 +572,24 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
);
}
+/** Brand monogram for tools from a known app (Gmail, Uber, Maps, ...); no logos are bundled. */
+export function AppActivityMark({ brand }: { brand: AppBrand }) {
+ return (
+ 1 ? 7 : 10,
+ }}
+ >
+ {brand.mark}
+
+ );
+}
+
function QuestionAnswerHistory({
answer,
}: {
diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx
index a440b62a584d2..6cbc0b57eecf9 100644
--- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx
+++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx
@@ -2,7 +2,11 @@ import { TimelinePendingApprovalRow } from "./TimelinePendingApprovalRow";
import { WorkLogButton, WorkLogList, WorkLogRow } from "./WorkLog";
import type { WorktreeSetupSnapshot } from "@t3tools/contracts";
import { type ToolActivityIcon } from "@t3tools/contracts";
-import { liveThoughtLine } from "@t3tools/client-runtime/work-log/presentation";
+import type { AppBrand } from "@t3tools/client-runtime/app-connectors";
+import {
+ liveThoughtLine,
+ resolveWorkEntryAppActivity,
+} from "@t3tools/client-runtime/work-log/presentation";
import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
import { observeResize } from "~/lib/observeResize";
import { tickTextContent } from "~/lib/tickTextContent";
@@ -46,6 +50,7 @@ import {
ThreadReadLabel,
ReasoningTraceContent,
SimpleWorkEntryRow,
+ AppActivityMark,
} from "./TimelineWorkEntryRow";
import {
remarkThoughtPreview,
@@ -384,6 +389,7 @@ function LiveActivityRow({
label,
iconName,
toolIcon,
+ brand,
failed = false,
active = false,
shimmer = false,
@@ -391,6 +397,7 @@ function LiveActivityRow({
label: ReactNode;
iconName?: WorkEntryIconName;
toolIcon?: ToolActivityIcon | undefined;
+ brand?: AppBrand | undefined;
failed?: boolean;
active?: boolean;
shimmer?: boolean;
@@ -406,13 +413,20 @@ function LiveActivityRow({
label={label}
iconName={iconName}
toolIcon={toolIcon}
+ brand={brand}
failed={failed}
announceFailure={failed}
active={animated && !shimmer}
/>
{showShimmer ? (
-
+
) : null}
@@ -423,6 +437,7 @@ function LiveActivityContent({
label,
iconName,
toolIcon,
+ brand,
failed = false,
announceFailure = false,
active = false,
@@ -431,6 +446,7 @@ function LiveActivityContent({
label: ReactNode;
iconName: WorkEntryIconName | undefined;
toolIcon?: ToolActivityIcon | undefined;
+ brand?: AppBrand | undefined;
failed?: boolean;
announceFailure?: boolean;
active?: boolean;
@@ -442,7 +458,9 @@ function LiveActivityContent({
return (
+ ) : iconName ? (
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 8acc312321b8f..50a11fe1dd87a 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -2545,3 +2545,53 @@ code {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
+
+/* App cards: type scale, easing and elevation copied from each app's design system. Colours come
+ from appCardThemes.ts as --ac-* variables on the card root. */
+@utility ac-t8 {
+ font-size: 8px;
+ line-height: 16px;
+}
+@utility ac-t11 {
+ font-size: 11px;
+ line-height: 16px;
+}
+@utility ac-t13 {
+ font-size: 13px;
+ line-height: 18px;
+}
+@utility ac-t15 {
+ font-size: 15px;
+ line-height: 21px;
+}
+@utility ac-t17 {
+ font-size: 17px;
+ line-height: 22px;
+ letter-spacing: -0.02em;
+}
+@utility ac-tight {
+ letter-spacing: -0.01em;
+}
+/* Material 3 emphasized easing. */
+@utility ac-ease-emphasized {
+ transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
+}
+/* Gmail's row hover lift. */
+@utility ac-lift {
+ &:hover {
+ z-index: 1;
+ box-shadow:
+ 0 1px 2px 0 rgb(60 64 67 / 0.3),
+ 0 1px 3px 1px rgb(60 64 67 / 0.15);
+ }
+}
+/* The maps route draws itself once along its real geometry. */
+@utility ac-route-draw {
+ stroke-dashoffset: 0;
+ @media (prefers-reduced-motion: no-preference) {
+ transition: stroke-dashoffset 1s cubic-bezier(0.65, 0, 0.35, 1);
+ @starting-style {
+ stroke-dashoffset: 1;
+ }
+ }
+}
diff --git a/apps/web/test/appCardsFilm.renders.tsx b/apps/web/test/appCardsFilm.renders.tsx
new file mode 100644
index 0000000000000..b309ad17856c8
--- /dev/null
+++ b/apps/web/test/appCardsFilm.renders.tsx
@@ -0,0 +1,400 @@
+// Shoots the app cards film in the real app (AppRoot on fakeEnvironment.ts) with demo data, one
+// PNG per frame, every animation pinned to a virtual clock. Skipped unless asked for:
+// VITE_APP_CARDS_FILM= vp test run --project renders test/appCardsFilm.renders.tsx
+import { createMemoryHistory } from "@tanstack/react-router";
+import type { RunId, ThreadId } from "@t3tools/contracts";
+import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
+import type * as Context from "effect/Context";
+import { expect, it, vi } from "vite-plus/test";
+import { page, userEvent } from "vite-plus/test/browser";
+
+import "~/index.css";
+
+import { AppRoot } from "~/AppRoot";
+import { __setClientSettingsForTests } from "~/hooks/useSettings";
+import { setAppearanceMode } from "~/hooks/useTheme";
+import { getRouter } from "~/router";
+import ferrySfoMap from "./fixtures/ferry-sfo-map.png";
+import {
+ ENVIRONMENT_ID,
+ assistantItem,
+ emitThreadEvent,
+ emptyThread,
+ installFakeEnvironment,
+ isThreadSubscribed,
+ runningRun,
+ toolItem,
+ userItem,
+ type FakeDriver,
+} from "./fakeEnvironment";
+import { act, fakeTimers, render } from "./render";
+
+vi.mock("../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
+ const [actual, { fakeDriver }, Layer] = await Promise.all([
+ importOriginal<{ readonly ConnectionDriver: Context.Key }>(),
+ import("./fakeEnvironment"),
+ import("effect/Layer"),
+ ]);
+ return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) };
+});
+vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({
+ ...(await original()),
+ trackRpcRequestSent: () => {},
+}));
+
+const OUT = import.meta.env.VITE_APP_CARDS_FILM as string | undefined;
+const FPS = Number(import.meta.env.VITE_APP_CARDS_FILM_FPS ?? 60);
+// Resumes a killed run: frames before this one are replayed but not saved again.
+const FROM = Number(import.meta.env.VITE_APP_CARDS_FILM_FROM ?? 0);
+// Laid out at 1280x720 and drawn at 1.5x: 1080p frames with laptop-sized type.
+const WIDTH = 1280;
+const HEIGHT = 720;
+const ZOOM = 1.5;
+
+const TRIP = emptyThread("thread-trip", "Thursday before SFO");
+const THREADS = [
+ TRIP,
+ emptyThread("thread-picker", "Model picker presets"),
+ emptyThread("thread-session", "Refresh sessions in auth middleware"),
+ emptyThread("thread-flaky", "Fix flaky composer tests"),
+ emptyThread("thread-ws", "Trim websocket snapshot size"),
+];
+installFakeEnvironment(THREADS, { projectTitle: "t3code", environmentLabel: "MacBook Pro" });
+__setClientSettingsForTests({
+ ...DEFAULT_CLIENT_SETTINGS,
+ onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
+});
+
+const PROMPT =
+ "Thursday I fly out of SFO. Find the Acme invoice I still owe, price a ride from the Ferry Building, show me the route, and tell me if PR #128 can merge before I leave.";
+
+// The real Ferry Building to SFO driving route (OSRM over OpenStreetMap), as the demo maps
+// connector returns it, with the basemap it sends.
+const FERRY_SFO =
+ "[[37.79376,-122.39407],[37.79324,-122.3934],[37.79251,-122.39433],[37.79246,-122.39228],[37.78788,-122.38787],[37.78263,-122.38804],[37.77976,-122.38962],[37.77737,-122.39212],[37.7759,-122.39525],[37.77356,-122.39816],[37.77129,-122.40078],[37.76716,-122.40196],[37.76224,-122.40219],[37.75564,-122.40312],[37.74932,-122.40347],[37.74476,-122.4039],[37.73961,-122.40442],[37.73529,-122.40594],[37.73186,-122.40592],[37.72818,-122.40382],[37.72402,-122.40047],[37.71913,-122.39819],[37.71246,-122.39591],[37.70594,-122.39391],[37.69766,-122.39276],[37.68965,-122.39327],[37.6828,-122.39578],[37.67455,-122.3984],[37.66447,-122.40065],[37.65586,-122.40054],[37.64764,-122.40214],[37.64144,-122.40354],[37.63487,-122.40011],[37.62957,-122.39552],[37.62291,-122.39129],[37.6165,-122.383]]";
+const MAP = `{image: "${new URL(ferrySfoMap, location.href).href}", bounds: [37.597565, -122.664055, 37.810018, -122.127003]}`;
+
+const PROGRAM = [
+ 'root = Board("Thursday, before SFO", "From Gmail, Uber, Google Maps and GitHub. Demo data.", [mail, ride, route, pr])',
+ 'mail = MailList("invoices from Acme", [{from: "Acme Billing", subject: "Invoice INV-2041 for October", snippet: "Your invoice of $4,280.00 is due Nov 15. Pay by card or ACH from the billing portal.", time: "Oct 31", unread: true, attachment: "INV-2041.pdf"}, {from: "Dana Ruiz", subject: "Re: Updated pricing for Q4", snippet: "Thanks for confirming. I have updated the order form.", time: "Oct 24", unread: false}])',
+ 'ride = RideOptions("uber", "Ferry Building", "SFO Terminal 2", [{name: "UberX", minutes: 4, price: "$38.40", seats: 4, arrival: "6:42 PM"}, {name: "Comfort", minutes: 6, price: "$46.10", seats: 4, arrival: "6:45 PM"}, {name: "UberXL", minutes: 9, price: "$61.75", seats: 6, arrival: "6:49 PM"}])',
+ `route = Route("Ferry Building", "SFO Terminal 2", "drive", 22, "14.5 mi", ["Head south on Steuart St", "Turn left onto Mission St", "Turn right onto The Embarcadero", "Merge onto I-280 S", "Take US-101 S toward the airport", "Turn right onto Departures"], "I-280 S and US-101 S", ${FERRY_SFO}, ${MAP})`,
+ 'pr = PullRequest(128, "Add model picker presets", "conflict", "feat/model-picker", "main", [{name: "Install", status: "pass", detail: "", started: 0, seconds: 11}, {name: "Tests", status: "pass", detail: "412 passed", started: 11, seconds: 38}, {name: "Lint", status: "fail", detail: "src/picker.ts:14 \'preset\' is defined but never used", started: 11, seconds: 9}, {name: "Types", status: "running", detail: "", started: 20, seconds: 24}], 2, [{title: "Wire presets into the composer", sha: "a41f9c2", author: "kevin", age: "2h", lane: "head", branch: "feat/model-picker"}, {title: "Add preset tests", sha: "9b3e0d4", author: "kevin", age: "3h", lane: "head"}, {title: "Rename picker tokens", sha: "c90e1b7", author: "julius", age: "5h", lane: "base", branch: "main", conflict: true}, {title: "Bump effect", sha: "77d0aa1", author: "theo", age: "1d", lane: "base"}])',
+].join("\n");
+const INTRO = "Here's Thursday at a glance, from your connected apps.\n\n```openui-v2\n";
+const OUTRO =
+ "\n```\n\nThe Acme invoice is **$4,280, due Nov 15**. UberX is cheapest at $38.40 and the drive is 22 min via I-280. PR #128 can't merge yet: 2 conflicts with `main`, and Lint is failing.";
+const ANSWER = INTRO + PROGRAM + OUTRO;
+
+const TOOLS = [
+ ["tool-gmail", "mcp__gmail__search_messages", { query: "invoices from Acme" }],
+ [
+ "tool-uber",
+ "mcp__uber__get_price_estimates",
+ { pickup: "Ferry Building", dropoff: "SFO Terminal 2" },
+ ],
+ [
+ "tool-maps",
+ "mcp__google-maps__get_directions",
+ { origin: "Ferry Building", destination: "SFO Terminal 2", mode: "drive" },
+ ],
+ ["tool-github", "mcp__github__get_pull_request", { number: 128 }],
+] as const;
+
+const firstSeen = new WeakMap();
+/** Pauses every animation and transition and puts it where it would be at `t` seconds. */
+function pinAnimations(t: number) {
+ for (const animation of document.getAnimations()) {
+ if (!firstSeen.has(animation)) firstSeen.set(animation, t);
+ animation.pause();
+ animation.currentTime = (t - firstSeen.get(animation)!) * 1000;
+ }
+}
+
+const ease = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2);
+const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
+
+// A cursor drawn over the app, since Playwright's pointer is invisible in screenshots.
+const cursor = document.createElement("div");
+cursor.style.cssText =
+ "position:fixed;left:0;top:0;width:30px;height:30px;z-index:2147483647;pointer-events:none;transform:translate(-100px,-100px);filter:drop-shadow(0 1px 2px rgb(0 0 0/.35))";
+cursor.innerHTML =
+ '';
+let cursorAt: readonly [number, number] = [WIDTH * ZOOM * 0.62, HEIGHT * ZOOM * 0.9];
+
+function centerOf(element: Element): readonly [number, number] {
+ const box = element.getBoundingClientRect();
+ return [box.left + box.width / 2, box.top + box.height / 2];
+}
+
+function scroller(): HTMLElement {
+ const anchor =
+ document.querySelector('[aria-label="Thursday, before SFO"]') ?? document.querySelector("main");
+ let node = anchor?.parentElement ?? null;
+ while (
+ node &&
+ !(
+ node.scrollHeight > node.clientHeight + 4 &&
+ /auto|scroll/.test(getComputedStyle(node).overflowY)
+ )
+ ) {
+ node = node.parentElement;
+ }
+ if (!node) throw new Error("no thread scroller");
+ return node;
+}
+
+it.skipIf(!OUT)(
+ "app cards film",
+ async () => {
+ await page.viewport(WIDTH * ZOOM, HEIGHT * ZOOM);
+ const start = new Date("2026-03-17T19:12:24.000Z").getTime();
+ // Until connected, Date runs at real speed from just before the fixtures' timestamps, so the
+ // minute clock behind sidebar ages ("2m") starts there and discovery's backoff still elapses.
+ vi.useFakeTimers({ toFake: ["Date"], now: start - 90_000, shouldAdvanceTime: true });
+ const router = getRouter(
+ createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${TRIP.id}`] }),
+ );
+ console.log("film: loading router");
+ // Discovery sometimes stalls the first load; AppRoot finishes it once mounted.
+ await Promise.race([router.load(), new Promise((resolve) => setTimeout(resolve, 8000))]);
+ console.log("film: router loaded");
+ const container = document.createElement("div");
+ container.style.cssText = `height: ${HEIGHT}px; width: ${WIDTH}px; zoom: ${ZOOM};`;
+ // Full-height views size to the viewport, which is ZOOM times the laid-out height here.
+ const fit = document.createElement("style");
+ fit.textContent = `.h-dvh, .h-svh { height: ${HEIGHT}px !important; } @media (min-width: 768px) { .md\\:h-dvh { height: ${HEIGHT}px !important; } }`;
+ document.head.append(fit);
+ await using _app = await render(, { container, router: false });
+ document.body.append(cursor);
+ await vi.waitFor(() => expect(isThreadSubscribed(TRIP.id)).toBe(true), { timeout: 30_000 });
+ await document.fonts.ready;
+ // From here Date only moves with the virtual clock. It starts just before the fixture runs'
+ // timestamps, so "Working for" counts from zero.
+ vi.useRealTimers();
+ using _clock = fakeTimers({ toFake: ["Date"], now: start });
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+
+ const run = runningRun(TRIP.id, "trip-run", 1, "trip-user");
+ const runId = run.id as RunId;
+ const editor = page.getByTestId("composer-editor");
+ let frame = 0;
+ let typed = 0;
+ let streamed = 0;
+ const toolsStarted = new Set();
+ const toolsDone = new Set();
+ let sent = false;
+ let finished = false;
+ let scrollFrom: number | null = null;
+ const done = new Set();
+ const once = async (key: string, at: number, t: number, action: () => Promise) => {
+ if (t >= at && !done.has(key)) {
+ done.add(key);
+ await action();
+ }
+ };
+ const emit = (event: Parameters[1]) =>
+ act(() => emitThreadEvent(TRIP.id as ThreadId, event));
+
+ /** Glides the cursor to `target` over [from, to] seconds. */
+ const glide = (
+ t: number,
+ from: number,
+ to: number,
+ start: readonly [number, number],
+ target: readonly [number, number],
+ ) => {
+ if (t < from || t > to + 1 / FPS) return;
+ const k = ease(clamp01((t - from) / (to - from)));
+ cursorAt = [start[0] + (target[0] - start[0]) * k, start[1] + (target[1] - start[1]) * k];
+ };
+ const targets = new Map();
+ const aim = (key: string, find: () => Element | null) => {
+ if (!targets.has(key)) {
+ const element = find();
+ if (element) targets.set(key, centerOf(element));
+ }
+ return targets.get(key);
+ };
+ const scrollTo = (t: number, from: number, to: number, target: () => number) => {
+ if (t < from || t > to + 1 / FPS) return;
+ const node = scroller();
+ if (scrollFrom === null || t - from < 1 / FPS / 2) scrollFrom = node.scrollTop;
+ node.scrollTop =
+ scrollFrom + (target() - scrollFrom) * ease(clamp01((t - from) / (to - from)));
+ if (t >= to) scrollFrom = null;
+ };
+ const cardTop = (label: RegExp) => {
+ const node = scroller();
+ const card = [...document.querySelectorAll("[data-app-card]")].find((element) =>
+ label.test(element.getAttribute("aria-label") ?? ""),
+ );
+ if (!card) return node.scrollTop;
+ return (
+ node.scrollTop +
+ (card.getBoundingClientRect().top - node.getBoundingClientRect().top) / ZOOM -
+ 24
+ );
+ };
+
+ const DURATION = 46;
+ for (let t = 0; t <= DURATION; t += 1 / FPS, frame++) {
+ vi.setSystemTime(start + t * 1000);
+ // 0.6-4.6 s: type the prompt into the real composer.
+ await once("focus", 0.3, t, () => userEvent.click(editor));
+ if (t >= 0.6 && t < 4.6) {
+ const want = Math.round(PROMPT.length * ease(clamp01((t - 0.6) / 3.8)));
+ if (want > typed) {
+ await userEvent.keyboard(PROMPT.slice(typed, want).replace(/[{[]/g, (c) => c + c));
+ typed = want;
+ }
+ }
+ glide(
+ t,
+ 4.6,
+ 5.2,
+ cursorAt,
+ aim("send", () =>
+ document.querySelector('button[aria-label^="Send"], button[type="submit"]'),
+ ) ?? cursorAt,
+ );
+ // 5.3 s: send. The fake server never answers a send, so the composer is cleared by hand
+ // and the turn arrives as the server would deliver it.
+ if (t >= 5.3 && !sent) {
+ sent = true;
+ await userEvent.keyboard("{Control>}a{/Control}{Backspace}");
+ await emit({ type: "run.created", payload: run });
+ await emit({
+ type: "turn-item.updated",
+ payload: userItem(TRIP.id, runId, "trip-user", 0, PROMPT),
+ });
+ }
+ // 6.0-9.6 s: four connector calls, overlapping.
+ for (const [index, [id, name, input]] of TOOLS.entries()) {
+ const start = 6.0 + index * 0.45;
+ const end = start + 1.5 + index * 0.25;
+ if (t >= start && !toolsStarted.has(id)) {
+ toolsStarted.add(id);
+ await emit({
+ type: "turn-item.updated",
+ payload: toolItem(TRIP.id, runId, id, 1 + index, name, input, true),
+ });
+ }
+ if (t >= end && !toolsDone.has(id)) {
+ toolsDone.add(id);
+ await emit({
+ type: "turn-item.updated",
+ payload: toolItem(TRIP.id, runId, id, 1 + index, name, input, false),
+ });
+ }
+ }
+ // 10-19 s: the answer streams; the board fills in card by card.
+ if (t >= 10 && streamed < ANSWER.length) {
+ const want = Math.min(ANSWER.length, Math.round((t - 10) * 520));
+ if (want > streamed) {
+ streamed = want;
+ await emit({
+ type: "turn-item.updated",
+ payload: assistantItem(
+ TRIP.id,
+ runId,
+ "trip-answer",
+ 5,
+ ANSWER.slice(0, streamed),
+ streamed < ANSWER.length,
+ ),
+ });
+ }
+ }
+ if (streamed >= ANSWER.length && !finished) {
+ finished = true;
+ await emit({
+ type: "run.updated",
+ payload: { ...run, status: "completed", completedAt: run.startedAt },
+ });
+ }
+ // 20-46 s: read the board, tapping as a person would.
+ scrollTo(t, 19.6, 21.0, () => cardTop(/^Gmail results/));
+ glide(
+ t,
+ 21.2,
+ 22.0,
+ cursorAt,
+ aim(
+ "mail",
+ () => [...document.querySelectorAll("[data-app-card=gmail] button")].at(-1) ?? null,
+ ) ?? cursorAt,
+ );
+ await once("mail", 22.1, t, () =>
+ userEvent.click(page.getByRole("button", { name: /^Dana Ruiz/ })),
+ );
+ scrollTo(t, 23.2, 24.6, () => cardTop(/ride options/));
+ glide(
+ t,
+ 24.8,
+ 25.6,
+ cursorAt,
+ aim("comfort", () =>
+ document.querySelector('[data-app-card=uber] button[aria-label^="Comfort"]'),
+ ) ?? cursorAt,
+ );
+ await once("comfort", 25.7, t, () =>
+ userEvent.click(page.getByRole("button", { name: /^Comfort/ })),
+ );
+ scrollTo(t, 27.0, 28.4, () => cardTop(/^Directions/));
+ for (const [n, at] of [
+ [0, 29.6],
+ [1, 30.6],
+ [2, 31.6],
+ ] as const) {
+ glide(
+ t,
+ at - 0.9,
+ at - 0.1,
+ cursorAt,
+ aim("next", () => document.querySelector('[aria-label="Next step"]')) ?? cursorAt,
+ );
+ await once(`next${n}`, at, t, () =>
+ userEvent.click(page.getByRole("button", { name: "Next step" })),
+ );
+ }
+ scrollTo(t, 32.6, 34.0, () => cardTop(/^Pull request/));
+ glide(
+ t,
+ 34.2,
+ 35.0,
+ cursorAt,
+ aim(
+ "tests",
+ () =>
+ [...document.querySelectorAll("[data-app-card=github] button")].find((b) =>
+ b.textContent?.startsWith("Tests"),
+ ) ?? null,
+ ) ?? cursorAt,
+ );
+ await once("tests", 35.1, t, () =>
+ userEvent.click(page.getByRole("button", { name: /^Tests/ })),
+ );
+ // 37.5 s: dark mode, the way Settings or the theme shortcut switches it.
+ await once("dark", 37.5, t, async () => {
+ await act(() => void setAppearanceMode("dark"));
+ });
+ scrollTo(t, 39.0, 41.5, () => cardTop(/^Gmail results/) - 40);
+ scrollTo(t, 42.0, 45.0, () => cardTop(/^Pull request/));
+
+ // Let React commit, then pin animations to this frame's time and save it.
+ // Frames being replayed only need React to commit, not a paint.
+ await new Promise((resolve) =>
+ frame >= FROM ? requestAnimationFrame(() => resolve(null)) : setTimeout(resolve, 0),
+ );
+ cursor.style.transform = `translate(${cursorAt[0] - 3}px, ${cursorAt[1] - 2}px)`;
+ pinAnimations(t);
+ if (frame % 20 === 0) console.log(`film: frame ${frame} t=${t.toFixed(2)}`);
+ if (frame >= FROM)
+ await page.screenshot({ path: `${OUT}/${String(frame).padStart(5, "0")}.png`, save: true });
+ }
+ expect(finished).toBe(true);
+ setAppearanceMode("light");
+ },
+ 3_600_000,
+);
diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts
index 26e2ee9fa33a3..0faa84566887c 100644
--- a/apps/web/test/fakeEnvironment.ts
+++ b/apps/web/test/fakeEnvironment.ts
@@ -101,7 +101,7 @@ const serverConfig: ServerConfig = {
keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
};
-const project: OrchestrationProjectShell = {
+let project: OrchestrationProjectShell = {
id: PROJECT_ID,
title: "render",
workspaceRoot: "/workspace/render",
@@ -408,8 +408,16 @@ const encodedDescriptor = Schema.encodeSync(ExecutionEnvironmentDescriptor)(
);
/** Serves the environment's HTTP API and these threads until disposed. */
-export function installFakeEnvironment(fixtures: ReadonlyArray): Disposable {
+export function installFakeEnvironment(
+ fixtures: ReadonlyArray,
+ {
+ projectTitle,
+ environmentLabel,
+ }: { readonly projectTitle?: string; readonly environmentLabel?: string } = {},
+): Disposable {
threads = fixtures;
+ if (projectTitle) project = { ...project, title: projectTitle };
+ if (environmentLabel) descriptor.label = environmentLabel;
globalThis.fetch = async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input), window.location.href);
if (url.origin !== window.location.origin) return realFetch(input, init);
@@ -459,6 +467,30 @@ export async function emitThreadEvent(
);
}
+/** A tool call from an MCP server, as providers report it (`mcp__server__tool`). */
+export function toolItem(
+ threadId: ThreadId,
+ runId: RunId,
+ id: string,
+ ordinal: number,
+ toolName: string,
+ input: unknown,
+ running: boolean,
+) {
+ return {
+ ...base(threadId, runId, id, ordinal),
+ type: "dynamic_tool",
+ toolName,
+ input,
+ ...(running ? { status: "running" as const, completedAt: null } : {}),
+ } satisfies OrchestrationV2TurnItem;
+}
+
+/** An empty thread: the user has not sent anything yet. */
+export function emptyThread(name: string, title: string): FakeThread {
+ return { id: ThreadId.make(name), title, runs: [], items: [] };
+}
+
export function runningRun(threadId: ThreadId, id: string, ordinal: number, userMessageId: string) {
return {
...run(threadId, RunId.make(id), ordinal, userMessageId),
diff --git a/apps/web/test/fixtures/appCardsBoard.ts b/apps/web/test/fixtures/appCardsBoard.ts
new file mode 100644
index 0000000000000..f23b286f5d0de
--- /dev/null
+++ b/apps/web/test/fixtures/appCardsBoard.ts
@@ -0,0 +1,51 @@
+// The demo app-card board: the real Ferry Building to SFO route (OSRM over OpenStreetMap) and its
+// basemap, the way the demo connectors return them. Shared by the browser test and preview renders.
+import ferrySfoMap from "./ferry-sfo-map.png";
+
+export const FERRY_SFO = JSON.stringify([
+ [37.79384, -122.39395],
+ [37.79325, -122.39321],
+ [37.79372, -122.39261],
+ [37.79271, -122.39147],
+ [37.79115, -122.39061],
+ [37.78924, -122.38841],
+ [37.78778, -122.38799],
+ [37.78085, -122.38865],
+ [37.7737, -122.39757],
+ [37.77275, -122.3981],
+ [37.76886, -122.39738],
+ [37.76591, -122.39412],
+ [37.76399, -122.393],
+ [37.75164, -122.39166],
+ [37.74557, -122.39467],
+ [37.73888, -122.40064],
+ [37.73572, -122.40444],
+ [37.73383, -122.40507],
+ [37.71634, -122.39893],
+ [37.7114, -122.39581],
+ [37.67773, -122.38851],
+ [37.6742, -122.38972],
+ [37.67028, -122.39245],
+ [37.66498, -122.39762],
+ [37.6597, -122.40477],
+ [37.65578, -122.4068],
+ [37.65292, -122.40712],
+ [37.64071, -122.40597],
+ [37.6356, -122.40409],
+ [37.62656, -122.40252],
+ [37.61442, -122.39796],
+ [37.6138, -122.39644],
+ [37.61536, -122.38856],
+ [37.61475, -122.38581],
+ [37.6151, -122.38486],
+ [37.6165, -122.38392],
+]);
+
+// One board with every app card, filled the way a connector turn would fill it.
+export const APP_CARDS_BOARD = [
+ 'root = Board("Your apps", "Demo data.", [mail, ride, route, pr])',
+ 'mail = MailList("invoices from Acme", [{from: "Acme Billing", subject: "Invoice INV-2041 for October", snippet: "Your invoice of $4,280.00 is due Nov 15.", time: "Oct 31", unread: true, attachment: "INV-2041.pdf"}, {from: "Dana Ruiz", subject: "Re: Updated pricing for Q4", snippet: "Thanks for confirming.", time: "Oct 24", unread: false}])',
+ 'ride = RideOptions("uber", "Ferry Building", "SFO", [{name: "UberX", minutes: 4, price: "$38.40", seats: 4, arrival: "6:42 PM"}, {name: "Comfort", minutes: 6, price: "$46.10", seats: 4}, {name: "UberXL", minutes: 9, price: "$61.75", seats: 6}])',
+ `route = Route("Ferry Building", "SFO Terminal 2", "drive", 22, "14.5 mi", ["Head south on Steuart St", "Turn left onto Mission St", "Turn right onto The Embarcadero"], "I-280 S and US-101 S", ${FERRY_SFO}, {image: "${new URL(ferrySfoMap, location.href).href}", bounds: [37.597565, -122.664055, 37.810018, -122.127003]})`,
+ 'pr = PullRequest(128, "Add model picker presets", "conflict", "feat/model-picker", "main", [{name: "Tests", status: "pass", detail: "412 passed", started: 11, seconds: 38}, {name: "Lint", status: "fail", detail: "src/picker.ts:14 no-unused-vars", started: 11, seconds: 9}, {name: "Types", status: "running", detail: "", started: 20, seconds: 24}], 2, [{title: "Wire presets into the composer", sha: "a41f9c2", author: "kevin", age: "2h", lane: "head", branch: "feat/model-picker"}, {title: "Rename picker tokens", sha: "c90e1b7", author: "julius", age: "5h", lane: "base", branch: "main", conflict: true}, {title: "Bump effect", sha: "77d0aa1", author: "theo", age: "1d", lane: "base"}])',
+].join("\n");
diff --git a/apps/web/test/fixtures/ferry-sfo-map.png b/apps/web/test/fixtures/ferry-sfo-map.png
new file mode 100644
index 0000000000000..6605251594f1b
Binary files /dev/null and b/apps/web/test/fixtures/ferry-sfo-map.png differ
diff --git a/apps/web/test/harnessKits.renders.tsx b/apps/web/test/harnessKits.renders.tsx
new file mode 100644
index 0000000000000..34bc36236fa7b
--- /dev/null
+++ b/apps/web/test/harnessKits.renders.tsx
@@ -0,0 +1,105 @@
+// App cards in other harnesses' tokens: each kit is mapped onto T3 Code's semantic variables around a
+// real board; every card keeps its own app's design system. Skipped unless asked for:
+// VITE_HARNESS_KITS="$(cat kits.json)" VITE_HARNESS_OUT= \
+// vp test run --project renders test/harnessKits.renders.tsx
+import { expect, it } from "vite-plus/test";
+import { page } from "vite-plus/test/browser";
+
+import "~/index.css";
+import { OpenUiCharts } from "~/components/chat/OpenUiCharts";
+import { setAppearanceMode } from "~/hooks/useTheme";
+import { APP_CARDS_BOARD } from "./fixtures/appCardsBoard";
+import { act, render } from "./render";
+
+interface Kit {
+ readonly id: string;
+ readonly name: string;
+ readonly source: string;
+ readonly fonts: { readonly ui?: string | null; readonly mono?: string | null };
+ readonly radius: { readonly card?: string | null; readonly bubble?: string | null };
+ readonly light: Record;
+ readonly dark: Record;
+}
+
+const KITS: readonly Kit[] = JSON.parse(import.meta.env.VITE_HARNESS_KITS ?? "[]");
+const OUT = import.meta.env.VITE_HARNESS_OUT;
+const PROMPT =
+ "Find the Acme invoice, price a ride from the Ferry Building to SFO, and check PR #128.";
+
+// Kit role -> the T3 Code variables that draw that role.
+const ROLE_VARS: Record = {
+ canvas: ["--background"],
+ surface: ["--card", "--popover", "--surface-raised"],
+ text: ["--foreground", "--card-foreground", "--popover-foreground"],
+ muted: ["--muted-foreground"],
+ border: ["--border", "--input"],
+ accent: ["--primary", "--ring"],
+ userBubble: ["--message-surface"],
+ userText: ["--message-foreground"],
+ codeBg: ["--code-background"],
+};
+
+function kitStyle(kit: Kit, scheme: "light" | "dark") {
+ const vars: Record = {};
+ for (const [role, names] of Object.entries(ROLE_VARS)) {
+ const value = kit[scheme][role];
+ if (value) for (const name of names) vars[name] = value;
+ }
+ if (kit.fonts.ui) vars["--font-sans"] = kit.fonts.ui;
+ if (kit.fonts.mono) vars["--font-mono"] = kit.fonts.mono;
+ if (kit.radius.card) vars["--radius"] = kit.radius.card;
+ return vars;
+}
+
+function Shell(props: { kit: Kit; scheme: "light" | "dark"; width: number }) {
+ const { kit, scheme, width } = props;
+ return (
+
+
+ {`${kit.name} tokens · ${scheme} · preview, not the real ${kit.name} UI`}
+
+
+ {PROMPT}
+
+
Here's Thursday at a glance, from your connected apps.
+
+
+ );
+}
+
+const settle = async () => {
+ await Promise.all(document.getAnimations().map((animation) => animation.finished));
+ await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined)));
+};
+
+it.skipIf(!OUT || KITS.length === 0)(
+ "renders the app cards in every harness kit",
+ async () => {
+ for (const scheme of ["light", "dark"] as const) {
+ await act(() => void setAppearanceMode(scheme));
+ for (const width of [412, 760]) {
+ await page.viewport(width + 40, 2600);
+ for (const kit of KITS) {
+ await using _shell = await render();
+ await expect.element(page.getByText("INV-2041.pdf", { exact: false })).toBeVisible();
+ await settle();
+ const shell = document.querySelector(`[data-harness="${kit.id}"]`)!;
+ // Nothing in a phone-width thread may scroll sideways.
+ expect(shell.scrollWidth).toBeLessThanOrEqual(width + 1);
+ await page
+ .elementLocator(shell)
+ .screenshot({ path: `${OUT}/${kit.id}-${scheme}-${width}.png`, save: true });
+ }
+ }
+ }
+ await act(() => void setAppearanceMode("light"));
+ },
+ 600_000,
+);
diff --git a/docs/operations/development.md b/docs/operations/development.md
index 3307dbc22b969..3acba2a89bc73 100644
--- a/docs/operations/development.md
+++ b/docs/operations/development.md
@@ -86,6 +86,12 @@ To add a scenario, point a `SCENARIOS` entry at a generic ACP replay fixture
The agent replays session updates, permission requests, and questions. It drops session setup
and vendor frames, so Grok and fx recordings lose their vendor-only behavior.
+The `App cards` scenario plays four demo connector calls (Gmail, Uber, Google Maps, GitHub) and an
+OpenUI board of app cards, which is the quickest way to check the cards on web, desktop and a paired
+phone. The same turn, shot frame by frame inside the app, is the app cards film:
+`VITE_APP_CARDS_FILM= vp test run --project renders test/appCardsFilm.renders.tsx`
+in `apps/web`.
+
### Sharing and remote debugging
`vp run dev --share` publishes the web port over the machine's tailnet and prints a pairing URL
diff --git a/docs/user/openui-reports.md b/docs/user/openui-reports.md
index 398ce792a7876..abc8f36a04196 100644
--- a/docs/user/openui-reports.md
+++ b/docs/user/openui-reports.md
@@ -23,8 +23,47 @@ block is complete; **Show source** still returns to the program.
see its exact count. Grids wider than 12 columns, such as hours of the day,
show compact cells and label every few columns; the counts stay one tap away.
-Charts keep their selections locally; tapping never sends a message or fetches
-anything. Scrolling a long conversation can reset a selection.
+Chart boards can also hold app cards. Each card is drawn in the design system
+of the service its data came from (GitHub's Primer, Uber's Base, Google's
+Material 3), in that app's typeface, and follows T3 Code's light or dark
+appearance:
+
+- **RideOptions** (Uber or Lyft): pickup and drop-off, then each ride option
+ with a vehicle picture, seats, pickup time, arrival time and price. The
+ cheapest option is tagged and selected. Tap another option to select it; the
+ button opens the ride app, where booking happens.
+- **MailList** (Gmail): the search, then each message's sender, subject, time,
+ snippet and attachment, with unread messages in bold. Tap a message to show
+ its whole snippet; "Open in Gmail" runs the same search in Gmail.
+- **Route** (Google Maps): travel time, distance, mode and main road, then one
+ step at a time. When the maps tool returns the route's shape, the card draws
+ it on the road it takes, over the tool's map image when it sent one; a card
+ never draws a route the tool did not return. "Open in Maps" opens the same
+ directions in Google Maps.
+- **PullRequest** (GitHub): number, title, state, recent commits on a small
+ commit graph with conflicting commits marked, the checks (as a timeline when
+ the tool reports when each ran), and one line that says where the pull
+ request stands, such as "This branch has 2 conflicts that must be resolved".
+ A failing check opens with its detail; tap any check to see its detail.
+
+On web and desktop a board fills in card by card while the agent is still
+writing it. The Android experiment shows the same cards natively, with the same
+themes, commit graph, route and app links, once the board is complete.
+
+Charts and cards keep their selections locally; tapping never sends a message,
+books, sends or merges anything. Links open the app itself, where any action
+still needs you. Scrolling a long conversation can reset a
+selection.
+
+When an agent uses a tool from a known app, its activity row shows that app's
+colored mark and a plain sentence, such as "Searching Gmail · invoices from
+Acme" or "Checked Uber prices", instead of the generic tool name. This works for
+any provider; the app is recognized from the tool's server name (Gmail, Google
+Calendar, Google Maps, Uber, Lyft, GitHub, Slack, Spotify and DoorDash). Expand
+the row as usual to see the full call.
+
+Brand names and colors only identify the service. T3 Code bundles no logos and
+is not affiliated with these companies.
Reports cannot call tools, run queries or mutations, maintain generated
state, or dispatch actions. Invalid or incomplete reports display a notice; the
@@ -45,3 +84,21 @@ does not replace the standard T3 Code app or receive its over-the-air updates.
A replay of a saved conversation demonstrates rendering and navigation, not
model generation speed. Generation-speed claims require live measurements with
the same facts, model, prompt overhead, and completion criteria.
+
+## Credits
+
+- Reports and chart boards use [OpenUI](https://github.com/thesysdev/openui)
+ (`@openuidev/react-lang`, `@openuidev/lang-core`).
+- The PullRequest card's commit graph, conflict marks and check chips follow
+ [@pitus_'s "Generative UI #2 – Merge conflicts"](https://x.com/pitus_/status/2111519352503873866).
+- The idea of brand-colored app cards in place of a bare "thinking" row comes
+ from Kevin Rajan ([@kvnloo](https://github.com/kvnloo)).
+- App card themes copy tokens from [Primer](https://primer.style) (MIT),
+ [Base Web](https://baseweb.design) (MIT) and Material 3; icons are
+ [Primer Octicons](https://primer.style/octicons) (MIT). Fonts are Google
+ Sans, Inter Tight and Mona Sans (SIL Open Font License).
+- Vehicle pictures in the ride card are generated illustrations, not Uber or
+ Lyft artwork. The demo maps connector's route comes from
+ [OSRM](https://project-osrm.org) and its map image from
+ [OpenStreetMap](https://www.openstreetmap.org/copyright) data (ODbL).
+
diff --git a/experiments/openui-lab/app-cards.test.ts b/experiments/openui-lab/app-cards.test.ts
new file mode 100644
index 0000000000000..a58a73a1adac4
--- /dev/null
+++ b/experiments/openui-lab/app-cards.test.ts
@@ -0,0 +1,92 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import {
+ OpenUiCharts,
+ inspectOpenUiChart,
+ openUiChartPrompt,
+} from "../../apps/web/src/components/chat/OpenUiCharts";
+
+const render = (code: string) => renderToStaticMarkup(createElement(OpenUiCharts, { code }));
+
+const ride = `root = Board("Ride to SFO", "UberX is the cheapest option.", [rides])
+rides = RideOptions("uber", "Ferry Building", "SFO Terminal 2", [{name: "UberX", minutes: 4, price: "$31.20", seats: 4}, {name: "Comfort", minutes: 6, price: "$38.90", seats: 4}, {name: "UberXL", minutes: 9, price: "$52.10", seats: 6}])`;
+const mail = `root = Board("Invoices", "Two unread invoices from Acme.", [mail])
+mail = MailList("invoices from Acme", [{from: "Acme Billing", subject: "Invoice #4512", snippet: "Your October invoice is ready.", time: "9:41 AM", unread: true}, {from: "Acme Billing", subject: "Invoice #4498", snippet: "September invoice attached.", time: "Oct 2", unread: false}])`;
+const route = `root = Board("To the Ferry Building", "About 18 minutes by car.", [route])
+route = Route("Home", "Ferry Building", "drive", 18, "6.2 mi", ["Head north on Valencia St", "Turn right onto Market St", "Arrive at Ferry Building"])`;
+const pr = (
+ state: string,
+ conflicts: number,
+ checks = '[{name: "Tests", status: "pass", detail: "vitest · 42 passed"}, {name: "Lint", status: "pass", detail: ""}, {name: "Types", status: "pass", detail: ""}]',
+) =>
+ `root = Board("PR #128", "Status of the auth PR.", [pr])\npr = PullRequest(128, "Refresh sessions in the auth middleware", "${state}", "feat/session-refresh", "main", ${checks}, ${conflicts})`;
+
+test("app cards parse, render their brand and supplied data", () => {
+ const cases: Array<[string, RegExp[]]> = [
+ [
+ ride,
+ [
+ /Uber ride options/,
+ /--ac-primary:#000000/,
+ /Ride to SFO Terminal 2/,
+ /Cheapest/,
+ /\$52\.10/,
+ /Choose UberX/,
+ /Nothing is booked from here/,
+ ],
+ ],
+ [
+ mail,
+ [
+ /Gmail results/,
+ /#EA4335/,
+ /invoices from Acme/,
+ /2 results/,
+ /Invoice #4512/,
+ /aria-label="Unread"|font-bold/,
+ ],
+ ],
+ [route, [/Directions from Home/, /18 min/, /6\.2 mi/, /Driving/, /Head north on Valencia St/]],
+ [pr("ready", 0), [/Pull request 128/, /feat\/session-refresh/, /All checks have passed/]],
+ [pr("conflict", 3), [/This branch has 3 conflicts that must be resolved/]],
+ [pr("merged", 0), [/>Merged, /--ac-done:#8250df/]],
+ ];
+ for (const [code, patterns] of cases) {
+ assert.deepEqual(inspectOpenUiChart(code), { kind: "ready" }, code.slice(0, 60));
+ const html = render(code);
+ for (const pattern of patterns) assert.match(html, pattern, `${code.slice(0, 40)} ${pattern}`);
+ assert.doesNotMatch(html, /