diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 71ffcd14a19a6..94b3b8e5e7cbf 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -124,6 +124,23 @@ const dmSansFonts = { bold: "@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf", } as const; +// OpenUI app cards draw each app in its own typeface (Google Sans, Inter Tight, Mona Sans; all OFL). +const appCardFonts = [ + ["GoogleSans-Regular", "google-sans/400Regular/GoogleSans_400Regular.ttf", 400], + ["GoogleSans-Medium", "google-sans/500Medium/GoogleSans_500Medium.ttf", 500], + ["GoogleSans-Bold", "google-sans/700Bold/GoogleSans_700Bold.ttf", 700], + ["InterTight-Regular", "inter-tight/400Regular/InterTight_400Regular.ttf", 400], + ["InterTight-SemiBold", "inter-tight/600SemiBold/InterTight_600SemiBold.ttf", 600], + ["InterTight-Bold", "inter-tight/700Bold/InterTight_700Bold.ttf", 700], + ["MonaSans-Regular", "mona-sans/400Regular/MonaSans_400Regular.ttf", 400], + ["MonaSans-SemiBold", "mona-sans/600SemiBold/MonaSans_600SemiBold.ttf", 600], + ["MonaSans-Bold", "mona-sans/700Bold/MonaSans_700Bold.ttf", 700], +].map(([fontFamily, file, weight]) => ({ + fontFamily: fontFamily as string, + path: `@expo-google-fonts/${file as string}`, + weight: weight as number, +})); + const widgetsPlugin: NonNullable[number] = [ "expo-widgets", { @@ -284,7 +301,10 @@ const config: ExpoConfig = { }, android: { icon: variant.assets.appIcon, - package: process.env.T3_OPENUI_LAB_BUILD === "1" ? "com.t3tools.t3code.openui" : variant.androidPackage, + package: + process.env.T3_OPENUI_LAB_BUILD === "1" + ? "com.t3tools.t3code.openui" + : variant.androidPackage, ...(repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE ? { googleServicesFile: repoEnv.T3CODE_ANDROID_GOOGLE_SERVICES_FILE } : {}), @@ -311,7 +331,12 @@ const config: ExpoConfig = { "expo-font", { ios: { - fonts: [dmSansFonts.regular, dmSansFonts.medium, dmSansFonts.bold], + fonts: [ + dmSansFonts.regular, + dmSansFonts.medium, + dmSansFonts.bold, + ...appCardFonts.map((font) => font.path), + ], }, android: { fonts: [ @@ -327,6 +352,10 @@ const config: ExpoConfig = { fontFamily: "DMSans-Bold", fontDefinitions: [{ path: dmSansFonts.bold, weight: 700 }], }, + ...appCardFonts.map(({ fontFamily, path, weight }) => ({ + fontFamily, + fontDefinitions: [{ path, weight }], + })), ], }, }, diff --git a/apps/mobile/assets/app-cards/car-black.webp b/apps/mobile/assets/app-cards/car-black.webp new file mode 100644 index 0000000000000..13e802381512a Binary files /dev/null and b/apps/mobile/assets/app-cards/car-black.webp differ diff --git a/apps/mobile/assets/app-cards/car-comfort.webp b/apps/mobile/assets/app-cards/car-comfort.webp new file mode 100644 index 0000000000000..ae0082eaaf120 Binary files /dev/null and b/apps/mobile/assets/app-cards/car-comfort.webp differ diff --git a/apps/mobile/assets/app-cards/car-x.webp b/apps/mobile/assets/app-cards/car-x.webp new file mode 100644 index 0000000000000..085ec04c23b96 Binary files /dev/null and b/apps/mobile/assets/app-cards/car-x.webp differ diff --git a/apps/mobile/assets/app-cards/car-xl.webp b/apps/mobile/assets/app-cards/car-xl.webp new file mode 100644 index 0000000000000..1bc155ad68e15 Binary files /dev/null and b/apps/mobile/assets/app-cards/car-xl.webp differ diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 497ed9a3e6189..b6a8c935dc38a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -55,6 +55,9 @@ "@clerk/expo-native-components": "0.4.0", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", + "@expo-google-fonts/google-sans": "^0.4.4", + "@expo-google-fonts/inter-tight": "^0.4.2", + "@expo-google-fonts/mona-sans": "^0.4.1", "@expo/metro-runtime": "~58.0.14", "@expo/ui": "58.0.20", "@legendapp/list": "catalog:", @@ -62,7 +65,6 @@ "@noble/curves": "catalog:", "@openuidev/lang-core": "1.0.2", "@openuidev/react-lang": "1.0.2", - "zod": "^4.6.5", "@pierre/diffs": "catalog:", "@pierre/highlights": "catalog:", "@react-native-ai/apple": "0.12.0", @@ -140,7 +142,8 @@ "remark-parse": "catalog:", "tailwind-merge": "^3.5.0", "unified": "catalog:", - "uniwind": "1.13.0" + "uniwind": "1.13.0", + "zod": "^4.6.5" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 71944a6fa054f..3fff6e94fed1c 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -79,10 +79,13 @@ import { shouldFollowThreadWorkGroupAppend, type ThreadWorkGroupScrollPosition, } from "./thread-feed-live-follow"; +import type { AppBrand } from "@t3tools/client-runtime/app-connectors"; import { + resolveWorkEntryAppActivity, resolveWorkEntryToolPresentation, toolGroupAction, type ToolGroupSummaryKind, + workEntryAppActivityLabel, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; @@ -200,6 +203,24 @@ export function ThreadDisclosureChevron(props: { ); } +/** Brand monogram for tools from a known app (Gmail, Uber, Maps, ...); no logos are bundled. */ +function AppActivityMark({ brand }: { readonly brand: AppBrand }) { + return ( + + 1 ? 7 : 10, lineHeight: 12 }} + > + {brand.mark} + + + ); +} + function ShimmerWorkContent(props: { readonly textClassName?: string; readonly compact?: boolean; @@ -1007,12 +1028,15 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( ); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); - const previewText = row.groupStandIn?.label ?? workEntryRowLabel(row.workEntry); + const appActivity = row.groupStandIn ? null : resolveWorkEntryAppActivity(row.workEntry); + const appLabel = appActivity ? workEntryAppActivityLabel(row.workEntry) : null; + const previewText = row.groupStandIn?.label ?? appLabel ?? workEntryRowLabel(row.workEntry); const answerPreview = row.workEntry.questionAnswer ? getQuestionAnswerPreview(row.workEntry.questionAnswer) : null; const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": "); - const displayText = row.groupStandIn?.label ?? workEntryRowLabel(row.workEntry, expanded); + const displayText = + row.groupStandIn?.label ?? appLabel ?? workEntryRowLabel(row.workEntry, expanded); const isSystemNotice = row.projectedItem.item.type === "system_notice"; const isUsageLimit = row.projectedItem.item.type === "error" && @@ -1058,7 +1082,7 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( }} onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} > - {row.live && !expanded ? ( + {row.live && !expanded && !appActivity ? ( - {toolIcon ? ( + {appActivity ? ( + + ) : toolIcon ? ( > = { + "google-sans": { + regular: "GoogleSans-Regular", + medium: "GoogleSans-Medium", + bold: "GoogleSans-Bold", + }, + "inter-tight": { + regular: "InterTight-Regular", + medium: "InterTight-SemiBold", + bold: "InterTight-Bold", + }, + "mona-sans": { regular: "MonaSans-Regular", medium: "MonaSans-SemiBold", bold: "MonaSans-Bold" }, +}; +const MONO = "monospace"; + +type Weight = "regular" | "medium" | "bold"; + +function useCardTheme(app: AppBrandId) { + return appCardTheme(app, useColorScheme() === "dark" ? "dark" : "light"); +} + +/** Text in the card's app font and colour. */ +function T({ + theme, + weight = "regular", + size = 14, + color, + style, + lines, + children, +}: { + theme: AppCardTheme; + weight?: Weight; + size?: number; + color?: string; + style?: TextStyle; + lines?: number; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +const ENTER = FadeInDown.duration(260).reduceMotion(ReduceMotion.System); + +function AppCard({ + theme, + label, + children, +}: { + theme: AppCardTheme; + label: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +// The brand's monogram tile; T3 Code bundles no logos (docs/user/openui-reports.md). +function BrandTile({ brand }: { brand: AppBrand }) { + return ( + + 1 ? 10 : 13, + fontWeight: "700", + }} + > + {brand.mark} + + + ); +} + +function Octicon({ icon, color, size = 16 }: { icon: OcticonName; color: string; size?: number }) { + return ( + + {OCTICONS[icon].map((d) => ( + + ))} + + ); +} + +function PillButton({ + theme, + label, + url, + tone, +}: { + theme: AppCardTheme; + label: string; + url: string; + tone: "primary" | "container" | "outline"; +}) { + const fill = + tone === "primary" ? theme.primary : tone === "container" ? theme.container : theme.surfaceAlt; + const ink = + tone === "primary" + ? theme.onPrimary + : tone === "container" + ? theme.onContainer + : theme.onSurface; + return ( + void Linking.openURL(url)} + style={({ pressed }) => ({ + height: 34, + paddingHorizontal: 14, + borderRadius: 999, + alignItems: "center", + justifyContent: "center", + backgroundColor: fill, + borderWidth: tone === "outline" ? 1 : 0, + borderColor: theme.outline, + opacity: pressed ? 0.8 : 1, + })} + > + + {label} + + + ); +} + +function Footer({ theme, children }: { theme: AppCardTheme; children: ReactNode }) { + return ( + + {children} + + ); +} + +const CARS: Record = { + x: require("../../assets/app-cards/car-x.webp"), + comfort: require("../../assets/app-cards/car-comfort.webp"), + xl: require("../../assets/app-cards/car-xl.webp"), + black: require("../../assets/app-cards/car-black.webp"), +}; +const RIDE_LINKS = { uber: "https://m.uber.com/", lyft: "https://ride.lyft.com/" } as const; + +/** Uber's product picker: vehicle renders, a grey selected row with a black outline, one black button. */ +const RideOptionsCard = memo(function RideOptionsCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS[props.app]; + const theme = useCardTheme(props.app); + const best = cheapestRide(props.options); + const [selected, setSelected] = useState(() => + Math.max( + 0, + props.options.findIndex((option) => option.name === best), + ), + ); + const chosen = props.options[selected] ?? props.options[0]!; + return ( + + + + + + {`Ride to ${props.dropoff}`} + + + {`from ${props.pickup}`} + + + + + {props.options.map((option, index) => { + const active = selected === index; + return ( + setSelected(index)} + style={{ + flexDirection: "row", + alignItems: "center", + gap: 10, + height: 64, + paddingRight: 12, + borderRadius: 12, + borderWidth: 2, + borderColor: active ? theme.onSurface : "transparent", + backgroundColor: active ? theme.surfaceAlt : "transparent", + }} + > + + + + + {option.name} + + {`👤 ${option.seats}`} + {option.name === best ? ( + + Cheapest + + ) : null} + + + {`${option.arrival ? `${option.arrival} · ` : ""}${option.minutes} min away`} + + + + {option.price} + + + ); + })} + + + void Linking.openURL(RIDE_LINKS[props.app])} + style={({ pressed }) => ({ + height: 48, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.primary, + opacity: pressed ? 0.85 : 1, + })} + > + + {`Choose ${chosen.name}`} + + + + {`Opens ${brand.name} to confirm. Nothing is booked from here.`} + + + + ); +}); + +/** Gmail's list: avatars, bold unread rows, read rows on the tinted surface, attachment chips. */ +const MailListCard = memo(function MailListCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS.gmail; + const theme = useCardTheme("gmail"); + const [open, setOpen] = useState(null); + return ( + + + + + + {props.query} + + + {`${props.messages.length} result${props.messages.length === 1 ? "" : "s"}`} + + + + {props.messages.length === 0 ? ( + + No messages matched. + + ) : null} + {props.messages.map((message, index) => { + const isOpen = open === index; + const weight = message.unread ? "bold" : "regular"; + const ink = isOpen ? theme.onContainer : theme.onSurface; + return ( + setOpen(isOpen ? null : index)} + style={{ + flexDirection: "row", + gap: 12, + paddingHorizontal: 14, + paddingVertical: 10, + borderTopWidth: 1, + borderTopColor: theme.outline, + backgroundColor: isOpen + ? theme.container + : message.unread + ? theme.surface + : theme.surfaceAlt, + }} + > + + + {message.from.trim().charAt(0).toUpperCase() || "?"} + + + + + + {message.from} + + + {message.time} + + + + {message.subject} + + + {message.snippet} + + {message.attachment ? ( + + + {message.attachment.split(".").pop()?.toUpperCase().slice(0, 4) ?? "FILE"} + + + {message.attachment} + + + ) : null} + + + ); + })} +
+ +
+
+ ); +}); + +const MODE_LABEL = { + drive: "Driving", + walk: "Walking", + transit: "Transit", + bike: "Cycling", +} as const; +const MAPS_MODE = { + drive: "driving", + walk: "walking", + transit: "transit", + bike: "bicycling", +} as const; +const MAP_W = 400; +const MAP_H = 200; + +/** The tool's route over the tool's basemap, in Google's map colours. Only real geometry. */ +function RouteMap({ + path, + map, + scheme, +}: { + path: ReadonlyArray; + map: z.infer["map"]; + scheme: "light" | "dark"; +}) { + const paint = MAP_PAINT[scheme]; + const { points, d } = projectPath(path, MAP_W, MAP_H, 22, map?.bounds); + const start = points[0]!; + const end = points.at(-1)!; + return ( + + + {map ? ( + + ) : null} + {/* Basemaps come light; dark mode dims them under Google's night land colour. */} + {map && scheme === "dark" ? ( + + ) : null} + + + + + + + + + + ); +} + +/** Google Maps' directions panel: travel time in traffic green, the route, then one step at a time. */ +const RouteCard = memo(function RouteCard({ props }: { props: z.infer }) { + const brand = APP_BRANDS["google-maps"]; + const scheme = useColorScheme() === "dark" ? "dark" : "light"; + const theme = appCardTheme("google-maps", scheme); + const [step, setStep] = useState(0); + const total = props.steps.length; + const query = `origin=${encodeURIComponent(props.origin)}&destination=${encodeURIComponent(props.destination)}&travelmode=${MAPS_MODE[props.mode]}`; + return ( + + + + + {`${props.origin} → ${props.destination}`} + + + + + + {MODE_LABEL[props.mode]} + + + {`${props.minutes} min`} + + {`${props.distance}${props.via ? ` · via ${props.via}` : ""}`} + + + {props.path ? : null} + {total ? ( + + + + {step + 1} + + + + {props.steps[step]} + + setStep(step - 1)} + hitSlop={6} + style={{ opacity: step === 0 ? 0.35 : 1, padding: 4 }} + > + + ‹ + + + {`${step + 1} / ${total}`} + setStep(step + 1)} + hitSlop={6} + style={{ opacity: step === total - 1 ? 0.35 : 1, padding: 4 }} + > + + › + + + + ) : null} +
+ + +
+
+ ); +}); + +const STATE = { + open: { label: "Open", icon: "git-pull-request" }, + conflict: { label: "Open", icon: "git-pull-request" }, + ready: { label: "Open", icon: "git-pull-request" }, + merged: { label: "Merged", icon: "git-merge" }, + closed: { label: "Closed", icon: "git-pull-request-closed" }, +} as const satisfies Record; +const STATUS_ICON = { + pass: "check-circle-fill", + fail: "x-circle-fill", + running: "dot-fill", + skip: "skip", +} as const satisfies Record; +const ROW = 34; + +function statusTone(theme: AppCardTheme, status: "pass" | "fail" | "running" | "skip") { + return status === "pass" + ? theme.success + : status === "fail" + ? theme.danger + : status === "running" + ? theme.warning + : theme.muted; +} + +function CommitGraph({ + theme, + commits, +}: { + theme: AppCardTheme; + commits: NonNullable["commits"]>; +}) { + const laneColor = { base: theme.muted, head: theme.accent, side: theme.success } as const; + const height = commits.length * ROW; + const ys = commits.map((_, index) => index * ROW + ROW / 2); + const lanes = commits.map((commit) => commit.lane); + const baseLast = lanes.lastIndexOf("base"); + const paths: ReactNode[] = []; + for (const lane of ["base", "head", "side"] as const) { + const first = lanes.indexOf(lane); + const last = lanes.lastIndexOf(lane); + if (first < 0) continue; + const x = commitLaneX(lane); + paths.push( + , + ); + if (lane !== "base" && baseLast > last) { + const y1 = ys[last]!; + const y2 = ys[baseLast]!; + const bx = commitLaneX("base"); + paths.push( + , + ); + } + } + return ( + + {paths} + {commits.map((commit, index) => ( + + ))} + + ); +} + +/** GitHub's pull request in Primer: state label, commit graph, checks as a timeline, merge box line. */ +const PullRequestCard = memo(function PullRequestCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS.github; + const theme = useCardTheme("github"); + const state = STATE[props.state]; + const stateTone = + props.state === "merged" ? theme.done : props.state === "closed" ? theme.danger : theme.success; + const counts = checkCounts(props.checks); + const timeline = checkTimeline(props.checks); + const [open, setOpen] = useState(() => { + const failed = props.checks.findIndex((check) => check.status === "fail" && check.detail); + return failed < 0 ? null : failed; + }); + const commits = props.commits ?? []; + const plural = props.conflicts === 1 ? "" : "s"; + const foot = + props.state === "conflict" + ? { + icon: "alert", + tone: theme.warning, + title: `This branch has ${props.conflicts} conflict${plural} that must be resolved`, + sub: `Conflicts with ${props.base}`, + } + : props.state === "ready" + ? { + icon: "check", + tone: theme.success, + title: "All checks have passed", + sub: "No conflicts with the base branch", + } + : props.state === "merged" + ? { + icon: "git-merge", + tone: theme.done, + title: "Pull request successfully merged and closed", + sub: `Merged into ${props.base}`, + } + : props.state === "closed" + ? { + icon: "git-pull-request-closed", + tone: theme.danger, + title: "Closed with unmerged commits", + sub: `${props.head} was not merged`, + } + : counts.fail + ? { + icon: "x-circle-fill", + tone: theme.danger, + title: "Some checks were not successful", + sub: `${counts.fail} failing, ${counts.pass} successful`, + } + : { + icon: "dot-fill", + tone: theme.warning, + title: "Some checks haven't completed yet", + sub: `${counts.running} in progress, ${counts.pass} successful`, + }; + const chip = (label: string) => ( + + {label} + + ); + return ( + + + + + + {props.title} + {` #${props.number}`} + + + + + + {state.label} + + + {chip(props.head)} + + → + + {chip(props.base)} + + + + {commits.length ? ( + + {commits.map((commit) => ( + + + {commit.title} + + + {commit.sha.slice(0, 7)} + + + ))} + + + ) : null} + {props.checks.length ? ( + + {props.checks.map((check, index) => { + const tone = statusTone(theme, check.status); + const span = timeline?.spans[index]; + return ( + + setOpen(open === index ? null : index)} + style={{ + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 14, + paddingVertical: 6, + }} + > + + + {check.name} + + + {span ? ( + + ) : null} + + + {check.seconds !== undefined ? formatSeconds(check.seconds) : ""} + + + {check.detail && open === index ? ( + + {check.detail} + + ) : null} + + ); + })} + + ) : null} + + + + + + + {foot.title} + + + {foot.sub} + + + + + + ); +}); + +export const RideOptions = defineComponent({ + name: "RideOptions", + description: DESCRIPTIONS.RideOptions, + props: rideOptionsProps, + component: RideOptionsCard, +}); +export const MailList = defineComponent({ + name: "MailList", + description: DESCRIPTIONS.MailList, + props: mailListProps, + component: MailListCard, +}); +export const Route = defineComponent({ + name: "Route", + description: DESCRIPTIONS.Route, + props: routeProps, + component: RouteCard, +}); +export const PullRequest = defineComponent({ + name: "PullRequest", + description: DESCRIPTIONS.PullRequest, + props: pullRequestProps, + component: PullRequestCard, +}); + +export const appCardSchemas = { + RideOptions: (value: unknown) => rideOptionsProps.safeParse(value), + MailList: (value: unknown) => mailListProps.safeParse(value), + Route: (value: unknown) => routeProps.safeParse(value), + PullRequest: (value: unknown) => pullRequestProps.safeParse(value), +}; diff --git a/apps/mobile/src/native/OpenUiCharts.tsx b/apps/mobile/src/native/OpenUiCharts.tsx index 89e8da8243822..c6024c62a7e7e 100644 --- a/apps/mobile/src/native/OpenUiCharts.tsx +++ b/apps/mobile/src/native/OpenUiCharts.tsx @@ -27,6 +27,7 @@ import { } from "@t3tools/client-runtime/openui-charts"; import { AppText } from "../components/ui/AppText"; +import { MailList, PullRequest, RideOptions, Route, appCardSchemas } from "./OpenUiAppCards"; // Catalog v2: the shared definition in client-runtime bound to this app's zod and OpenUI. const title = z.string().max(LIMITS.title); @@ -274,7 +275,7 @@ const Board = defineComponent({ props: z.object({ title, summary: z.string().max(LIMITS.summary), - panels: z.array(z.union([Treemap.ref, BarChart.ref, StackedArea.ref, Heatmap.ref])).min(1).max(LIMITS.panels), + panels: z.array(z.union([Treemap.ref, BarChart.ref, StackedArea.ref, Heatmap.ref, RideOptions.ref, MailList.ref, Route.ref, PullRequest.ref])).min(1).max(LIMITS.panels), }), component: function NativeBoard({ props, renderNode }) { return ( @@ -289,12 +290,13 @@ const Board = defineComponent({ }, }); -const library = createLibrary({ components: [Board, Treemap, BarChart, StackedArea, Heatmap], root: "Board" }); +const library = createLibrary({ components: [Board, Treemap, BarChart, StackedArea, Heatmap, RideOptions, MailList, Route, PullRequest], root: "Board" }); const panelSchemas = { Treemap: (value: unknown) => treemapProps.safeParse(value), BarChart: (value: unknown) => barChartProps.safeParse(value), StackedArea: (value: unknown) => stackedAreaProps.safeParse(value), Heatmap: (value: unknown) => heatmapProps.safeParse(value), + ...appCardSchemas, }; export function inspectOpenUiChart(code: string) { diff --git a/apps/server/scripts/dev-replay-agent.ts b/apps/server/scripts/dev-replay-agent.ts index b6eea893ad6b7..d2a088c9d3ff7 100644 --- a/apps/server/scripts/dev-replay-agent.ts +++ b/apps/server/scripts/dev-replay-agent.ts @@ -44,6 +44,8 @@ const FIXTURES = "../src/orchestration-v2/testkit/fixtures"; // Use acpRegistry transcripts: vendor transcripts carry state in frames this agent drops. export const SCENARIOS = [ { id: "showcase", name: "Showcase", file: "./dev-replay/showcase_transcript.ndjson" }, + // Four demo connector calls, then an OpenUI board of app cards (experiments/openui-lab). + { id: "app-cards", name: "App cards", file: "./dev-replay/app_cards_transcript.ndjson" }, { id: "todo-list", name: "Plan and reads", diff --git a/apps/server/scripts/dev-replay/app_cards_transcript.ndjson b/apps/server/scripts/dev-replay/app_cards_transcript.ndjson new file mode 100644 index 0000000000000..59ccade850e3b --- /dev/null +++ b/apps/server/scripts/dev-replay/app_cards_transcript.ndjson @@ -0,0 +1,13 @@ +{"type":"transcript_start","provider":"acpRegistry","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"dev_replay_app_cards","metadata":{"generatedBy":"hand-written for the dev replay agent: four demo connector calls, then an OpenUI app card board"}} +{"type":"expect_outbound","frame":{"kind":"request","method":"session/prompt","params":""}} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Four apps to check: Gmail for the invoice, Uber for prices, Maps for the route, GitHub for the PR. They are independent, so I will call them together and answer with one board."}}}}} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call","toolCallId":"cards-gmail","kind":"other","title":"mcp__gmail__search_messages","status":"in_progress","rawInput":{"query":"invoices from Acme"}}}}} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call","toolCallId":"cards-uber","kind":"other","title":"mcp__uber__get_price_estimates","status":"in_progress","rawInput":{"pickup":"Ferry Building","dropoff":"SFO Terminal 2"}}}},"afterMs":350} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call","toolCallId":"cards-maps","kind":"other","title":"mcp__google-maps__get_directions","status":"in_progress","rawInput":{"origin":"Ferry Building","destination":"SFO Terminal 2","mode":"drive"}}}},"afterMs":350} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call","toolCallId":"cards-github","kind":"other","title":"mcp__github__get_pull_request","status":"in_progress","rawInput":{"number":128}}}},"afterMs":350} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"cards-gmail","status":"completed","content":[{"type":"content","content":{"type":"text","text":"2 messages from Acme Billing and Dana Ruiz (demo data)."}}]}}},"afterMs":500} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"cards-uber","status":"completed","content":[{"type":"content","content":{"type":"text","text":"UberX $38.40, Comfort $46.10, UberXL $61.75 (demo data)."}}]}}},"afterMs":750} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"cards-maps","status":"completed","content":[{"type":"content","content":{"type":"text","text":"22 min, 14.5 mi via I-280 S and US-101 S (demo data)."}}]}}},"afterMs":1000} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"cards-github","status":"completed","content":[{"type":"content","content":{"type":"text","text":"PR #128: 2 conflicts with main, Lint failing (demo data)."}}]}}},"afterMs":1250} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Here's Thursday at a glance, from your connected apps.\n\n```openui-v2\nroot = Board(\"Thursday, before SFO\", \"From Gmail, Uber, Google Maps and GitHub. Demo data.\", [mail, ride, route, pr])\nmail = 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}])\nride = 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\"}])\nroute = 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\", [[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]], {image: \"https://raw.githubusercontent.com/kvnloo/t3code/media/openui-app-cards/maps/ferry-sfo-map.png\", bounds: [37.597565, -122.664055, 37.810018, -122.127003]})\npr = 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\"}])\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."}}}}} +{"type":"emit_inbound","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}} diff --git a/apps/web/package.json b/apps/web/package.json index b1a2f4c25ad5f..198c5354439d3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,7 +26,10 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", + "@fontsource-variable/google-sans": "^5.3.2", "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/inter-tight": "^5.3.0", + "@fontsource-variable/mona-sans": "^5.3.0", "@legendapp/list": "catalog:", "@noble/hashes": "catalog:", "@openuidev/lang-core": "1.0.2", diff --git a/apps/web/src/assets/app-cards/car-black.webp b/apps/web/src/assets/app-cards/car-black.webp new file mode 100644 index 0000000000000..13e802381512a Binary files /dev/null and b/apps/web/src/assets/app-cards/car-black.webp differ diff --git a/apps/web/src/assets/app-cards/car-comfort.webp b/apps/web/src/assets/app-cards/car-comfort.webp new file mode 100644 index 0000000000000..ae0082eaaf120 Binary files /dev/null and b/apps/web/src/assets/app-cards/car-comfort.webp differ diff --git a/apps/web/src/assets/app-cards/car-x.webp b/apps/web/src/assets/app-cards/car-x.webp new file mode 100644 index 0000000000000..085ec04c23b96 Binary files /dev/null and b/apps/web/src/assets/app-cards/car-x.webp differ diff --git a/apps/web/src/assets/app-cards/car-xl.webp b/apps/web/src/assets/app-cards/car-xl.webp new file mode 100644 index 0000000000000..1bc155ad68e15 Binary files /dev/null and b/apps/web/src/assets/app-cards/car-xl.webp differ diff --git a/apps/web/src/components/chat/MarkdownOpenUiCodeBlock.tsx b/apps/web/src/components/chat/MarkdownOpenUiCodeBlock.tsx index 1d9efd7abb7c6..177af7d019701 100644 --- a/apps/web/src/components/chat/MarkdownOpenUiCodeBlock.tsx +++ b/apps/web/src/components/chat/MarkdownOpenUiCodeBlock.tsx @@ -5,11 +5,11 @@ import { Button } from "../ui/button"; const Report = lazy(() => import("./OpenUiReport").then((module) => ({ default: module.OpenUiReport }))); const Charts = lazy(() => import("./OpenUiCharts").then((module) => ({ default: module.OpenUiCharts }))); -/** The versioned fence stays in the normal transcript; v1 reports render on request, v2 boards once complete. */ +/** The versioned fence stays in the normal transcript; v1 reports render on request, v2 boards as they stream. */ export function MarkdownOpenUiCodeBlock({ code, version, isStreaming }: { code: string; version: 1 | 2; isStreaming: boolean }) { - // Chart boards are the answer itself, so they render once the fence completes unless the reader chose source. + // Chart boards are the answer itself, so they render card by card while streaming unless the reader chose source. const [choice, setChoice] = useState(null); - const showReport = choice ?? (version === 2 && !isStreaming); + const showReport = choice ?? version === 2; const Renderer = version === 2 ? Charts : Report; const noun = version === 2 ? "chart" : "report"; const source =
{code}
; @@ -24,7 +24,7 @@ export function MarkdownOpenUiCodeBlock({ code, version, isStreaming }: { code: {showReport ? (

{version === 2 ? "Chart" : "Report"} unavailable. Original source:

{source}}> Loading {noun} renderer…

}> - +
) : source} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c26bcf51a718a..af15dd3f7d61c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -22,6 +22,7 @@ import { liveActivityToolStatus, normalizeCompactToolLabel, resolveWorkEntryToolPresentation, + workEntryAppActivityLabel, summarizeToolGroup, toolGroupAction, toolGroupSummaryKind, @@ -95,6 +96,8 @@ function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { function singleToolCallLabel(entry: WorkLogEntry): string { if (entry.itemType === "reasoning") return entry.detail?.trim().replace(/\s+/g, " ") || "Thought"; + const appLabel = workEntryAppActivityLabel(entry, entry.toolLifecycleStatus ?? "completed"); + if (appLabel) return appLabel; const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); if (toolPresentation) return toolPresentation.displayName; const item = entry.structuredPayload; @@ -136,6 +139,8 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string const thought = entry.detail?.trim().replace(/\s+/g, " "); return thought || entry.label; } + const appLabel = workEntryAppActivityLabel(entry); + if (appLabel) return appLabel; const toolPresentation = resolveWorkEntryToolPresentation(entry); if (toolPresentation) return toolPresentation.displayName; if (entry.command?.trim()) return commandDisplayText(entry.command); @@ -242,6 +247,8 @@ export function liveWorkEntryLabel( (status === "inProgress" ? "Thinking" : "Thought") ); } + const appLabel = workEntryAppActivityLabel(entry, status); + if (appLabel) return appLabel; const toolPresentation = resolveWorkEntryToolPresentation({ ...entry, toolLifecycleStatus: status, diff --git a/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx b/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx new file mode 100644 index 0000000000000..06f69be0921df --- /dev/null +++ b/apps/web/src/components/chat/OpenUiAppCards.browser.test.tsx @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; +import { APP_CARDS_BOARD as BOARD } from "../../../test/fixtures/appCardsBoard"; +import { setAppearanceMode } from "~/hooks/useTheme"; +import { render } from "../../../test/render"; +import { OpenUiCharts } from "./OpenUiCharts"; + +// The real Ferry Building to SFO driving route (OSRM over OpenStreetMap), as a maps tool returns it. + +const shot = import.meta.env.VITE_APP_CARDS_SHOT; +// Entry transitions and the basemap finish before a screenshot. +const settle = async () => { + await Promise.all(document.getAnimations().map((animation) => animation.finished)); + await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined))); +}; + +describe("OpenUI app cards", () => { + it("renders every card and keeps taps local to the card", async () => { + await render( +
+ +
, + ); + + await expect.element(page.getByText("INV-2041.pdf", { exact: false })).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: /^Dana Ruiz/ })); + await expect + .element(page.getByRole("button", { name: /^Dana Ruiz/ })) + .toHaveAttribute("aria-expanded", "true"); + + await expect.element(page.getByRole("link", { name: "Choose UberX" })).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: /^Comfort/ })); + await expect.element(page.getByRole("link", { name: "Choose Comfort" })).toBeVisible(); + + const map = page.getByRole("region", { name: /^Directions from Ferry Building/ }); + // The route is drawn from the tool's geometry: one point per coordinate it returned. + const routePath = map + .element() + .querySelector('svg[viewBox="0 0 400 200"] path')! + .getAttribute("d")!; + expect(routePath.split("L")).toHaveLength(36); + await expect.element(page.getByText("22 min")).toBeVisible(); + await expect.element(page.getByText(/via I-280 S and US-101 S/)).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "Next step" })); + await expect + .element(page.getByRole("status").filter({ hasText: "Turn left onto Mission St" })) + .toBeVisible(); + + await expect.element(page.getByText("Rename picker tokens")).toBeVisible(); + await expect + .element(page.getByText("This branch has 2 conflicts that must be resolved")) + .toBeVisible(); + // The failing check opens with its log line. + await expect + .element(page.getByRole("status").filter({ hasText: "src/picker.ts:14 no-unused-vars" })) + .toBeVisible(); + await expect.element(page.getByText("38s")).toBeVisible(); + + if (shot) { + await page.viewport(720, 2000); + await settle(); + await page + .getByRole("region", { name: "Your apps" }) + .screenshot({ path: "app-cards-light.png" }); + } + }); + + it("fits a phone-width thread without overflow", async () => { + await page.viewport(390, 900); + await render( +
+ +
, + ); + await expect.element(page.getByRole("link", { name: "Choose UberX" })).toBeVisible(); + for (const card of document.querySelectorAll("[data-app-card]")) { + expect(card.scrollWidth, card.dataset.appCard).toBeLessThanOrEqual(card.clientWidth + 1); + } + if (shot) { + await page.viewport(390, 2400); + await settle(); + await page + .getByRole("region", { name: "Your apps" }) + .last() + .screenshot({ path: "app-cards-phone.png" }); + } + }); + + it("switches each card to its app's dark theme", async () => { + setAppearanceMode("dark"); + try { + await page.viewport(720, 900); + await render( +
+ +
, + ); + const pr = page.getByRole("region", { name: "Pull request 128, conflict" }).last(); + // Primer dark canvas. + await expect.element(pr).toHaveStyle({ backgroundColor: "rgb(13, 17, 23)" }); + if (shot) { + await page.viewport(720, 2000); + await settle(); + await page + .getByRole("region", { name: "Your apps" }) + .last() + .screenshot({ path: "app-cards-dark.png" }); + } + } finally { + setAppearanceMode("light"); + } + }); + + it("fills a streaming board in card by card", async () => { + const lines = BOARD.split("\n"); + // Root, mail and half of the ride statement have arrived. + const partial = `${lines.slice(0, 2).join("\n")}\n${lines[2]!.slice(0, 40)}`; + const screen = await render( +
+ +
, + ); + await expect.element(screen.getByText("INV-2041.pdf", { exact: false })).toBeVisible(); + expect(screen.container.querySelectorAll("[data-app-card]")).toHaveLength(1); + await screen.rerender( +
+ +
, + ); + await vi.waitFor(() => + expect(screen.container.querySelectorAll("[data-app-card]")).toHaveLength(4), + ); + }); +}); diff --git a/apps/web/src/components/chat/OpenUiAppCards.tsx b/apps/web/src/components/chat/OpenUiAppCards.tsx new file mode 100644 index 0000000000000..9cc862b56a15f --- /dev/null +++ b/apps/web/src/components/chat/OpenUiAppCards.tsx @@ -0,0 +1,1068 @@ +import "@fontsource-variable/google-sans"; +import "@fontsource-variable/inter-tight"; +import "@fontsource-variable/mona-sans"; + +import { defineComponent } from "@openuidev/react-lang"; +import { memo, useState, type CSSProperties, type ReactNode } from "react"; +import { z } from "zod/v4"; + +import { APP_BRANDS, type AppBrand, type AppBrandId } from "@t3tools/client-runtime/app-connectors"; +import { + MAP_PAINT, + appCardTheme, + projectPath, + type AppCardFont, + type AppCardTheme, +} from "@t3tools/client-runtime/app-card-themes"; +import { OCTICONS, type OcticonName } from "@t3tools/client-runtime/octicons"; +import { + APP_CARD_DESCRIPTIONS as DESCRIPTIONS, + APP_CARD_LIMITS as LIMITS, + avatarColor, + carClass, + cheapestRide, + checkCounts, + checkTimeline, + commitLaneX, + formatSeconds, + rideReadout, + type CarClass, +} from "@t3tools/client-runtime/openui-app-cards"; + +import carBlack from "~/assets/app-cards/car-black.webp"; +import carComfort from "~/assets/app-cards/car-comfort.webp"; +import carX from "~/assets/app-cards/car-x.webp"; +import carXl from "~/assets/app-cards/car-xl.webp"; +import { useRenderedAppearance } from "~/hooks/useTheme"; + +const text = z.string().max(LIMITS.text); +const name = z.string().min(1).max(LIMITS.text); +const minutes = z.number().finite().min(0).max(LIMITS.minutes); +const latitude = z.number().finite().min(-90).max(90); +const longitude = z.number().finite().min(-180).max(180); +const seconds = z + .number() + .finite() + .min(0) + .max(LIMITS.minutes * 60); + +export const rideOptionsProps = z.object({ + app: z.enum(["uber", "lyft"]), + pickup: name, + dropoff: name, + options: z + .array( + z.object({ + name, + minutes, + price: z.string().min(1).max(LIMITS.price), + seats: z.number().int().min(1).max(20), + arrival: text.optional(), + }), + ) + .min(1) + .max(LIMITS.rideOptions), +}); +export const mailListProps = z.object({ + query: text, + messages: z + .array( + z.object({ + from: name, + subject: text, + snippet: z.string().max(LIMITS.snippet), + time: text, + unread: z.boolean(), + attachment: text.optional(), + }), + ) + .max(LIMITS.messages), +}); +export const routeProps = z.object({ + origin: name, + destination: name, + mode: z.enum(["drive", "walk", "transit", "bike"]), + minutes, + distance: text, + steps: z.array(name).max(LIMITS.steps), + via: text.optional(), + path: z + .array(z.tuple([latitude, longitude])) + .min(2) + .max(LIMITS.pathPoints) + .optional(), + map: z + .object({ + image: z.url({ protocol: /^https?$/ }).max(LIMITS.url), + bounds: z.tuple([latitude, longitude, latitude, longitude]), + }) + .optional(), +}); +export const pullRequestProps = z.object({ + number: z.number().int().min(1), + title: name, + state: z.enum(["open", "conflict", "ready", "merged", "closed"]), + head: name, + base: name, + checks: z + .array( + z.object({ + name, + status: z.enum(["pass", "fail", "running", "skip"]), + detail: text, + started: seconds.optional(), + seconds: seconds.optional(), + }), + ) + .max(LIMITS.checks), + conflicts: z.number().int().min(0).max(10_000), + commits: z + .array( + z.object({ + title: name, + sha: z.string().min(4).max(12), + author: name, + age: text, + lane: z.enum(["base", "head", "side"]), + branch: text.optional(), + conflict: z.boolean().optional(), + }), + ) + .max(LIMITS.commits) + .optional(), +}); + +const FONT_STACK: Record = { + "google-sans": '"Google Sans Variable", "Google Sans", Roboto, Arial, sans-serif', + "inter-tight": '"Inter Tight Variable", "Inter Variable", "Helvetica Neue", Arial, sans-serif', + "mona-sans": + '"Mona Sans Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", sans-serif', +}; +const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'; + +/** Theme tokens as CSS variables, so every element below reads var(--ac-…). */ +function themeVars(theme: AppCardTheme): CSSProperties { + return { + "--ac-surface": theme.surface, + "--ac-surface-alt": theme.surfaceAlt, + "--ac-on-surface": theme.onSurface, + "--ac-muted": theme.muted, + "--ac-outline": theme.outline, + "--ac-primary": theme.primary, + "--ac-on-primary": theme.onPrimary, + "--ac-container": theme.container, + "--ac-on-container": theme.onContainer, + "--ac-accent": theme.accent, + "--ac-success": theme.success, + "--ac-warning": theme.warning, + "--ac-warning-surface": theme.warningSurface, + "--ac-danger": theme.danger, + "--ac-done": theme.done, + "--ac-radius": `${theme.radius}px`, + fontFamily: FONT_STACK[theme.font], + color: theme.onSurface, + backgroundColor: theme.surface, + } as CSSProperties; +} + +function useCardTheme(app: AppBrandId) { + return appCardTheme(app, useRenderedAppearance() === "dark" ? "dark" : "light"); +} + +// The brand's monogram tile; T3 Code bundles no logos (docs/user/openui-reports.md). +function BrandTile({ brand }: { brand: AppBrand }) { + return ( + 1 ? 10 : 13, + }} + > + {brand.mark} + + ); +} + +function AppCard({ + app, + label, + children, +}: { + app: AppBrandId; + label: string; + children: ReactNode; +}) { + const theme = useCardTheme(app); + return ( +
+ {children} +
+ ); +} + +function Octicon({ + name: icon, + className, + style, +}: { + name: OcticonName; + className?: string; + style?: CSSProperties; +}) { + return ( + + {OCTICONS[icon].map((d) => ( + + ))} + + ); +} + +function ExternalButton({ + href, + children, + tone, +}: { + href: string; + children: ReactNode; + tone: "primary" | "container" | "outline"; +}) { + const style: CSSProperties = + tone === "primary" + ? { backgroundColor: "var(--ac-primary)", color: "var(--ac-on-primary)" } + : tone === "container" + ? { backgroundColor: "var(--ac-container)", color: "var(--ac-on-container)" } + : { + borderColor: "var(--ac-outline)", + color: "var(--ac-on-surface)", + backgroundColor: "var(--ac-surface-alt)", + }; + return ( + + {children} + + ); +} + +const CARS: Record = { x: carX, comfort: carComfort, xl: carXl, black: carBlack }; +const RIDE_LINKS = { uber: "https://m.uber.com/", lyft: "https://ride.lyft.com/" } as const; +const ROW_HEIGHT = 64; + +/** Uber's product picker: vehicle renders, a grey selected row with a black outline that slides, one black button. */ +const RideOptionsCard = memo(function RideOptionsCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS[props.app]; + const best = cheapestRide(props.options); + const [selected, setSelected] = useState(() => + Math.max( + 0, + props.options.findIndex((option) => option.name === best), + ), + ); + const chosen = props.options[selected] ?? props.options[0]!; + return ( + +
+ +
+

{`Ride to ${props.dropoff}`}

+

+ {`from ${props.pickup}`} +

+
+ + {`Prices from ${brand.name}`} + +
+
+
+ {props.options.map((option, index) => ( + + ))} +
+
+ + {`Choose ${chosen.name}`} + +

+ {`Opens ${brand.name} to confirm. Nothing is booked from here.`} +

+
+ + ); +}); + +function initials(from: string) { + return from.trim().charAt(0).toUpperCase() || "?"; +} + +/** Gmail's list: avatars, bold unread rows, read rows on the tinted surface, attachment chips, a hover lift. */ +const MailListCard = memo(function MailListCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS.gmail; + const [open, setOpen] = useState(null); + return ( + +
+ +
+ + + + {props.query} + + {`${props.messages.length} result${props.messages.length === 1 ? "" : "s"}`} + +
+
+ {props.messages.length === 0 ? ( +

+ No messages matched. +

+ ) : null} +
    + {props.messages.map((message, index) => { + const isOpen = open === index; + return ( +
  • + +
  • + ); + })} +
+
+ + Read-only. Nothing is sent from here. + + + + Open in Gmail + +
+
+ ); +}); + +const MODE_LABEL = { + drive: "Driving", + walk: "Walking", + transit: "Transit", + bike: "Cycling", +} as const; +const MODE_ICON = { + drive: + "M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16m11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5M5 11l1.5-4.5h11L19 11z", + walk: "M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2M9.8 8.9 7 23h2.1l1.8-8 2.1 2v6h2v-7.5l-2.1-2 .6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1L6 8.3V13h2V9.6z", + transit: + "M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20.5v.5h2.23l2-2H14l2 2h2v-.5L16.5 19c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-3.58-4-8-4M7.5 17c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17m3.5-7H6V6h5zm2 0V6h5v4zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5", + bike: "M15.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2M5 12c-2.8 0-5 2.2-5 5s2.2 5 5 5 5-2.2 5-5-2.2-5-5-5m0 8.5c-1.9 0-3.5-1.6-3.5-3.5s1.6-3.5 3.5-3.5 3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5m5.8-10 2.4-2.4.8.8c1.3 1.3 3 2.1 5.1 2.1V9c-1.5 0-2.7-.6-3.6-1.5l-1.9-1.9c-.5-.4-1-.6-1.6-.6s-1.1.2-1.4.6L7.8 8.4c-.4.4-.6.9-.6 1.4 0 .6.2 1.1.6 1.4L11 14v5h2v-6.2zM19 12c-2.8 0-5 2.2-5 5s2.2 5 5 5 5-2.2 5-5-2.2-5-5-5m0 8.5c-1.9 0-3.5-1.6-3.5-3.5s1.6-3.5 3.5-3.5 3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5", +} as const; +const MAPS_MODE = { + drive: "driving", + walk: "walking", + transit: "transit", + bike: "bicycling", +} as const; + +const MAP_W = 400; +const MAP_H = 200; + +/** + * The route the maps tool returned, drawn in Google's map colours over the tool's static basemap when + * it sent one. Only real geometry: no path, no map. + */ +function RouteMap({ + path, + map, +}: { + path: ReadonlyArray; + map: z.infer["map"]; +}) { + const scheme = useRenderedAppearance() === "dark" ? "dark" : "light"; + const paint = MAP_PAINT[scheme]; + const { points, d } = projectPath(path, MAP_W, MAP_H, 22, map?.bounds); + const start = points[0]!; + const end = points.at(-1)!; + return ( + + {map ? ( + + ) : null} + + + + + + + + + ); +} + +/** Google Maps' directions panel: the travel time in traffic green, the route, then one step at a time. */ +const RouteCard = memo(function RouteCard({ props }: { props: z.infer }) { + const brand = APP_BRANDS["google-maps"]; + const [step, setStep] = useState(0); + const total = props.steps.length; + const query = `origin=${encodeURIComponent(props.origin)}&destination=${encodeURIComponent(props.destination)}&travelmode=${MAPS_MODE[props.mode]}`; + return ( + +
+ +

+ {props.origin} + {" → "} + {props.destination} +

+
+
+ + + + + {MODE_LABEL[props.mode]} + + {`${props.minutes} min`} + + {`${props.distance}${props.via ? ` · via ${props.via}` : ""}`} + +
+ {props.path ? : null} + {total ? ( +
+ + {step + 1} + +

+ {props.steps[step]} +

+ + + {`${step + 1} / ${total}`} + + +
+ ) : null} +
+ Route from Google Maps. + + + Open in Maps + + + Start + +
+
+ ); +}); + +const STATE = { + open: { label: "Open", icon: "git-pull-request", tone: "--ac-success" }, + conflict: { label: "Open", icon: "git-pull-request", tone: "--ac-success" }, + ready: { label: "Open", icon: "git-pull-request", tone: "--ac-success" }, + merged: { label: "Merged", icon: "git-merge", tone: "--ac-done" }, + closed: { label: "Closed", icon: "git-pull-request-closed", tone: "--ac-danger" }, +} as const satisfies Record; +const LANE_COLOR = { + base: "var(--ac-muted)", + head: "var(--ac-accent)", + side: "var(--ac-success)", +} as const; +const ROW = 32; + +function CommitGraph({ + commits, +}: { + commits: NonNullable["commits"]>; +}) { + const height = commits.length * ROW; + const ys = commits.map((_, index) => index * ROW + ROW / 2); + const lastOf = (lane: "base" | "head" | "side") => commits.map((c) => c.lane).lastIndexOf(lane); + const firstOf = (lane: "base" | "head" | "side") => commits.findIndex((c) => c.lane === lane); + const baseLast = lastOf("base"); + const segments: ReactNode[] = []; + for (const lane of ["base", "head", "side"] as const) { + const first = firstOf(lane); + const last = lastOf(lane); + if (first < 0) continue; + const x = commitLaneX(lane); + segments.push( + , + ); + if (lane !== "base" && baseLast > last) { + const y1 = ys[last]!; + const y2 = ys[baseLast]!; + segments.push( + , + ); + } + } + return ( + + {segments} + {commits.map((commit, index) => ( + + ))} + + ); +} + +const STATUS_ICON = { + pass: { icon: "check-circle-fill", tone: "var(--ac-success)" }, + fail: { icon: "x-circle-fill", tone: "var(--ac-danger)" }, + running: { icon: "dot-fill", tone: "var(--ac-warning)" }, + skip: { icon: "skip", tone: "var(--ac-muted)" }, +} as const satisfies Record; + +/** GitHub's pull request, in Primer: state label, commits on a graph, checks as a timeline, the merge box line. */ +const PullRequestCard = memo(function PullRequestCard({ + props, +}: { + props: z.infer; +}) { + const brand = APP_BRANDS.github; + const state = STATE[props.state]; + const counts = checkCounts(props.checks); + const timeline = checkTimeline(props.checks); + const [open, setOpen] = useState(() => { + const failed = props.checks.findIndex((check) => check.status === "fail" && check.detail); + return failed < 0 ? null : failed; + }); + const commits = props.commits ?? []; + const merged = props.state === "merged"; + const foot = + props.state === "conflict" + ? { + icon: "alert" as const, + tone: "var(--ac-warning)", + title: `This branch has ${props.conflicts} conflict${props.conflicts === 1 ? "" : "s"} that must be resolved`, + sub: `Conflicts with ${props.base}`, + } + : props.state === "ready" + ? { + icon: "check" as const, + tone: "var(--ac-success)", + title: "All checks have passed", + sub: "This branch has no conflicts with the base branch", + } + : merged + ? { + icon: "git-merge" as const, + tone: "var(--ac-done)", + title: "Pull request successfully merged and closed", + sub: `Merged into ${props.base}`, + } + : props.state === "closed" + ? { + icon: "git-pull-request-closed" as const, + tone: "var(--ac-danger)", + title: "Closed with unmerged commits", + sub: `${props.head} was not merged`, + } + : counts.fail + ? { + icon: "x-circle-fill" as const, + tone: "var(--ac-danger)", + title: "Some checks were not successful", + sub: `${counts.fail} failing, ${counts.pass} successful`, + } + : { + icon: "dot-fill" as const, + tone: "var(--ac-warning)", + title: "Some checks haven't completed yet", + sub: `${counts.running} in progress, ${counts.pass} successful`, + }; + return ( + +
+ +
+

+ {props.title}{" "} + {`#${props.number}`} +

+

+ + + {state.label} + + + {props.head} + + → + + {props.base} + +

+
+
+ {commits.length ? ( +
    + + {commits.map((commit, index) => ( +
  1. + + {commit.title} + + {commit.branch ? ( + + {commit.branch} + + ) : null} + + + {commit.sha.slice(0, 7)} + + {`${commit.author} · ${commit.age}`} + +
  2. + ))} +
+ ) : null} + {props.checks.length ? ( +
+ {props.checks.map((check, index) => { + const status = STATUS_ICON[check.status]; + const span = timeline?.spans[index]; + const expandable = Boolean(check.detail); + return ( +
+ + {expandable && open === index ? ( +

+ {check.detail} +

+ ) : null} +
+ ); + })} +
+ ) : null} +
+ + + + + {foot.title} + + {foot.sub} + + + + Open on GitHub + +
+
+ ); +}); + +export const RideOptions = defineComponent({ + name: "RideOptions", + description: DESCRIPTIONS.RideOptions, + props: rideOptionsProps, + component: RideOptionsCard, +}); +export const MailList = defineComponent({ + name: "MailList", + description: DESCRIPTIONS.MailList, + props: mailListProps, + component: MailListCard, +}); +export const Route = defineComponent({ + name: "Route", + description: DESCRIPTIONS.Route, + props: routeProps, + component: RouteCard, +}); +export const PullRequest = defineComponent({ + name: "PullRequest", + description: DESCRIPTIONS.PullRequest, + props: pullRequestProps, + component: PullRequestCard, +}); + +export const appCardSchemas = { + RideOptions: (value: unknown) => rideOptionsProps.safeParse(value), + MailList: (value: unknown) => mailListProps.safeParse(value), + Route: (value: unknown) => routeProps.safeParse(value), + PullRequest: (value: unknown) => pullRequestProps.safeParse(value), +}; diff --git a/apps/web/src/components/chat/OpenUiCharts.tsx b/apps/web/src/components/chat/OpenUiCharts.tsx index 0437b5783bfd9..9d38a2d902153 100644 --- a/apps/web/src/components/chat/OpenUiCharts.tsx +++ b/apps/web/src/components/chat/OpenUiCharts.tsx @@ -23,10 +23,13 @@ import { shade, stackSeries, stackedReadout, + streamingChartBoard, tailPath, treemapReadout, } from "@t3tools/client-runtime/openui-charts"; +import { MailList, PullRequest, RideOptions, Route, appCardSchemas } from "./OpenUiAppCards"; + // Catalog v2: the shared definition in client-runtime bound to this app's zod and OpenUI. const title = z.string().max(LIMITS.title); const label = z.string().min(1).max(LIMITS.label); @@ -220,7 +223,7 @@ const Board = defineComponent({ props: z.object({ title, summary: z.string().max(LIMITS.summary), - panels: z.array(z.union([Treemap.ref, BarChart.ref, StackedArea.ref, Heatmap.ref])).min(1).max(LIMITS.panels), + panels: z.array(z.union([Treemap.ref, BarChart.ref, StackedArea.ref, Heatmap.ref, RideOptions.ref, MailList.ref, Route.ref, PullRequest.ref])).min(1).max(LIMITS.panels), }), component: ({ props, renderNode }) => (
@@ -233,12 +236,13 @@ const Board = defineComponent({ ), }); -const library = createLibrary({ components: [Board, Treemap, BarChart, StackedArea, Heatmap], root: "Board" }); +const library = createLibrary({ components: [Board, Treemap, BarChart, StackedArea, Heatmap, RideOptions, MailList, Route, PullRequest], root: "Board" }); const panelSchemas = { Treemap: (value: unknown) => treemapProps.safeParse(value), BarChart: (value: unknown) => barChartProps.safeParse(value), StackedArea: (value: unknown) => stackedAreaProps.safeParse(value), Heatmap: (value: unknown) => heatmapProps.safeParse(value), + ...appCardSchemas, }; export const openUiChartPrompt = library.prompt(OPENUI_CHART_PROMPT); @@ -252,9 +256,13 @@ export function inspectOpenUiChart(code: string) { } } -export function OpenUiCharts({ code }: { code: string }) { - const status = useMemo(() => inspectOpenUiChart(code), [code]); - if (status.kind !== "ready") return

{status.reason}

; +export function OpenUiCharts({ code, isStreaming = false }: { code: string; isStreaming?: boolean }) { + const shown = isStreaming ? streamingChartBoard(code) : code; + const status = shown === null ? null : inspectOpenUiChart(shown); + if (status === null || status.kind !== "ready") { + const reason = isStreaming || status === null ? "Receiving chart board…" : status.reason; + return

{reason}

; + } // No tool provider, actions or state callbacks: generated content has no action authority. - return ; + return ; } diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index a8d5e3e68de77..b7f27c5d2f89d 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -16,13 +16,16 @@ import { Link } from "@tanstack/react-router"; import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; import { resolveViewedImageAsset, + resolveWorkEntryAppActivity, toolGroupAction, + workEntryAppActivityLabel, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { turnItemHasDetail, turnItemNeedsDetailFetch, } from "@t3tools/client-runtime/work-log/item-detail"; +import type { AppBrand } from "@t3tools/client-runtime/app-connectors"; import { claudeSkillInvocation } from "@t3tools/shared/toolActivity"; import { memo, use, useState, type KeyboardEvent } from "react"; import { @@ -278,6 +281,8 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ? undefined : (workEntry.toolIcon ?? workEntry.toolSource?.icon); const isReasoning = workEntry.itemType === "reasoning"; + const appActivity = + showWarningIndicator || showDestructiveRowStyle ? null : resolveWorkEntryAppActivity(workEntry); const questionHeading = workEntry.questionAnswer ? getQuestionTextPreview(workEntry.questionAnswer) : ""; @@ -286,7 +291,9 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ? workEntry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought" - : questionHeading || (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot)); + : appActivity + ? workEntryAppActivityLabel(workEntry)! + : questionHeading || (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot)); const answerPreview = workEntry.questionAnswer && hasQuestionAnswer(workEntry.questionAnswer) ? getQuestionAnswerPreview(workEntry.questionAnswer) @@ -395,18 +402,22 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { data-v2-item-visibility={workEntry.projectedItem?.visibility} {...rowToggleProps} icon={ - - - + appActivity ? ( + + ) : ( + + + + ) } label={
@@ -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]*href="([^"]*)"/g)) + assert.match(href, /^https:\/\//, "cards only link out to the app over https"); + } + assert.equal(render(ride).match(/aria-pressed/g)?.length, 3); + assert.match(openUiChartPrompt, /RideOptions\(app: "uber" \| "lyft"/); + assert.match(openUiChartPrompt, /never invent prices/); +}); + +test("app cards reject incoherent states and unknown brands", () => { + for (const code of [ + pr("conflict", 0), + pr("merged", 2), + pr("ready", 0, '[{name: "Tests", status: "running", detail: ""}]'), + ride.replace('"uber"', '"bolt"'), + ride.replace("[{name", '[{name: "UberX", minutes: 1, price: "$1", seats: 4}, {name'), + route.replace('"drive"', '"teleport"'), + ]) + assert.equal(inspectOpenUiChart(code).kind, "invalid", code.slice(-90)); +}); + +test("app cards mix with charts on one board", () => { + const code = `root = Board("Trip", "Ride and route.", [rides, route, spend]) +rides = RideOptions("lyft", "Home", "Airport", [{name: "Lyft", minutes: 3, price: "$28", seats: 4}]) +route = Route("Home", "Airport", "transit", 52, "14 mi", ["Walk to 16th St BART"]) +spend = BarChart("Ride spend", "USD", [{label: "Uber", value: 120}, {label: "Lyft", value: 80}])`; + assert.deepEqual(inspectOpenUiChart(code), { kind: "ready" }); + const html = render(code); + assert.match(html, /--ac-primary:#ff00bf/); + assert.match(html, /Transit/); + assert.match(html, /Ride spend/); +}); diff --git a/experiments/openui-lab/dogfood/acp-with-connectors.mjs b/experiments/openui-lab/dogfood/acp-with-connectors.mjs new file mode 100644 index 0000000000000..6119ecdd4687a --- /dev/null +++ b/experiments/openui-lab/dogfood/acp-with-connectors.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Dogfood ACP proxy: `node acp-with-connectors.mjs `. Adds the demo connectors +// to every session so a sandbox provider gets Gmail/Uber/Maps/GitHub without touching agent config. +// OPENUI_CATALOG_FILE, when set, is appended to each prompt in place of catalog instructions +// T3 does not inject yet. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +import * as NodeURL from "node:url"; + +const [command, ...args] = process.argv.slice(2); +if (!command) { + process.stderr.write("usage: acp-with-connectors.mjs \n"); + process.exit(2); +} + +const connectorPath = NodeURL.fileURLToPath(new URL("./demo-connectors.mjs", import.meta.url)); +const servers = ["gmail", "uber", "google-maps", "github"].map((name) => ({ + name, + command: process.execPath, + args: [connectorPath, name], + env: [], +})); +const catalog = process.env.OPENUI_CATALOG_FILE + ? `The openui-v2 catalog for this reply is complete below; do not look for it elsewhere.\n\n${NodeFS.readFileSync(process.env.OPENUI_CATALOG_FILE, "utf8")}` + : null; +const SESSION_METHODS = new Set(["session/new", "session/load", "session/resume", "session/fork"]); + +const child = NodeChildProcess.spawn(command, args, { stdio: ["pipe", "inherit", "inherit"] }); +child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0))); +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal)); + +NodeReadline.createInterface({ input: process.stdin }) + .on("line", (line) => { + let out = line; + try { + const message = JSON.parse(line); + if (SESSION_METHODS.has(message.method) && message.params) { + const existing = (message.params.mcpServers ?? []).filter( + (server) => !servers.some((demo) => demo.name === server.name), + ); + message.params.mcpServers = [...existing, ...servers]; + out = JSON.stringify(message); + } else if ( + catalog && + message.method === "session/prompt" && + Array.isArray(message.params?.prompt) + ) { + message.params.prompt = [...message.params.prompt, { type: "text", text: catalog }]; + out = JSON.stringify(message); + } + } catch {} + child.stdin.write(`${out}\n`); + }) + .on("close", () => child.stdin.end()); diff --git a/experiments/openui-lab/dogfood/demo-connectors.mjs b/experiments/openui-lab/dogfood/demo-connectors.mjs new file mode 100644 index 0000000000000..24531f1ad052a --- /dev/null +++ b/experiments/openui-lab/dogfood/demo-connectors.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node +// Demo MCP connectors for dogfooding app cards: `node demo-connectors.mjs gmail|uber|google-maps|github`. +// Speaks MCP over stdio with no dependencies. Every result is canned demo data and says so; +// nothing is fetched, booked, sent or merged. +import * as NodeReadline from "node:readline"; + +const ROUTE_FERRY_SFO = [ + [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], +]; + +const DEMO = "Demo data from the T3 Code OpenUI lab, not a real account."; + +const CONNECTORS = { + gmail: { + search_messages: { + description: "Search the user's Gmail. Returns matching messages.", + input: { query: { type: "string", description: "Gmail search query" } }, + run: ({ query }) => ({ + query, + messages: [ + { + from: "Acme Billing", + subject: "Invoice #4512 for October", + snippet: "Your October invoice for $1,240.00 is ready. Payment is due Nov 15.", + time: "9:41 AM", + unread: true, + attachment: "INV-4512.pdf", + }, + { + from: "Acme Billing", + subject: "Invoice #4498 for September", + snippet: "September invoice attached. Thanks for your business.", + time: "Oct 2", + unread: false, + }, + { + from: "Priya at Acme", + subject: "Re: billing contact change", + snippet: "Updated the billing contact to finance@ as requested.", + time: "Sep 28", + unread: false, + }, + ], + }), + }, + }, + uber: { + get_price_estimates: { + description: "Uber ride options and prices for a trip.", + input: { pickup: { type: "string" }, dropoff: { type: "string" } }, + run: ({ pickup, dropoff }) => ({ + pickup, + dropoff, + options: [ + { name: "UberX", pickup_minutes: 4, price: "$31.20", seats: 4 }, + { name: "Comfort", pickup_minutes: 6, price: "$38.90", seats: 4 }, + { name: "UberXL", pickup_minutes: 9, price: "$52.10", seats: 6 }, + ], + }), + }, + request_ride: { + description: "Request an Uber ride. Demo only: never books.", + input: { + product: { type: "string" }, + pickup: { type: "string" }, + dropoff: { type: "string" }, + }, + run: () => ({ + booked: false, + reason: "Demo connector: booking is disabled. Open the Uber app to book.", + }), + }, + }, + "google-maps": { + get_directions: { + description: "Directions between two places.", + input: { + origin: { type: "string" }, + destination: { type: "string" }, + mode: { type: "string", description: "drive, walk, transit or bike" }, + }, + run: ({ origin, destination, mode = "drive" }) => ({ + origin, + destination, + mode, + minutes: 22, + distance: "14.5 mi", + via: "I-280 S and US-101 S", + steps: [ + "Head south on Steuart St", + "Turn left onto Mission St", + "Turn right onto The Embarcadero", + "Continue onto King St", + "Merge onto I-280 S", + "Take the exit onto US-101 S", + "Keep right onto US-101 Spur toward the airport", + "Turn right onto Departures", + ], + // The real Ferry Building to SFO driving route (OSRM over OpenStreetMap, ODbL), simplified. + path: ROUTE_FERRY_SFO, + // A static basemap of the same area in Google's map colours, from OpenStreetMap (ODbL). + map: { + image: + "https://raw.githubusercontent.com/kvnloo/t3code/media/openui-app-cards/maps/ferry-sfo-map.png", + bounds: [37.597565, -122.664055, 37.810018, -122.127003], + }, + }), + }, + }, + github: { + get_pull_request: { + description: "A pull request with its checks and merge state.", + input: { number: { type: "number" } }, + run: ({ number = 128 }) => ({ + number, + title: "Refresh sessions in the auth middleware", + head: "feat/session-refresh", + base: "main", + mergeable_state: "dirty", + conflicts: 3, + checks: [ + { name: "Install", status: "pass", detail: "", started: 0, seconds: 11 }, + { name: "Lint", status: "pass", detail: "", started: 11, seconds: 9 }, + { name: "Tests", status: "pass", detail: "vitest · 42 passed", started: 11, seconds: 38 }, + { name: "Types", status: "running", detail: "tsgo", started: 20, seconds: 24 }, + ], + commits: [ + { + title: "Refresh tokens before expiry", + sha: "a41f9c2", + author: "kevin", + age: "2h", + lane: "head", + branch: "feat/session-refresh", + }, + { + title: "Add refresh test for clock skew", + sha: "9b3e0d4", + author: "kevin", + age: "3h", + lane: "head", + }, + { + title: "Rewrite session middleware", + sha: "c90e1b7", + author: "julius", + age: "5h", + lane: "base", + branch: "main", + conflict: true, + }, + { + title: "Bump effect to 4.0.0-beta.31", + sha: "77d0aa1", + author: "theo", + age: "1d", + lane: "base", + }, + ], + }), + }, + merge_pull_request: { + description: "Merge a pull request. Demo only: never merges.", + input: { number: { type: "number" } }, + run: ({ number }) => ({ + merged: false, + number, + reason: "Demo connector: merging is disabled.", + }), + }, + }, +}; + +const name = process.argv[2]; +const tools = CONNECTORS[name]; +if (!tools) { + process.stderr.write(`usage: demo-connectors.mjs ${Object.keys(CONNECTORS).join("|")}\n`); + process.exit(2); +} + +const send = (message) => + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); + +NodeReadline.createInterface({ input: process.stdin }).on("line", (line) => { + let request; + try { + request = JSON.parse(line); + } catch { + return; + } + const { id, method, params } = request; + if (id === undefined) return; + if (method === "initialize") { + send({ + id, + result: { + protocolVersion: params?.protocolVersion ?? "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name, version: "0.0.1" }, + }, + }); + } else if (method === "tools/list") { + send({ + id, + result: { + tools: Object.entries(tools).map(([tool, spec]) => ({ + name: tool, + description: `${spec.description} ${DEMO}`, + inputSchema: { type: "object", properties: spec.input }, + })), + }, + }); + } else if (method === "tools/call") { + const spec = tools[params?.name]; + if (!spec) send({ id, error: { code: -32602, message: `Unknown tool ${params?.name}` } }); + else + send({ + id, + result: { + content: [ + { + type: "text", + text: JSON.stringify({ note: DEMO, ...spec.run(params.arguments ?? {}) }), + }, + ], + }, + }); + } else if (method === "ping") { + send({ id, result: {} }); + } else { + send({ id, error: { code: -32601, message: `Method not found: ${method}` } }); + } +}); diff --git a/experiments/openui-lab/run-smoke.mjs b/experiments/openui-lab/run-smoke.mjs index e6edf05d18962..9559630e75b0b 100644 --- a/experiments/openui-lab/run-smoke.mjs +++ b/experiments/openui-lab/run-smoke.mjs @@ -14,6 +14,12 @@ const sources = { "OpenUiReport.tsx": "apps/web/src/components/chat/OpenUiReport.tsx", "OpenUiCharts.tsx": "apps/web/src/components/chat/OpenUiCharts.tsx", "openUiCharts.ts": "packages/client-runtime/src/openUiCharts.ts", + "OpenUiAppCards.tsx": "apps/web/src/components/chat/OpenUiAppCards.tsx", + "openUiAppCards.ts": "packages/client-runtime/src/openUiAppCards.ts", + "appConnectors.ts": "packages/client-runtime/src/appConnectors.ts", + "appCardThemes.ts": "packages/client-runtime/src/appCardThemes.ts", + "octicons.ts": "packages/client-runtime/src/octicons.ts", + "app-cards.test.ts": "experiments/openui-lab/app-cards.test.ts", "report.test.ts": "experiments/openui-lab/report.test.ts", "charts.test.ts": "experiments/openui-lab/charts.test.ts", "source-demo-fences.md": "experiments/openui-lab/source-demo-fences.md", @@ -22,7 +28,18 @@ const rewrites = [ ["../../apps/web/src/components/chat/OpenUiReport", "./OpenUiReport"], ["../../apps/web/src/components/chat/OpenUiCharts", "./OpenUiCharts"], ["@t3tools/client-runtime/openui-charts", "./openUiCharts"], + ["@t3tools/client-runtime/openui-app-cards", "./openUiAppCards"], + ["@t3tools/client-runtime/app-connectors", "./appConnectors"], + ["@t3tools/client-runtime/app-card-themes", "./appCardThemes"], + ["@t3tools/client-runtime/octicons", "./octicons"], + ["~/hooks/useTheme", "./useTheme"], ]; +// SSR in plain Node has no bundler: fonts are dropped, images become their file names, the theme is light. +const stubs = { + "useTheme.ts": 'export const useRenderedAppearance = () => "light";\n', +}; +const bundlerOnly = /^import "@fontsource[^"]*";\n/gm; +const assetImport = /^import (\w+) from "~\/assets\/[^"]*\/([^"/]+)";$/gm; for (const [name, path] of Object.entries(sources)) { if (name.endsWith(".md")) { copyFileSync(join(root, path), join(scratch, name)); @@ -30,22 +47,76 @@ for (const [name, path] of Object.entries(sources)) { } let text = readFileSync(join(root, path), "utf8"); for (const [from, to] of rewrites) text = text.replaceAll(`"${from}"`, `"${to}"`); + text = text.replace(bundlerOnly, "").replace(assetImport, 'const $1 = "$2";'); writeFileSync(join(scratch, name), text); } -writeFileSync(join(scratch, "tsconfig.json"), JSON.stringify({ compilerOptions: { jsx: "react-jsx" } })); -const dependencies = { "@openuidev/react-lang": "1.0.2", "@openuidev/lang-core": "1.0.2", zod: "4.1.12", react: "19.2.0", "react-dom": "19.2.0", tsx: "4.20.6" }; -writeFileSync(join(scratch, "package.json"), JSON.stringify({ private: true, type: "module", dependencies })); +for (const [name, text] of Object.entries(stubs)) writeFileSync(join(scratch, name), text); +writeFileSync( + join(scratch, "tsconfig.json"), + JSON.stringify({ compilerOptions: { jsx: "react-jsx" } }), +); +const dependencies = { + "@openuidev/react-lang": "1.0.2", + "@openuidev/lang-core": "1.0.2", + zod: "4.1.12", + react: "19.2.0", + "react-dom": "19.2.0", + tsx: "4.20.6", +}; +writeFileSync( + join(scratch, "package.json"), + JSON.stringify({ private: true, type: "module", dependencies }), +); const env = { ...process.env, OPENUI_TELEMETRY_DISABLED: "1", DO_NOT_TRACK: "1" }; function run(command, args) { - const result = spawnSync(command, args, { cwd: scratch, env, encoding: "utf8", timeout: 120_000 }); - const log = (result.stdout ?? "") + (result.stderr ?? "") + (result.error ? String(result.error) : ""); + const result = spawnSync(command, args, { + cwd: scratch, + env, + encoding: "utf8", + timeout: 120_000, + }); + const log = + (result.stdout ?? "") + (result.stderr ?? "") + (result.error ? String(result.error) : ""); process.stdout.write(log); return { status: result.status, log }; } -const install = run("npm", ["install", "--ignore-scripts", "--legacy-peer-deps", "--no-audit", "--no-fund"]); -const result = install.status === 0 ? run(process.execPath, ["--import", "tsx", "--test", "report.test.ts", "charts.test.ts"]) : install; +const install = run("npm", [ + "install", + "--ignore-scripts", + "--legacy-peer-deps", + "--no-audit", + "--no-fund", +]); +const result = + install.status === 0 + ? run(process.execPath, [ + "--import", + "tsx", + "--test", + "report.test.ts", + "charts.test.ts", + "app-cards.test.ts", + ]) + : install; writeFileSync(join(out, "smoke.log"), result.log); -const hash = (path) => createHash("sha256").update(readFileSync(join(root, path))).digest("hex"); +const hash = (path) => + createHash("sha256") + .update(readFileSync(join(root, path))) + .digest("hex"); const sourceSha256 = Object.fromEntries(Object.values(sources).map((path) => [path, hash(path)])); -writeFileSync(join(out, "smoke-receipt.json"), JSON.stringify({ scope: "isolated stock parser + React SSR, not integrated T3 or browser", status: result.status, node: process.version, dependencies, sourceSha256, scratch }, null, 2)); +writeFileSync( + join(out, "smoke-receipt.json"), + JSON.stringify( + { + scope: "isolated stock parser + React SSR, not integrated T3 or browser", + status: result.status, + node: process.version, + dependencies, + sourceSha256, + scratch, + }, + null, + 2, + ), +); process.exitCode = result.status ?? 1; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index ecb9bedad2c2a..3c7f768bcc295 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -514,6 +514,22 @@ "./openui-charts": { "types": "./src/openUiCharts.ts", "default": "./src/openUiCharts.ts" + }, + "./app-connectors": { + "types": "./src/appConnectors.ts", + "default": "./src/appConnectors.ts" + }, + "./openui-app-cards": { + "types": "./src/openUiAppCards.ts", + "default": "./src/openUiAppCards.ts" + }, + "./app-card-themes": { + "types": "./src/appCardThemes.ts", + "default": "./src/appCardThemes.ts" + }, + "./octicons": { + "types": "./src/octicons.ts", + "default": "./src/octicons.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/appCardThemes.test.ts b/packages/client-runtime/src/appCardThemes.test.ts new file mode 100644 index 0000000000000..b34b4254b8e03 --- /dev/null +++ b/packages/client-runtime/src/appCardThemes.test.ts @@ -0,0 +1,41 @@ +import * as NodeAssert from "node:assert/strict"; +import { it as test } from "vite-plus/test"; + +import { appCardTheme, projectPath } from "./appCardThemes.ts"; + +test("each app gets its own design system in both schemes", () => { + NodeAssert.equal(appCardTheme("github", "light").done, "#8250df"); + NodeAssert.equal(appCardTheme("github", "dark").surface, "#0d1117"); + NodeAssert.equal(appCardTheme("uber", "light").primary, "#000000"); + NodeAssert.equal(appCardTheme("gmail", "dark").font, "google-sans"); + NodeAssert.equal(appCardTheme("spotify", "light").font, "google-sans"); +}); + +test("a route path keeps its shape and fits the box", () => { + // A north-south route is taller than wide, so it fills the height and is centred horizontally. + const { points, d } = projectPath( + [ + [37.79, -122.39], + [37.7, -122.4], + [37.62, -122.38], + ], + 300, + 200, + 20, + ); + const ys = points.map(([, y]) => y); + NodeAssert.equal(Math.min(...ys), 20); + NodeAssert.equal(Math.max(...ys), 180); + for (const [x] of points) NodeAssert.ok(x > 100 && x < 200); + NodeAssert.match(d, /^M[\d.]+ [\d.]+ L/); + NodeAssert.deepEqual(projectPath([], 10, 10).points, []); +}); + +test("with basemap bounds, a point lands where the image drew it", () => { + // Bounds and pixel from the Ferry Building to SFO basemap the demo maps connector returns. + const bounds = [37.597565, -122.664055, 37.810018, -122.127003] as const; + const { points } = projectPath([[37.7944, -122.394]], 400, 200, 16, bounds); + const [x, y] = points[0]!; + NodeAssert.ok(Math.abs(x - 201.2) < 1, `x ${x}`); + NodeAssert.ok(Math.abs(y - 14.7) < 1, `y ${y}`); +}); diff --git a/packages/client-runtime/src/appCardThemes.ts b/packages/client-runtime/src/appCardThemes.ts new file mode 100644 index 0000000000000..be0ee171f8c78 --- /dev/null +++ b/packages/client-runtime/src/appCardThemes.ts @@ -0,0 +1,229 @@ +/** + * App card themes copied from each app's design system: GitHub from Primer, Uber from Base Web (Inter + * Tight stands in for the private Uber Move), Gmail and Maps from Google's Material 3. Web maps a theme + * to CSS variables; mobile reads it directly. + */ +import type { AppBrandId } from "./appConnectors.ts"; + +export type AppCardFont = "google-sans" | "inter-tight" | "mona-sans"; + +export interface AppCardTheme { + readonly font: AppCardFont; + /** Card container. */ + readonly surface: string; + /** Inset areas: read mail rows, selected rows, check lists. */ + readonly surfaceAlt: string; + readonly onSurface: string; + readonly muted: string; + readonly outline: string; + /** Primary action fill and its text. */ + readonly primary: string; + readonly onPrimary: string; + /** Tonal action / selected fill and its text. */ + readonly container: string; + readonly onContainer: string; + /** Links and focus. */ + readonly accent: string; + readonly success: string; + readonly warning: string; + readonly warningSurface: string; + readonly danger: string; + readonly done: string; + readonly radius: number; +} + +type ThemePair = { readonly light: AppCardTheme; readonly dark: AppCardTheme }; + +const GOOGLE: ThemePair = { + light: { + font: "google-sans", + surface: "#ffffff", + surfaceAlt: "#f2f6fc", + onSurface: "#1f1f1f", + muted: "#444746", + outline: "#c4c7c5", + primary: "#0b57d0", + onPrimary: "#ffffff", + container: "#c2e7ff", + onContainer: "#001d35", + accent: "#0b57d0", + success: "#188038", + warning: "#b06000", + warningSurface: "#fef7e0", + danger: "#b3261e", + done: "#0b57d0", + radius: 16, + }, + dark: { + font: "google-sans", + surface: "#1e1f20", + surfaceAlt: "#282a2c", + onSurface: "#e3e3e3", + muted: "#c4c7c5", + outline: "#444746", + primary: "#a8c7fa", + onPrimary: "#062e6f", + container: "#004a77", + onContainer: "#c2e7ff", + accent: "#a8c7fa", + success: "#6dd58c", + warning: "#fdd663", + warningSurface: "#3d3000", + danger: "#f2b8b5", + done: "#a8c7fa", + radius: 16, + }, +}; + +const BASE: ThemePair = { + light: { + font: "inter-tight", + surface: "#ffffff", + surfaceAlt: "#f3f3f3", + onSurface: "#000000", + muted: "#5e5e5e", + outline: "#e8e8e8", + primary: "#000000", + onPrimary: "#ffffff", + container: "#f3f3f3", + onContainer: "#000000", + accent: "#276ef1", + success: "#0e8345", + warning: "#9f6402", + warningSurface: "#fdf2dc", + danger: "#de1135", + done: "#000000", + radius: 12, + }, + dark: { + font: "inter-tight", + surface: "#161616", + surfaceAlt: "#292929", + onSurface: "#dedede", + muted: "#ababab", + outline: "#383838", + primary: "#dedede", + onPrimary: "#000000", + container: "#292929", + onContainer: "#dedede", + accent: "#5e8bdb", + success: "#5c9d70", + warning: "#f6bc2f", + warningSurface: "#3a2d0b", + danger: "#de5b5d", + done: "#dedede", + radius: 12, + }, +}; + +const PRIMER: ThemePair = { + light: { + font: "mona-sans", + surface: "#ffffff", + surfaceAlt: "#f6f8fa", + onSurface: "#1f2328", + muted: "#59636e", + outline: "#d1d9e0", + primary: "#1f883d", + onPrimary: "#ffffff", + container: "#ddf4ff", + onContainer: "#0969da", + accent: "#0969da", + success: "#1a7f37", + warning: "#9a6700", + warningSurface: "#fff8c5", + danger: "#d1242f", + done: "#8250df", + radius: 12, + }, + dark: { + font: "mona-sans", + surface: "#0d1117", + surfaceAlt: "#151b23", + onSurface: "#f0f6fc", + muted: "#9198a1", + outline: "#3d444d", + primary: "#238636", + onPrimary: "#ffffff", + container: "#388bfd1a", + onContainer: "#4493f8", + accent: "#4493f8", + success: "#3fb950", + warning: "#d29922", + warningSurface: "#bb800926", + danger: "#f85149", + done: "#ab7df8", + radius: 12, + }, +}; + +const LYFT: ThemePair = { + light: { ...BASE.light, primary: "#ff00bf", onPrimary: "#ffffff", accent: "#ff00bf" }, + dark: { ...BASE.dark, primary: "#ff00bf", onPrimary: "#ffffff", accent: "#ff5fd5" }, +}; + +export const APP_CARD_THEMES: Readonly>> = { + gmail: GOOGLE, + "google-maps": GOOGLE, + "google-calendar": GOOGLE, + uber: BASE, + lyft: LYFT, + github: PRIMER, +}; + +export function appCardTheme(app: AppBrandId, scheme: "light" | "dark"): AppCardTheme { + return (APP_CARD_THEMES[app] ?? GOOGLE)[scheme]; +} + +/** Google Maps' map palette (measured on google.com/maps): land, water, park, route and its casing. */ +export const MAP_PAINT = { + light: { + land: "#f7f7f7", + route: "#0b57d0", + routeCasing: "#0842a0", + ink: "#1f1f1f", + startFill: "#ffffff", + imageFilter: "none", + pin: "#ea4335", + pinCore: "#a50e0e", + }, + dark: { + land: "#1f2835", + route: "#8ab4f8", + routeCasing: "#4285f4", + ink: "#e3e3e3", + startFill: "#1f2835", + imageFilter: "invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9) saturate(0.6)", + pin: "#ea4335", + pinCore: "#a50e0e", + }, +} as const; + +/** + * A [lat, lon] path projected into a w x h box (Web Mercator). Fits the path itself, padded, or the + * [south, west, north, east] bounds of a basemap image drawn edge to edge in the same box. + */ +export function projectPath( + path: ReadonlyArray, + w: number, + h: number, + pad = 16, + bounds?: readonly [number, number, number, number], +) { + if (path.length === 0) return { points: [] as Array<[number, number]>, d: "" }; + const merc = (lat: number) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)); + const xs = path.map(([, lon]) => (lon * Math.PI) / 180); + const ys = path.map(([lat]) => merc(lat)); + const [x0, x1, y0, y1] = bounds + ? [(bounds[1] * Math.PI) / 180, (bounds[3] * Math.PI) / 180, merc(bounds[0]), merc(bounds[2])] + : [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]; + const inset = bounds ? 0 : pad; + const span = Math.max((x1 - x0) / (w - inset * 2), (y1 - y0) / (h - inset * 2)) || 1; + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + const points = xs.map((x, i): [number, number] => [ + Math.round(((x - cx) / span + w / 2) * 10) / 10, + Math.round(((cy - ys[i]!) / span + h / 2) * 10) / 10, + ]); + return { points, d: "M" + points.map(([x, y]) => `${x} ${y}`).join(" L") }; +} diff --git a/packages/client-runtime/src/appConnectors.test.ts b/packages/client-runtime/src/appConnectors.test.ts new file mode 100644 index 0000000000000..5a67715665290 --- /dev/null +++ b/packages/client-runtime/src/appConnectors.test.ts @@ -0,0 +1,83 @@ +import * as NodeAssert from "node:assert/strict"; +import { it as test } from "vite-plus/test"; + +import { + brandForServer, + normalizeServerName, + resolveAppActivity, + splitToolName, +} from "./appConnectors.ts"; + +test("server names resolve through harness renames", () => { + NodeAssert.equal(normalizeServerName("editor_gmail_3f2a9c0d"), "gmail"); + NodeAssert.equal(normalizeServerName("mcp__google-maps"), "googlemaps"); + NodeAssert.equal(brandForServer("editor_google_maps_0a1b2c3d")?.id, "google-maps"); + NodeAssert.equal(brandForServer("Uber")?.name, "Uber"); + NodeAssert.equal(brandForServer("t3-code"), null); + NodeAssert.equal(brandForServer("github_enterprise_tools"), null); +}); + +test("tool names split for T3 items and ACP titles", () => { + NodeAssert.deepEqual(splitToolName("gmail.search_threads"), { + server: "gmail", + tool: "search_threads", + }); + NodeAssert.deepEqual(splitToolName("mcp__editor_uber_0a1b2c3d__request_ride"), { + server: "editor_uber_0a1b2c3d", + tool: "request_ride", + }); + NodeAssert.equal(splitToolName("terminal"), null); +}); + +test("activity wording follows the tool and its lifecycle", () => { + const search = resolveAppActivity( + "gmail.search_threads", + { query: "invoices from Acme" }, + "inProgress", + )!; + NodeAssert.equal(search.label, "Searching Gmail"); + NodeAssert.equal(search.detail, "invoices from Acme"); + NodeAssert.equal(search.phase, "running"); + NodeAssert.equal( + resolveAppActivity("gmail.search_threads", {}, "completed")!.label, + "Searched Gmail", + ); + NodeAssert.equal( + resolveAppActivity("uber.estimate_rides", { dropoff: "SFO" }, "completed")!.label, + "Checked Uber prices", + ); + NodeAssert.equal( + resolveAppActivity("editor_uber_0a1b2c3d.request_ride", { dropoff: "SFO" }, "inProgress")! + .label, + "Requesting an Uber", + ); + NodeAssert.equal( + resolveAppActivity("google_maps.directions", { destination: "Ferry Building" }, "completed")! + .detail, + "Ferry Building", + ); + NodeAssert.equal(resolveAppActivity("lyft.unknown_tool", {}, "inProgress")!.label, "Using Lyft"); + NodeAssert.equal( + resolveAppActivity("gmail.search_threads", {}, "failed")!.label, + "Searching Gmail didn't finish", + ); + NodeAssert.equal(resolveAppActivity("terminal", {}, "completed"), null); + NodeAssert.equal(resolveAppActivity("t3-code.html_render", {}, "completed"), null); +}); + +test("only a finished merge plays the merged transition", () => { + const merging = resolveAppActivity( + "github.merge_pull_request", + { pull_number: 128 }, + "inProgress", + )!; + NodeAssert.equal(merging.label, "Merging the pull request"); + NodeAssert.equal(merging.detail, "#128"); + NodeAssert.equal(merging.merged, false); + NodeAssert.equal( + resolveAppActivity("github.merge_pull_request", { pull_number: 128 }, "completed")!.merged, + true, + ); + NodeAssert.equal(resolveAppActivity("github.merge_pull_request", {}, "failed")!.merged, false); + NodeAssert.equal(resolveAppActivity("github.search_issues", {}, "completed")!.merged, false); +}); diff --git a/packages/client-runtime/src/appConnectors.ts b/packages/client-runtime/src/appConnectors.ts new file mode 100644 index 0000000000000..5d8ed0f1789ac --- /dev/null +++ b/packages/client-runtime/src/appConnectors.ts @@ -0,0 +1,189 @@ +/** + * Brands for connector tools: activity rows read "Searching Gmail · invoices" with the app's color, + * matched by MCP server name so every provider gets it. The OpenUI app cards share these brands. + * Names and colors only identify the service; no logos are bundled (cards draw a monogram). + */ + +export type AppBrandId = + | "gmail" + | "google-calendar" + | "google-maps" + | "uber" + | "lyft" + | "github" + | "slack" + | "spotify" + | "doordash"; + +export interface AppBrand { + readonly id: AppBrandId; + readonly name: string; + /** Monogram drawn in place of a logo. */ + readonly mark: string; + /** Card accent: stripe, monogram background, primary button. */ + readonly color: string; + /** Text and icon color drawn on `color`. */ + readonly onColor: string; +} + +export const APP_BRANDS: Readonly> = { + gmail: { id: "gmail", name: "Gmail", mark: "M", color: "#EA4335", onColor: "#FFFFFF" }, + "google-calendar": { + id: "google-calendar", + name: "Google Calendar", + mark: "31", + color: "#1A73E8", + onColor: "#FFFFFF", + }, + "google-maps": { + id: "google-maps", + name: "Google Maps", + mark: "◆", + color: "#188038", + onColor: "#FFFFFF", + }, + uber: { id: "uber", name: "Uber", mark: "U", color: "#000000", onColor: "#FFFFFF" }, + lyft: { id: "lyft", name: "Lyft", mark: "L", color: "#FF00BF", onColor: "#FFFFFF" }, + github: { id: "github", name: "GitHub", mark: "GH", color: "#24292F", onColor: "#FFFFFF" }, + slack: { id: "slack", name: "Slack", mark: "#", color: "#4A154B", onColor: "#FFFFFF" }, + spotify: { id: "spotify", name: "Spotify", mark: "♪", color: "#1DB954", onColor: "#000000" }, + doordash: { id: "doordash", name: "DoorDash", mark: "D", color: "#FF3008", onColor: "#FFFFFF" }, +}; + +/** Server-name aliases after normalization (lowercase, no separators). */ +const SERVER_ALIASES: ReadonlyArray]> = [ + ["gmail", ["gmail", "googlegmail", "gwsgmail", "googlemail"]], + ["google-calendar", ["googlecalendar", "gcal", "calendar", "gwscalendar"]], + ["google-maps", ["googlemaps", "maps", "gmaps"]], + ["uber", ["uber", "uberrides"]], + ["lyft", ["lyft"]], + ["github", ["github", "gh"]], + ["slack", ["slack"]], + ["spotify", ["spotify"]], + ["doordash", ["doordash"]], +]; + +/** + * Normalize an MCP server name, undoing harness renames: Hermes registers editor-supplied + * servers as `editor__<8 hex>`, and ACP titles may carry an `mcp__` prefix. + */ +export function normalizeServerName(server: string): string { + return server + .replace(/^mcp__/, "") + .toLowerCase() + .replace(/[\s-]+/g, "_") + .replace(/^editor_(.+?)_[0-9a-f]{8}$/, "$1") + .replace(/[^a-z0-9]/g, ""); +} + +export function brandForServer(server: string): AppBrand | null { + const key = normalizeServerName(server); + for (const [id, aliases] of SERVER_ALIASES) if (aliases.includes(key)) return APP_BRANDS[id]; + return null; +} + +/** Split a T3 tool name (`server.tool`) or an ACP title (`mcp__server__tool`). */ +export function splitToolName( + toolName: string, +): { readonly server: string; readonly tool: string } | null { + const acp = /^mcp__(.+?)__(.+)$/.exec(toolName); + if (acp) return { server: acp[1]!, tool: acp[2]! }; + const dot = toolName.indexOf("."); + return dot > 0 && dot < toolName.length - 1 + ? { server: toolName.slice(0, dot), tool: toolName.slice(dot + 1) } + : null; +} + +export type AppActivityPhase = "running" | "done" | "failed"; + +export interface AppActivity { + readonly brand: AppBrand; + /** Sentence-case action, tense matched to the phase: "Searching Gmail" / "Searched Gmail". */ + readonly label: string; + /** The most useful argument, e.g. the search query or the destination. */ + readonly detail: string | null; + readonly phase: AppActivityPhase; + /** A finished merge: clients play the one-shot merged transition. */ + readonly merged: boolean; +} + +type Verb = readonly [pattern: RegExp, running: string, done: string]; + +/** First match wins; `{app}` is the brand name. */ +const VERBS: ReadonlyArray = [ + [/merge/, "Merging the pull request", "Merged the pull request"], + [/(estimate|price|quote|fare)/, "Checking {app} prices", "Checked {app} prices"], + [ + /(request|book|order|reserve)_?ride|^ride_request|request_?trip/, + "Requesting an {app}", + "Requested an {app}", + ], + [/(order|checkout|place_order)/, "Placing an order on {app}", "Placed an order on {app}"], + [/(status|track|eta)/, "Checking the {app} status", "Checked the {app} status"], + [/(direction|route|navigate|travel_?time)/, "Getting directions", "Got directions"], + [/(send|reply|draft|compose)/, "Writing in {app}", "Wrote in {app}"], + [/(create|add|schedule|insert)_?(event|meeting)/, "Adding to {app}", "Added to {app}"], + [/(search|find|query|list|lookup|places)/, "Searching {app}", "Searched {app}"], + [/(read|get|fetch|open|view)/, "Reading from {app}", "Read from {app}"], + [/(play|queue)/, "Playing on {app}", "Played on {app}"], +]; + +const DETAIL_KEYS = [ + "query", + "q", + "search", + "destination", + "dropoff", + "to", + "title", + "subject", + "text", + "address", + "name", +]; + +function detailFrom(input: unknown): string | null { + if (input === null || typeof input !== "object" || Array.isArray(input)) return null; + const record = input as Record; + for (const key of DETAIL_KEYS) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value.trim().slice(0, 120); + } + const number = record.number ?? record.pull_number ?? record.pr; + return typeof number === "number" && Number.isSafeInteger(number) ? `#${number}` : null; +} + +/** + * Presentation for one tool call, or null when the server is not a known brand. + * `status` takes T3 tool lifecycle values (`inProgress`, `completed`, `failed`, ...). + */ +export function resolveAppActivity( + toolName: string | undefined, + input: unknown, + status: string | undefined, +): AppActivity | null { + if (!toolName) return null; + const parts = splitToolName(toolName); + const brand = parts ? brandForServer(parts.server) : null; + if (!parts || !brand) return null; + const phase: AppActivityPhase = + status === "failed" || status === "declined" + ? "failed" + : status === "completed" + ? "done" + : "running"; + const tool = parts.tool.toLowerCase(); + const verb = VERBS.find(([pattern]) => pattern.test(tool)); + const [running, done] = verb ? [verb[1], verb[2]] : ["Using {app}", "Used {app}"]; + const named = (template: string) => template.split("{app}").join(brand.name); + return { + brand, + label: + phase === "failed" + ? `${named(running)} didn't finish` + : named(phase === "running" ? running : done), + detail: detailFrom(input), + phase, + merged: phase === "done" && /merge/.test(tool), + }; +} diff --git a/packages/client-runtime/src/octicons.ts b/packages/client-runtime/src/octicons.ts new file mode 100644 index 0000000000000..7e4742dd86599 --- /dev/null +++ b/packages/client-runtime/src/octicons.ts @@ -0,0 +1,42 @@ +/** + * Primer Octicons used by the GitHub app card: 16px viewBox path data, one or more paths per icon. + * From @primer/octicons 19.42.0, MIT License, Copyright (c) GitHub Inc. + */ +export const OCTICONS = { + "git-pull-request": [ + "M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z", + ], + "git-pull-request-closed": [ + "M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z", + ], + "git-pull-request-draft": [ + "M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 14a2.25 2.25 0 1 1 0-4.5 2.25 2.25 0 0 1 0 4.5ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM14 7.5a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Zm0-4.25a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Z", + ], + "git-merge": [ + "M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z", + ], + "git-commit": [ + "M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z", + ], + check: [ + "M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z", + ], + "check-circle-fill": [ + "M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z", + ], + "x-circle-fill": [ + "M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z", + ], + skip: [ + "M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z", + ], + "dot-fill": ["M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"], + alert: [ + "M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z", + ], + clock: [ + "M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z", + ], +} as const satisfies Record>; + +export type OcticonName = keyof typeof OCTICONS; diff --git a/packages/client-runtime/src/openUiAppCards.test.ts b/packages/client-runtime/src/openUiAppCards.test.ts new file mode 100644 index 0000000000000..f9a4f10f9b5fb --- /dev/null +++ b/packages/client-runtime/src/openUiAppCards.test.ts @@ -0,0 +1,109 @@ +import * as NodeAssert from "node:assert/strict"; +import { it as test } from "vite-plus/test"; + +import { + appCardProblem, + avatarColor, + carClass, + checkTimeline, + formatSeconds, + cheapestRide, + checkCounts, + rideReadout, + type PullRequestCheck, +} from "./openUiAppCards.ts"; +import { chartPanelProblem, streamingChartBoard } from "./openUiCharts.ts"; + +const options = [ + { 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 }, +]; + +test("ride options read out and tag the cheapest", () => { + NodeAssert.equal(rideReadout(options[0]!), "UberX · pickup in 4 min · $31.20 · 4 seats"); + NodeAssert.equal(cheapestRide(options), "UberX"); + NodeAssert.equal( + cheapestRide([{ name: "Wait & Save", minutes: 12, price: "Price on request", seats: 4 }]), + null, + ); + NodeAssert.match( + appCardProblem({ + typeName: "RideOptions", + props: { app: "uber", options: [...options, options[0]!] }, + })!, + /same option twice/, + ); +}); + +test("pull request states must agree with checks and conflicts", () => { + const checks: ReadonlyArray = [ + { name: "Tests", status: "pass", detail: "42 passed" }, + { name: "Lint", status: "pass", detail: "" }, + ]; + const pr = ( + state: "open" | "conflict" | "ready" | "merged" | "closed", + conflicts: number, + list = checks, + ) => ({ typeName: "PullRequest" as const, props: { state, checks: list, conflicts } }); + NodeAssert.equal(appCardProblem(pr("ready", 0)), null); + NodeAssert.equal(appCardProblem(pr("conflict", 3)), null); + NodeAssert.match(appCardProblem(pr("conflict", 0))!, /conflict count/); + NodeAssert.match(appCardProblem(pr("merged", 2))!, /cannot have conflicts/); + NodeAssert.match( + appCardProblem(pr("ready", 0, [{ name: "Tests", status: "running", detail: "" }]))!, + /every check/, + ); + NodeAssert.equal( + chartPanelProblem(pr("merged", 0)), + null, + "the chart board inspector delegates to app cards", + ); + NodeAssert.deepEqual(checkCounts([...checks, { name: "Types", status: "fail", detail: "" }]), { + pass: 2, + fail: 1, + running: 0, + skip: 0, + }); +}); + +test("a sender keeps one avatar color regardless of case or spacing", () => { + NodeAssert.equal(avatarColor("Acme Billing"), avatarColor(" acme billing ")); + NodeAssert.match(avatarColor("Dana Ruiz"), /^#[0-9a-f]{6}$/); +}); + +test("ride names map to a vehicle glyph", () => { + NodeAssert.deepEqual( + ["UberX", "Comfort", "UberXL", "Black", "Black SUV", "Lyft XL", "Wait & Save"].map(carClass), + ["x", "comfort", "xl", "black", "xl", "xl", "x"], + ); +}); + +test("checks with timing become gantt spans; any untimed check falls back", () => { + const timeline = checkTimeline([ + { started: 0, seconds: 11 }, + { started: 41, seconds: 33 }, + ]); + NodeAssert.equal(timeline?.total, 74); + NodeAssert.deepEqual(timeline?.spans[1], { start: 41 / 74, length: 33 / 74 }); + NodeAssert.equal(checkTimeline([{ started: 0, seconds: 3 }, {}]), null); + NodeAssert.equal(formatSeconds(64), "1m 4s"); + NodeAssert.equal(formatSeconds(38), "38s"); +}); + +test("a streaming board shows the panels whose statements are complete", () => { + const root = 'root = Board("Trip", "Demo.", [mail, ride, route])\n'; + const mail = 'mail = MailList("q", [])\n'; + NodeAssert.equal(streamingChartBoard(root), null); + NodeAssert.equal( + streamingChartBoard(root + mail + 'ride = RideOptions("ub'), + root.replace("[mail, ride, route]", "[mail]") + mail.trimEnd(), + ); + NodeAssert.equal(streamingChartBoard('root = Board("Trip", "Demo.", [mail'), null); + NodeAssert.equal(streamingChartBoard(mail), null); + // An open fence arrives with a newline after the partial line. + NodeAssert.equal( + streamingChartBoard(`${root}${mail}ride = Ride\n`), + root.replace("[mail, ride, route]", "[mail]") + mail.trimEnd(), + ); +}); diff --git a/packages/client-runtime/src/openUiAppCards.ts b/packages/client-runtime/src/openUiAppCards.ts new file mode 100644 index 0000000000000..654a3f11a9f8f --- /dev/null +++ b/packages/client-runtime/src/openUiAppCards.ts @@ -0,0 +1,216 @@ +/** + * App cards for the `openui-v2` catalog: rides, mail, routes and pull requests in their app's colors. + * Read-only like the charts: taps stay local to the card and nothing is booked, sent or merged. + * Design credits: docs/user/openui-reports.md. + */ +import { APP_BRANDS, type AppBrand } from "./appConnectors.ts"; + +export const APP_CARD_TYPES = ["RideOptions", "MailList", "Route", "PullRequest"] as const; +export type AppCardType = (typeof APP_CARD_TYPES)[number]; + +export const APP_CARD_LIMITS = { + text: 200, + snippet: 400, + price: 24, + rideOptions: 8, + messages: 20, + steps: 20, + checks: 12, + commits: 12, + pathPoints: 600, + url: 2_000, + minutes: 100_000, +} as const; + +export const APP_CARD_DESCRIPTIONS: Readonly> = { + RideOptions: + 'Ride choices a ride app returned for one trip: app is "uber" or "lyft"; each option has a name, pickup minutes, a price string, seats and optionally an arrival time text.', + MailList: + "Messages a mail search returned: the query, then each message's sender, subject, snippet, time text, unread flag and optionally one attachment file name.", + Route: + "Directions a maps app returned: origin, destination, mode (drive, walk, transit or bike), minutes, a distance string, then the steps in order. Optionally the road it takes (via), the route geometry the tool returned as [lat, lon] points (path) and the static basemap it returned as {image, bounds: [south, west, north, east]} (map); never invent a path or a map.", + PullRequest: + "One pull request: number, title, state (open, conflict, ready, merged or closed), head and base branches, its checks (pass, fail, running or skip, optionally with the second each started and how many seconds it ran), the conflict count, and optionally its recent commits newest first (title, short sha, author, age text, lane base, head or side, an optional branch label on a lane's tip, and conflict true on base commits that conflict).", +}; + +export const APP_CARD_PROMPT_RULES = [ + "App cards only show what a connector tool returned in this turn. Copy values exactly; never invent prices, times, messages or check results.", + "PullRequest state conflict needs a conflict count above 0; ready means every check passed or was skipped; merged and closed have no conflicts.", +] as const; + +export const APP_CARD_EXAMPLE = + 'root = Board("Ride to SFO", "UberX is the cheapest option.", [rides])\nrides = 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}])'; + +export interface RideOption { + readonly name: string; + readonly minutes: number; + readonly price: string; + readonly seats: number; +} +export interface MailMessage { + readonly from: string; + readonly subject: string; + readonly snippet: string; + readonly time: string; + readonly unread: boolean; +} +export type CheckStatus = "pass" | "fail" | "running" | "skip"; +export interface PullRequestCheck { + readonly name: string; + readonly status: CheckStatus; + readonly detail: string; +} +export type PullRequestState = "open" | "conflict" | "ready" | "merged" | "closed"; + +export type AppCardPanel = + | { + readonly typeName: "RideOptions"; + readonly props: { + readonly app: "uber" | "lyft"; + readonly options: ReadonlyArray; + }; + } + | { + readonly typeName: "MailList"; + readonly props: { readonly messages: ReadonlyArray }; + } + | { readonly typeName: "Route"; readonly props: { readonly steps: ReadonlyArray } } + | { + readonly typeName: "PullRequest"; + readonly props: { + readonly state: PullRequestState; + readonly checks: ReadonlyArray; + readonly conflicts: number; + }; + }; + +export function isAppCardType(value: unknown): value is AppCardType { + return typeof value === "string" && (APP_CARD_TYPES as ReadonlyArray).includes(value); +} + +/** Cross-field rules the schemas cannot express. */ +export function appCardProblem(panel: AppCardPanel): string | null { + switch (panel.typeName) { + case "RideOptions": { + const names = panel.props.options.map((option) => option.name); + return new Set(names).size === names.length + ? null + : "RideOptions lists the same option twice."; + } + case "MailList": + case "Route": + return null; + case "PullRequest": { + const { state, checks, conflicts } = panel.props; + if (state === "conflict" && conflicts < 1) + return "PullRequest in conflict needs a conflict count."; + if (state !== "conflict" && state !== "open" && conflicts > 0) + return `PullRequest ${state} cannot have conflicts.`; + if ( + state === "ready" && + checks.some((check) => check.status === "fail" || check.status === "running") + ) + return "PullRequest ready needs every check passed or skipped."; + return null; + } + } +} + +export function rideBrand(app: "uber" | "lyft"): AppBrand { + return APP_BRANDS[app]; +} + +export function rideReadout(option: RideOption) { + return `${option.name} · pickup in ${option.minutes} min · ${option.price} · ${option.seats} seats`; +} + +/** The cheapest option by the numeric part of its price, for the "Best price" tag. */ +export function cheapestRide(options: ReadonlyArray): string | null { + let best: { name: string; value: number } | null = null; + for (const option of options) { + const value = Number(option.price.replace(/[^0-9.]/g, "")); + if ( + Number.isFinite(value) && + option.price.match(/[0-9]/) && + (best === null || value < best.value) + ) + best = { name: option.name, value }; + } + return best?.name ?? null; +} + +export function checkCounts(checks: ReadonlyArray) { + const counts = { pass: 0, fail: 0, running: 0, skip: 0 }; + for (const check of checks) counts[check.status] += 1; + return counts; +} + +/** x of a commit-graph lane, in px from the card's left edge. */ +export function commitLaneX(lane: "base" | "head" | "side") { + return lane === "head" ? 34 : lane === "side" ? 46 : 22; +} + +const AVATAR_COLORS = [ + "#5b6cf0", + "#0f9d73", + "#e37400", + "#c2410c", + "#7c3aed", + "#0284c7", + "#be185d", + "#4d7c0f", +]; + +/** A stable avatar color for a sender, so one sender keeps one color across cards. */ +export function avatarColor(name: string) { + let hash = 0; + for (const char of name.trim().toLowerCase()) hash = (hash * 31 + char.codePointAt(0)!) >>> 0; + return AVATAR_COLORS[hash % AVATAR_COLORS.length]!; +} + +export type CarClass = "x" | "comfort" | "xl" | "black"; + +/** Which vehicle glyph a ride option gets, from its product name. */ +export function carClass(name: string): CarClass { + const n = name.toLowerCase(); + if (/\b(xl|xxl|suv|van|6|7)\b|xl$/.test(n)) return "xl"; + if (/black|lux|premier|select/.test(n)) return "black"; + if (/comfort|priority|extra/.test(n)) return "comfort"; + return "x"; +} + +export interface CheckSpan { + readonly start: number; + readonly length: number; +} + +/** + * Gantt bars for checks that report timing: each bar's start and length as fractions of the run. + * Returns null when any check lacks timing, so the card falls back to chips. + */ +export function checkTimeline( + checks: ReadonlyArray<{ + readonly started?: number | undefined; + readonly seconds?: number | undefined; + }>, +): { readonly total: number; readonly spans: ReadonlyArray } | null { + if ( + checks.length === 0 || + checks.some((check) => check.started === undefined || check.seconds === undefined) + ) + return null; + const total = Math.max(1, ...checks.map((check) => check.started! + check.seconds!)); + return { + total, + spans: checks.map((check) => ({ + start: check.started! / total, + length: check.seconds! / total, + })), + }; +} + +/** "1m 4s", "38s". */ +export function formatSeconds(seconds: number) { + const s = Math.round(seconds); + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; +} diff --git a/packages/client-runtime/src/openUiCharts.ts b/packages/client-runtime/src/openUiCharts.ts index 8947ad9128124..f690f0a7281dd 100644 --- a/packages/client-runtime/src/openUiCharts.ts +++ b/packages/client-runtime/src/openUiCharts.ts @@ -7,6 +7,8 @@ * zod and OpenUI copies, which keeps duplicate React/zod instances out of either bundle. */ +import { APP_CARD_EXAMPLE, APP_CARD_PROMPT_RULES, appCardProblem, isAppCardType, type AppCardPanel } from "./openUiAppCards.ts"; + export const OPENUI_CHART_FENCE = "openui-v2"; /** Bounds every client applies to the schemas. */ @@ -43,9 +45,11 @@ export const OPENUI_CHART_PROMPT = { "Use each component's arguments in declared order.", "StackedArea series need one value per category; Heatmap counts need one row per row label and one count per column label.", "After the fenced block include a normal Markdown summary for clients without the renderer.", + ...APP_CARD_PROMPT_RULES, ], examples: [ 'root = Board("Churn", "Login is the hottest file.", [map, top])\nmap = Treemap("Size and churn", "LOC", "commits", [{path: "src/login.ts", size: 80, color: 12}, {path: "src/ui.ts", size: 120, color: 4}])\ntop = BarChart("Most commits", "commits", [{label: "login.ts", value: 12}, {label: "ui.ts", value: 4}])', + APP_CARD_EXAMPLE, ], }; @@ -72,6 +76,25 @@ export type OpenUiChartStatus = const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; /** Decide whether a parsed board may render: read-only, complete, schema-valid and coherent. */ +/** + * What a streaming board can show so far: its complete statements, with the root's panel list cut + * to the panels already defined, so cards appear one by one. Null until the root and one panel + * are complete. Only for a fence that is still streaming; a finished fence is shown whole or not. + */ +export function streamingChartBoard(code: string): string | null { + // The last line is still arriving; Markdown hands an open fence over with a newline appended. + const body = code.replace(/\n$/, ""); + const lines = body.slice(0, body.lastIndexOf("\n") + 1).split("\n").filter((line) => line.trim()); + const defined = new Set(lines.map((line) => /^\s*([A-Za-z_]\w*)\s*=/.exec(line)?.[1])); + const rootIndex = lines.findIndex((line) => /^\s*root\s*=/.test(line)); + const panels = rootIndex < 0 ? null : /\[([^[\]]*)\]\)\s*$/.exec(lines[rootIndex]!); + if (!panels) return null; + const kept = panels[1]!.split(",").map((name) => name.trim()).filter((name) => defined.has(name)); + if (kept.length === 0) return null; + lines[rootIndex] = `${lines[rootIndex]!.slice(0, panels.index)}[${kept.join(", ")}])`; + return lines.join("\n"); +} + export function inspectParsedChartBoard( parsed: ParsedChartProgram, panelSchemas: Readonly>, @@ -88,7 +111,7 @@ export function inspectParsedChartBoard( } for (const panel of panels) { const typeName = isRecord(panel) ? panel.typeName : undefined; - if (typeName !== "Treemap" && typeName !== "BarChart" && typeName !== "StackedArea" && typeName !== "Heatmap") { + if (typeName !== "Treemap" && typeName !== "BarChart" && typeName !== "StackedArea" && typeName !== "Heatmap" && !isAppCardType(typeName)) { return { kind: "invalid", reason: "Chart board contains an unknown panel." }; } const props = panelSchemas[typeName](isRecord(panel) ? panel.props : undefined); @@ -351,7 +374,8 @@ export type ChartPanel = | { readonly typeName: "Treemap"; readonly props: { readonly rows: ReadonlyArray } } | { readonly typeName: "BarChart"; readonly props: { readonly bars: ReadonlyArray<{ readonly label: string; readonly value: number }> } } | { readonly typeName: "StackedArea"; readonly props: { readonly categories: ReadonlyArray; readonly series: ReadonlyArray } } - | { readonly typeName: "Heatmap"; readonly props: { readonly rows: ReadonlyArray; readonly columns: ReadonlyArray; readonly counts: ReadonlyArray> } }; + | { readonly typeName: "Heatmap"; readonly props: { readonly rows: ReadonlyArray; readonly columns: ReadonlyArray; readonly counts: ReadonlyArray> } } + | AppCardPanel; /** Cross-field rules the schemas cannot express; returns a reader-facing reason or null. */ export function chartPanelProblem(panel: ChartPanel): string | null { @@ -382,5 +406,7 @@ export function chartPanelProblem(panel: ChartPanel): string | null { const index = counts.findIndex((row) => row.length !== columns.length); return index >= 0 ? `Heatmap row "${rows[index]}" needs ${columns.length} counts.` : null; } + default: + return appCardProblem(panel); } } diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 28caf074e4fc0..d230342f0e9f8 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -4,7 +4,10 @@ import { ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/con import * as DateTime from "effect/DateTime"; import { + resolveWorkEntryAppActivity, resolveWorkEntryToolPresentation, + summarizeToolGroup, + workEntryAppActivityLabel, toolItemForDisplay, type WorkLogPresentationEntry, workEntryIndicatesToolFailure, @@ -189,3 +192,94 @@ describe("resolveWorkEntryToolPresentation", () => { ).toBeNull(); }); }); + +describe("resolveWorkEntryAppActivity", () => { + it.each([ + [{ label: "mcp__editor_gmail_3f2a9c0d__search_threads" }, "Searching Gmail"], + [ + { + label: "Gmail search", + toolData: { server: "gmail", tool: "search_threads", arguments: { query: "invoices" } }, + }, + "Searching Gmail", + ], + [ + { label: "uber.request_ride", toolLifecycleStatus: "completed" as const }, + "Requested an Uber", + ], + ])("brands connector tools across providers: %o", (fields, label) => { + expect(resolveWorkEntryAppActivity({ ...fields })?.label).toBe(label); + }); + + it("reads the detail from structured arguments and leaves other tools alone", () => { + expect( + resolveWorkEntryAppActivity({ + label: "Gmail search", + toolData: { server: "gmail", tool: "search_threads", arguments: { query: "invoices" } }, + })?.detail, + ).toBe("invoices"); + expect(resolveWorkEntryAppActivity({ label: "T3-code.preview_click" })).toBeNull(); + expect(resolveWorkEntryAppActivity({ label: "Ran command" })).toBeNull(); + }); + + it("reads Hermes connector calls, whose tool name carries the detail after a colon", () => { + const activity = resolveWorkEntryAppActivity({ + label: "search messages", + structuredPayload: { + type: "dynamic_tool", + toolName: "editor_gmail_63ea3519.search_messages: invoices from Acme", + input: { query: "invoices from Acme" }, + } as never, + toolLifecycleStatus: "completed", + }); + expect(activity && [activity.brand.name, activity.label, activity.detail]).toEqual([ + "Gmail", + "Searched Gmail", + "invoices from Acme", + ]); + }); +}); + +describe("workEntryAppActivityLabel", () => { + it("joins the sentence and detail, with the live status taking precedence", () => { + const entry = { + label: "get pull request", + structuredPayload: { + type: "dynamic_tool", + toolName: "editor_github_2f7f86e1.get_pull_request", + input: { number: 128 }, + } as never, + toolLifecycleStatus: "completed" as const, + }; + expect(workEntryAppActivityLabel(entry)).toBe("Read from GitHub · #128"); + expect(workEntryAppActivityLabel(entry, "inProgress")).toBe("Reading from GitHub · #128"); + expect(workEntryAppActivityLabel({ label: "Ran command" })).toBeNull(); + }); +}); + +describe("summarizeToolGroup", () => { + const sourced = (key: string, name: string) => + ({ + id: key, + createdAt: "2026-11-03T00:00:00.000Z", + label: name, + tone: "tool", + itemType: "dynamic_tool", + toolSource: { key: `mcp:${key}`, name, kind: "integration" }, + }) as unknown as WorkLogPresentationEntry; + + it("names connector apps by brand instead of their renamed MCP servers", () => { + const summary = summarizeToolGroup([ + sourced("editor_gmail_63ea3519", "editor gmail 63ea3519"), + sourced("editor_uber_46f286a1", "editor uber 46f286a1"), + sourced("editor_google_maps_0e4b678a", "editor google maps 0e4b678a"), + ]).summary; + expect(summary).toBe("Used Gmail, Uber, and Google Maps"); + }); + + it("keeps the integration wording for servers that are not known apps", () => { + expect(summarizeToolGroup([sourced("linear", "Linear")]).summary).toBe( + "Used Linear integration", + ); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 1d884f0b54eb9..9e95c3dd10ae3 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -15,6 +15,7 @@ import { type T3McpToolDefinition, type T3McpToolSummaryAction, } from "@t3tools/shared/t3McpToolPresentation"; +import { brandForServer, resolveAppActivity } from "@t3tools/client-runtime/app-connectors"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; @@ -242,6 +243,31 @@ export function resolveWorkEntryToolPresentation( ); } +/** Brand-colored activity for tools from known apps (Gmail, Uber, Maps, ...), or null. */ +export function resolveWorkEntryAppActivity( + entry: Pick< + WorkLogPresentationEntry, + "label" | "toolTitle" | "toolData" | "toolLifecycleStatus" | "structuredPayload" + >, +) { + return resolveAppActivity( + workEntryToolName(entry), + workEntryToolInput(entry), + entry.toolLifecycleStatus, + ); +} + +/** "Searching Gmail · invoices" for known apps, or null; `status` overrides the entry's own. */ +export function workEntryAppActivityLabel( + entry: Parameters[0], + status?: string, +): string | null { + const activity = resolveWorkEntryAppActivity( + status === undefined ? entry : { ...entry, toolLifecycleStatus: status as never }, + ); + return activity ? [activity.label, activity.detail].filter(Boolean).join(" · ") : null; +} + function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -571,14 +597,18 @@ export function summarizeToolGroup(entries: ReadonlyArray label); if (sources.size > 0) { const sourceValues = [...sources.values()]; - const sourceNames = [...new Set(sourceValues.map((source) => source.name))]; + const brands = sourceValues.map((source) => brandForServer(source.name)); + const sourceNames = [ + ...new Set(sourceValues.map((source, index) => brands[index]?.name ?? source.name)), + ]; const formattedNames = sourceNames.length < 2 ? sourceNames[0]! : sourceNames.length === 2 ? sourceNames.join(" and ") : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`; - const allIntegrations = sourceValues.every((source) => source.kind === "integration"); + const allIntegrations = + !brands.every(Boolean) && sourceValues.every((source) => source.kind === "integration"); labels.unshift( `Used ${formattedNames}${allIntegrations ? ` ${sourceNames.length === 1 ? "integration" : "integrations"}` : ""}`, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4462481e14d9..420ea72ae404f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,15 @@ importers: '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 + '@expo-google-fonts/google-sans': + specifier: ^0.4.4 + version: 0.4.4 + '@expo-google-fonts/inter-tight': + specifier: ^0.4.2 + version: 0.4.2 + '@expo-google-fonts/mona-sans': + specifier: ^0.4.1 + version: 0.4.1 '@expo/metro-runtime': specifier: ~58.0.14 version: 58.0.14(@expo/log-box@58.0.12)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -1045,9 +1054,18 @@ importers: '@effect/atom-react': specifier: 4.0.5 version: 4.0.5(effect@4.0.5)(react@19.3.0)(scheduler@0.28.0) + '@fontsource-variable/google-sans': + specifier: ^5.3.2 + version: 5.3.2 '@fontsource-variable/inter': specifier: ^5.3.0 version: 5.3.0 + '@fontsource-variable/inter-tight': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/mona-sans': + specifier: ^5.3.0 + version: 5.3.0 '@legendapp/list': specifier: 3.3.5 version: 3.3.5(patch_hash=6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -4043,9 +4061,18 @@ packages: '@expo-google-fonts/dm-sans@0.4.2': resolution: {integrity: sha512-e/fLH5aCJzkEenz7axvEn8N7IJAKhUwNjIxS75h9j+Ip1M9S7d22M19gJN3A7QW4egVkBJRyQh1hQFhngQn9yA==} + '@expo-google-fonts/google-sans@0.4.4': + resolution: {integrity: sha512-6lx67hqW5HDZEHu4tY0DBr3OlTrCjrbMLITQMeykX6GeXVAtjSC34ShqjkFHELAQD0cmykfGK+GzHWjjo3TRDQ==} + + '@expo-google-fonts/inter-tight@0.4.2': + resolution: {integrity: sha512-/b4YLh8v145nMfrtw0FF2EB8eRhw/SVt/LdhzN1+Faz/uSFvZkF7EtxfipV8iacDSb1PUqyssHd0tZjlzhhf2g==} + '@expo-google-fonts/material-symbols@0.4.38': resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==} + '@expo-google-fonts/mona-sans@0.4.1': + resolution: {integrity: sha512-lwr4o4HWTj9oiBEPotlTX6VarveV9WJAOBPn3k3A98sQaQ3jFGI3Xld8zDp1/ZswAR4cmhxGMJLDqHilh1mojA==} + '@expo/cli@58.1.11': resolution: {integrity: sha512-wkFTSLXXYqLkRT5oy4DnLuTOtbFIu3qTiXHSHkFSqUBTHiE92y3bOIwM3UN7Y1ys18qVJge8lXjezaEfsHmijQ==} hasBin: true @@ -4332,9 +4359,18 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource-variable/google-sans@5.3.2': + resolution: {integrity: sha512-fN+fO2Ffm+n2G5K+UK4ynP5WrWT+M/dbBs9HZaYcgAT5evhefJJH2MlGj8yMz+8slQmhjWAqPJyJ1sELkUM4Uw==} + + '@fontsource-variable/inter-tight@5.3.0': + resolution: {integrity: sha512-ZhZZ29sNZ15P1K+cUt13GeBRYON0akgmNiftshUeDLos7X7zGcRgPzt434879Xhq1QbwlW3M/apBMk7jWabwvA==} + '@fontsource-variable/inter@5.3.0': resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + '@fontsource-variable/mona-sans@5.3.0': + resolution: {integrity: sha512-PkxRB3KI4z9Gjpk7vVqgO3GMSyQuOIIj4TaRDbvbcDj8SzWwI38jvLTQxJM/cTRorhcT7h8qjcllR+qjRtM/Fg==} + '@hono/node-server@1.19.17': resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} @@ -14014,8 +14050,14 @@ snapshots: '@expo-google-fonts/dm-sans@0.4.2': {} + '@expo-google-fonts/google-sans@0.4.4': {} + + '@expo-google-fonts/inter-tight@0.4.2': {} + '@expo-google-fonts/material-symbols@0.4.38': {} + '@expo-google-fonts/mona-sans@0.4.1': {} + '@expo/cli@58.1.11(5e998cea2b9eedfd9ad81915214fc418)': dependencies: 2g: 1.0.1(@tsc-rs/typescript@0.8.0) @@ -14493,8 +14535,14 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fontsource-variable/google-sans@5.3.2': {} + + '@fontsource-variable/inter-tight@5.3.0': {} + '@fontsource-variable/inter@5.3.0': {} + '@fontsource-variable/mona-sans@5.3.0': {} + '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: hono: 4.13.7