feat(trigger-chat-agent): AI chat agent that teaches Trigger.dev by drawing - #126
feat(trigger-chat-agent): AI chat agent that teaches Trigger.dev by drawing#126D-K-P wants to merge 31 commits into
Conversation
Fork of clickhouse-chat-agent, reworked from data analytics to learning Trigger.dev. Strips ClickHouse (client, listTables/describeTable/runQuery tools, PointMap/maplibre) and the chart cards; swaps in an interactive React Flow + dagre FlowGraph plus code/diagram/prompt/stat cards rendered from json-render specs. The chat.agent() keeps the versioned AI Prompt and renderVisualization validate/retry loop, and merges a docs MCP server (default Context7, DOCS_MCP_URL) into its tool set each turn to ground answers on the live docs without inventing API surface.
Replace the parked shadcn-neutral styling with the Trigger.dev Launch Week look: charcoal surfaces, apple-green accent, Satoshi titles. Adds the brand palette as real CSS variables (so the React Flow SVG edge layer can read them) and remaps the shadcn semantic tokens so the chat shell and the json-render primitives inherit the look with no per-component overrides. Satoshi loads from the Fontshare CDN via a <link> in the layout.
Turn the agent into a tutor, not just a diagram renderer. Adds a sandboxed HTML `Lesson` catalog component (model-authored Tufte-style lesson with an interactive quiz, rendered in an allow-scripts-only iframe so its JS can't reach the app origin or session token) and a `suggestNext` tool that ends every turn with clickable deeper / sideways / practice / topic chips to keep the learning flowing. Rewrites the versioned prompt around a teaching method adapted from Matt Pocock's "teach" skill: mission-first, one tangible win per turn, knowledge then a retrieval quiz, everything grounded in the docs MCP. The empty state seeds start-here / go-deeper goals plus a docs-grounded "suggest more topics".
…cted HTML Adversarial review confirmed the sandbox (allow-scripts, no allow-same-origin) already blocks app-origin XSS and reading the session token, but a lesson could still beacon out or render a token-phishing form. Inject a strict CSP into the lesson document — connect-src/form-action 'none', img-src data: only — closing the exfiltration and phishing channels while keeping the quiz scripts, inline styles, and Fontshare fonts working. Also harden the resize postMessage handler against non-finite heights.
…ender Add a pre-render screening layer for model-authored lesson HTML, on top of the sandbox + CSP. In the renderVisualization tool (server-side, before anything reaches the browser) every Lesson is vetted by a deterministic red-flag scan (lesson-screen.ts — network calls, forms, credential inputs, cookie/storage access, navigation/redirect, nested frames, eval) AND a fan-out of cheap parallel LLM screeners with distinct adversarial lenses (exfiltration, social-engineering). Any hit fails the tool, so the model regenerates a clean lesson through the existing validate-and-retry loop. The static scan is unit tested (attacks blocked, benign quizzes pass). LESSON_SCREENING=off disables the LLM fan-out.
… source The docs a lesson is grounded on are the upstream prompt-injection vector: a poisoned page could steer the agent. Wrap every docs-MCP tool's output before the model sees it (quarantine.ts) — coerce it to text and delimit it as "untrusted reference material: data, not instructions", with an inline flag when injection markers are present. Reinforce the same rule in the system prompt. Unlike lessons, docs can't be regenerated, so this neutralizes rather than blocks; the lesson screen remains the backstop for any malicious output an injection might still induce. Quarantine + flag logic unit tested.
…t execute A live run surfaced a TypeError: the docs-MCP tool's own toModelOutput does `'content' in output`, which throws when the wrapper had replaced execute's result with a plain string. Quarantine in toModelOutput instead (the layer that decides what text the model sees), leaving the raw MCP result shape intact for the SDK. Verified end to end against a real project: grounding, quarantine, renderVisualization (FlowGraph + Lesson), lesson screening, and suggestNext all complete cleanly.
…esign Replace the generic shadcn shell with the Launch Week chat language: lavender user bubbles and translucent charcoal assistant bubbles that grow from their tail corner (bubbleIn), the chat.agent phosphor wordmark, a pill composer with the masked gradient-border focus glow and apple send button, a breathing-dot thinking indicator, word-by-word blur-in on streaming text, and newest-turn-to- top scrolling with edge-fade masks. Adds the lavender token and a deeper charcoal-1000 page background. Cards still render full-width (no bubble).
Bring in the real Launch Week card designs and a set of cheap, data-fill components the model populates instead of authoring HTML. Ports HeroCard (icon badge + kicker + display title) and the rich StatCard (count-up value, delta badge, mini bars) from the marketing catalog, and adds Quiz (interactive multiple-choice with immediate feedback), Callout (tip/warn/note), Steps, Glossary, and Compare. Replaces the plain Stat. Each is a handful of tokens for the model and renders identically every time.
Remove the model-authored HTML `Lesson` and everything it required — the sandboxed iframe (lesson.tsx), the injected CSP, the deterministic scan (lesson-screen.ts) and the LLM screening fan-out in the agent — now that the teaching kit is all data-driven components. Nothing generates markup, so the whole attack surface is gone; the docs-MCP quarantine stays (docs are still untrusted input). Rewrites the prompt to keep responses short and valuable (components carry the density) and to steer the model across the new kit.
… often Drop the per-word blur-in reveal (it read laggy) — assistant text now just renders markdown as tokens stream in, the standard AI SDK approach, which feels snappier. Steer the agent to frequently offer a "paste-ready prompt to scaffold this in my repo" next-step chip and answer it with a PromptCard.
…unclickable cards
Persist conversations to Postgres, gated on DATABASE_URL — unset, the app runs exactly as before with no sidebar; set, you get history, resume-on-reload and delete. Showcases where the chat.agent lifecycle hooks belong: onChatStart creates the row (once per chat), onTurnStart awaits the message write so the question is durable before streaming AND sets the prompt (it fires on continuation runs, where onChatStart doesn't), and onTurnComplete writes the turn plus the resume cursor in one transaction so a refresh can't replay it. Messages live in a single JSON column, so AI SDK upgrades need no migration. Chats are owned by an anonymous cookie id minted in proxy.ts and every query is scoped by it. Drizzle + pg against Supabase; pg avoids the prepared-statement limitation of the transaction pooler.
Dagre was laying out every node as 128px wide — a constant carried over from a design that assumed one-word labels — while the nodes actually rendered 150-300px. Dagre reserved too little room, so siblings on a rank drew on top of each other. Measure each node's width from its content and use that same value for both the layout and the rendered node, so the two can't diverge; the serpentine grid gets one column pitch sized to the widest node. Labels and sublabels are capped in the catalog (22/24 chars) and truncate, and the schema now asks for at most ~10 nodes and 3 branches per node so graphs stay readable in a chat column.
…architecture Nothing was shown when a turn failed — the commonest case being `pnpm dev:trigger` not running, which failed silently. Add an inline error banner with a retry (a banner, not a toast: these are usually setup problems worth leaving on screen) that maps failures to actionable text, including the dev-server case. Sanitise errors server-side via uiMessageStreamOptions.onError so the browser gets a useful message and the full error stays in the run log. Also adds "Explain how this app is built" as a starting question, plus a factual section in the prompt describing this app's architecture — the docs it grounds on don't cover it, so without this the showcase question got a vague answer.
…duplicate prose Restores auto-follow while an answer streams — I'd removed it when the entrance felt jerky, but the blur was the culprit, not the scrolling. It's driven by a ResizeObserver (message count doesn't change while text streams, so a messages-keyed effect never fired) and disarms the moment the reader scrolls up, re-arming at the bottom. The composer now floats over the thread: content scrolls underneath and the last inch masks into the background instead of stopping at a hard edge. Side rules on the column so the overflowing Next chips read as clipped by a boundary, plus a fade on that row. Prompt: say each thing once (prose must not restate a card's contents) and call suggestNext exactly once at the end of a turn — calling it repeatedly was producing duplicated paragraphs and orphaned chip sets. Card text no longer carries its own max-width: inside a card the card is the measure, so a 55ch cap just left half the card empty.
Trigger.dev already persists the conversation: each chat.agent chat is backed by a durable Session that outlives its runs, and the agent rebuilds full history from a snapshot after an idle timeout, crash or redeploy. So the Postgres tables were storing a second copy of what the platform already holds. Removes Drizzle, pg and the schema/migrations, and rebuilds the sidebar on sessions.list(). Owner and title are stamped into session metadata when the session is created, and the list is filtered by owner server-side so a browser only ever receives its own chats. Two API limitations shaped this, both verified against 4.5.9: sessions.update() returns Unauthorized even with a secret key, and triggerConfig.tags is dropped at create time — so tags (which list can filter on server-side) aren't usable, and metadata has to be set up front. Opening an old chat resumes it and the agent remembers everything, but earlier messages aren't redrawn: session.out is trimmed to ~one turn and there's no public API to read the stored transcript. The UI says so rather than looking broken. Verified live: turn runs, metadata persists, owner-scoped list returns only that user's chat.
…er-side
Session tags go in the top-level `tags` field on session create, not
`triggerConfig.tags` (which tags each run the session schedules). That lets
sessions.list({ tag }) do the owner filtering server-side instead of listing
everything and narrowing in memory. Verified against 4.5.9: tags persist on the
session row and the tag filter returns just that owner's chats.
Back to a single-conversation demo: no sidebar, no chat list, no anonymous-user cookie, no /chat/[id] route, no clientData. The example is one static page again and needs nothing but a Trigger project and an Anthropic key. The conversation is still durable — that's the platform's job, not the app's — but nothing in the UI depends on enumerating or reopening past chats, which is what all the removed machinery existed for.
…cost observability
WalkthroughAdds a complete Estimated code review effort: 5 (Critical) | ~120 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (4)
trigger-chat-agent/trigger.config.ts (1)
4-4: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winFail fast when
TRIGGER_PROJECT_REFis missing.The non-null assertion hides a missing environment variable. The config then resolves
projecttoundefined, and the CLI error does not point at the cause. Add an explicit check so the setup step in the README is enforced.♻️ Proposed refactor
import { defineConfig } from "`@trigger.dev/sdk`"; +const projectRef = process.env.TRIGGER_PROJECT_REF; +if (!projectRef) { + throw new Error("TRIGGER_PROJECT_REF is not set. Copy .env.example to .env and set it."); +} + export default defineConfig({ - project: process.env.TRIGGER_PROJECT_REF!, + project: projectRef, runtime: "node-22",🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@trigger-chat-agent/trigger.config.ts` at line 4, Replace the non-null assertion on project in the Trigger configuration with an explicit validation of TRIGGER_PROJECT_REF that fails immediately with a clear missing-environment-variable error, ensuring setup cannot continue with an undefined project value.trigger-chat-agent/package.json (1)
5-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd a
lintscript and ESLint dependencies, or remove the ESLint directive.
src/components/chat.tsxline 172 contains// eslint-disable-next-line react-hooks/exhaustive-deps, but this package declares no ESLint dependency and nolintscript. Users who copy the example get no lint feedback.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@trigger-chat-agent/package.json` around lines 5 - 11, Add a package-level lint script and the required ESLint dependencies/configuration in package.json so the existing directive in Chat component remains effective, or remove that directive if linting is intentionally unsupported; ensure the chosen approach gives copied examples consistent lint behavior.trigger-chat-agent/src/components/chat.tsx (1)
610-619: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThis branch is unreachable; remove it or document why it stays.
groupMessagePartsroutes every part that is not text,tool-renderVisualization, orsuggestNextinto adocsgroup, andMessagerenders those groups withDocsToolChain.MessageParttherefore never receives a generictool-ordynamic-toolpart. The block duplicates the documentation-status presentation and will drift fromDocsToolChain.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@trigger-chat-agent/src/components/chat.tsx` around lines 610 - 619, The generic tool/dynamic-tool branch in MessagePart is unreachable because groupMessageParts routes those parts into the docs group. Remove the startsWith("tool-")/dynamic-tool handling and rely on DocsToolChain for documentation-status rendering, unless there is a demonstrated routing exception that must be documented.trigger-chat-agent/src/lib/quarantine.ts (1)
1-12: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove the stale
lesson-screen.tsreference.No
lesson-screen.tsfile exists in the repository. Update the header comment so it does not reference removed code.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@trigger-chat-agent/src/lib/quarantine.ts` around lines 1 - 12, Update the header comment in the quarantine module to remove the stale lesson-screen.ts reference, while preserving the remaining description of the downstream mitigation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@trigger-chat-agent/.gitignore`:
- Line 2: Update the ignore rules in .gitignore to cover all local environment
variants, including .env.local, .env.development.local, and
.env.production.local, while retaining the existing .env rule.
In `@trigger-chat-agent/src/app/actions.ts`:
- Around line 12-20: Update mintChatAccessToken to require an authenticated
caller and verify that the caller owns or may access the supplied chatId before
invoking auth.createPublicToken; reject unauthenticated or unauthorized
requests. If anonymous sessions are intentionally supported, document that
security requirement explicitly in the action instead of leaving the endpoint
unprotected.
In `@trigger-chat-agent/src/components/chat.tsx`:
- Around line 496-556: Update DocsToolChain to treat both output-available and
output-error states as terminal when computing complete, so failed lookups show
“Complete” and stop the searching animation. Preserve a distinct output-error
row indicator rather than rendering the successful Check icon for failed parts.
In `@trigger-chat-agent/src/components/error-notice.tsx`:
- Around line 53-58: Add role="alert" to the root div returned by the error
notice component so asynchronously rendered failures are announced to assistive
technology, leaving the existing styling and content unchanged.
In `@trigger-chat-agent/src/components/flow-graph.tsx`:
- Around line 450-472: Stabilize the nodes and sequence inputs used by the
status animation useEffect so replaceMessage’s deep-cloned but unchanged data
does not reset statuses or recreate timers. Derive content-based keys or
memoized array values from nodes and sequence, and use those stable dependencies
while preserving updates when their actual contents change.
In `@trigger-chat-agent/src/components/quiz.tsx`:
- Around line 36-62: Update the option buttons rendered in the options.map
callback to remain focusable after answered: replace the disabled attribute with
aria-disabled and guard the onClick handler so setPicked only runs before
answering. Mark the conditional explanation paragraph as an assertive live
region so its appearance is announced to assistive technologies.
In `@trigger-chat-agent/src/components/stat-card.tsx`:
- Around line 73-93: Update the onUpdate formatting in AnimatedValue so animated
numbers retain digit grouping when numText contains commas. Format the
interpolated value with grouping separators before combining it with prefix and
suffix, while preserving decimal precision and existing ungrouped-number
behavior.
In `@trigger-chat-agent/src/components/visualization.tsx`:
- Around line 11-40: Update VisualizationErrorBoundary usage in Visualization to
key the boundary by spec, ensuring repaired specifications mount a fresh
boundary and reset failed state. Add componentDidCatch to
VisualizationErrorBoundary to log rendering errors during development without
changing the existing fallback UI.
In `@trigger-chat-agent/src/lib/catalog.ts`:
- Around line 304-311: Update validateSpec to perform a reachability walk from
the root element using a visited set, following each element’s children and
rejecting any node encountered more than once as a cyclic or repeated reference.
Preserve the existing missing-child-key validation and return the accumulated
validation errors through the current result shape.
In `@trigger-chat-agent/src/trigger/trigger-chat-agent.ts`:
- Around line 48-67: Update loadDocsTools to initialize createMCPClient with
initializationOptions.timeout set to 10,000 ms, and replace client.tools() with
listTools({ options: { timeout: 10_000 } }) followed by
toolsFromDefinitions(...). Clear docsToolsPromise only when loading throws so
failed loads can retry, while preserving a valid empty tool set as a successful
cached result.
---
Nitpick comments:
In `@trigger-chat-agent/package.json`:
- Around line 5-11: Add a package-level lint script and the required ESLint
dependencies/configuration in package.json so the existing directive in Chat
component remains effective, or remove that directive if linting is
intentionally unsupported; ensure the chosen approach gives copied examples
consistent lint behavior.
In `@trigger-chat-agent/src/components/chat.tsx`:
- Around line 610-619: The generic tool/dynamic-tool branch in MessagePart is
unreachable because groupMessageParts routes those parts into the docs group.
Remove the startsWith("tool-")/dynamic-tool handling and rely on DocsToolChain
for documentation-status rendering, unless there is a demonstrated routing
exception that must be documented.
In `@trigger-chat-agent/src/lib/quarantine.ts`:
- Around line 1-12: Update the header comment in the quarantine module to remove
the stale lesson-screen.ts reference, while preserving the remaining description
of the downstream mitigation.
In `@trigger-chat-agent/trigger.config.ts`:
- Line 4: Replace the non-null assertion on project in the Trigger configuration
with an explicit validation of TRIGGER_PROJECT_REF that fails immediately with a
clear missing-environment-variable error, ensuring setup cannot continue with an
undefined project value.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: a2df0c9a-246a-4da6-a147-033700484457
⛔ Files ignored due to path filters (2)
trigger-chat-agent/pnpm-lock.yamlis excluded by!**/pnpm-lock.yamltrigger-chat-agent/src/app/icon.svgis excluded by!**/*.svg
📒 Files selected for processing (34)
README.mdtrigger-chat-agent/.env.exampletrigger-chat-agent/.gitignoretrigger-chat-agent/README.mdtrigger-chat-agent/components.jsontrigger-chat-agent/next.config.tstrigger-chat-agent/package.jsontrigger-chat-agent/postcss.config.mjstrigger-chat-agent/src/app/actions.tstrigger-chat-agent/src/app/globals.csstrigger-chat-agent/src/app/layout.tsxtrigger-chat-agent/src/app/page.tsxtrigger-chat-agent/src/components/chat.tsxtrigger-chat-agent/src/components/code-card.tsxtrigger-chat-agent/src/components/diagram-card.tsxtrigger-chat-agent/src/components/error-notice.tsxtrigger-chat-agent/src/components/flow-graph.tsxtrigger-chat-agent/src/components/hero-card.tsxtrigger-chat-agent/src/components/prompt-card.tsxtrigger-chat-agent/src/components/quiz.tsxtrigger-chat-agent/src/components/stat-card.tsxtrigger-chat-agent/src/components/streaming-text.tsxtrigger-chat-agent/src/components/teaching-cards.tsxtrigger-chat-agent/src/components/visualization.tsxtrigger-chat-agent/src/components/wordmark.tsxtrigger-chat-agent/src/lib/catalog.tstrigger-chat-agent/src/lib/code-theme.tstrigger-chat-agent/src/lib/motion.tstrigger-chat-agent/src/lib/quarantine.tstrigger-chat-agent/src/lib/registry.tsxtrigger-chat-agent/src/lib/utils.tstrigger-chat-agent/src/trigger/trigger-chat-agent.tstrigger-chat-agent/trigger.config.tstrigger-chat-agent/tsconfig.json
| @@ -0,0 +1,6 @@ | |||
| node_modules | |||
| .env | |||
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Ignore all local environment files.
Line 2 does not ignore .env.local, .env.development.local, or .env.production.local. A developer can commit TRIGGER_SECRET_KEY or other credentials when using a standard local environment filename.
Proposed fix
-.env
+.env*
+!.env.example📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| .env | |
| .env* | |
| !.env.example |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@trigger-chat-agent/.gitignore` at line 2, Update the ignore rules in
.gitignore to cover all local environment variants, including .env.local,
.env.development.local, and .env.production.local, while retaining the existing
.env rule.
| export async function mintChatAccessToken(chatId: string) { | ||
| return auth.createPublicToken({ | ||
| scopes: { | ||
| read: { sessions: chatId }, | ||
| write: { sessions: chatId }, | ||
| }, | ||
| expirationTime: "1h", | ||
| }); | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Bind mintChatAccessToken to an authenticated caller, or document the gap.
mintChatAccessToken is an exported server action, so it is a public POST endpoint. It mints a read and write token for any chatId the caller supplies, with no ownership check. Anyone who learns or guesses a session id can read that conversation and send turns as that user for one hour.
The demo may accept anonymous sessions. Users copy this file into real applications, so state the requirement in code.
🔒 Suggested ownership check
export async function mintChatAccessToken(chatId: string) {
+ // SECURITY: this server action is a public endpoint. In a real application,
+ // resolve the signed-in user and verify they own `chatId` before minting.
+ // const user = await getCurrentUser();
+ // if (!user || !(await userOwnsSession(user.id, chatId))) throw new Error("Forbidden");
return auth.createPublicToken({
scopes: {
read: { sessions: chatId },
write: { sessions: chatId },
},
expirationTime: "1h",
});
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function mintChatAccessToken(chatId: string) { | |
| return auth.createPublicToken({ | |
| scopes: { | |
| read: { sessions: chatId }, | |
| write: { sessions: chatId }, | |
| }, | |
| expirationTime: "1h", | |
| }); | |
| } | |
| export async function mintChatAccessToken(chatId: string) { | |
| // SECURITY: this server action is a public endpoint. In a real application, | |
| // resolve the signed-in user and verify they own `chatId` before minting. | |
| // const user = await getCurrentUser(); | |
| // if (!user || !(await userOwnsSession(user.id, chatId))) throw new Error("Forbidden"); | |
| return auth.createPublicToken({ | |
| scopes: { | |
| read: { sessions: chatId }, | |
| write: { sessions: chatId }, | |
| }, | |
| expirationTime: "1h", | |
| }); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@trigger-chat-agent/src/app/actions.ts` around lines 12 - 20, Update
mintChatAccessToken to require an authenticated caller and verify that the
caller owns or may access the supplied chatId before invoking
auth.createPublicToken; reject unauthenticated or unauthorized requests. If
anonymous sessions are intentionally supported, document that security
requirement explicitly in the action instead of leaving the endpoint
unprotected.
| function DocsToolChain({ parts }: { parts: MessagePartValue[] }) { | ||
| const [expanded, setExpanded] = useState(false); | ||
| const complete = parts.every( | ||
| (part) => (part as { state?: string }).state === "output-available", | ||
| ); | ||
|
|
||
| return ( | ||
| <div | ||
| className="rounded-2xl border border-grid-dimmed bg-charcoal-950/60 px-4 py-3" | ||
| aria-label="Documentation lookups" | ||
| > | ||
| <button | ||
| type="button" | ||
| aria-expanded={expanded} | ||
| onClick={() => setExpanded((value) => !value)} | ||
| className="mb-1 flex min-h-10 w-full items-center gap-2 rounded-lg text-left" | ||
| > | ||
| <BookOpen className="size-3.5 text-apple-500" /> | ||
| <span className="font-mono text-2xs uppercase tracking-widest text-dimmed"> | ||
| Grounding in the docs | ||
| </span> | ||
| <span className="ml-auto font-mono text-2xs text-charcoal-500"> | ||
| {complete ? "Complete" : "Searching"} | ||
| </span> | ||
| <ChevronDown | ||
| className={`size-3.5 shrink-0 text-charcoal-500 transition-transform duration-150 ${expanded ? "rotate-180" : ""}`} | ||
| /> | ||
| </button> | ||
| <div className="relative space-y-3 before:absolute before:bottom-2 before:left-[0.4375rem] before:top-2 before:w-px before:bg-grid-bright"> | ||
| {parts.map((part, i) => { | ||
| const done = | ||
| (part as { state?: string }).state === "output-available"; | ||
| const label = docsToolLabel(part); | ||
| return ( | ||
| <div key={i} className="relative flex min-w-0 items-start gap-3"> | ||
| <span className="relative z-10 mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full bg-charcoal-950 text-dimmed"> | ||
| <Globe2 className="size-3.5" /> | ||
| </span> | ||
| <span | ||
| className={`min-w-0 flex-1 text-sm text-dimmed ${expanded ? "break-words leading-5" : "truncate"}`} | ||
| title={label} | ||
| > | ||
| {label} | ||
| </span> | ||
| {done ? ( | ||
| <Check | ||
| className="mt-0.5 size-3.5 shrink-0 text-dimmed" | ||
| aria-label="Complete" | ||
| /> | ||
| ) : ( | ||
| <span | ||
| className="relative mt-1 flex size-2 shrink-0" | ||
| aria-label="Searching" | ||
| > | ||
| <span className="absolute inset-0 animate-ping rounded-full bg-apple-500/60 motion-reduce:animate-none" /> | ||
| <span className="relative size-2 rounded-full bg-apple-500" /> | ||
| </span> | ||
| )} | ||
| </div> | ||
| ); | ||
| })} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Treat terminal error states as finished in DocsToolChain.
complete requires state === "output-available" for every part. A docs tool that ends in output-error never reaches that state. The header then stays on "Searching" and the row keeps the pinging dot after the turn ends. The docs MCP is remote, so this state is reachable.
🐛 Proposed fix
+const TERMINAL_STATES = new Set(["output-available", "output-error"]);
+const isSettled = (part: MessagePartValue) =>
+ TERMINAL_STATES.has((part as { state?: string }).state ?? "");
+
function DocsToolChain({ parts }: { parts: MessagePartValue[] }) {
const [expanded, setExpanded] = useState(false);
- const complete = parts.every(
- (part) => (part as { state?: string }).state === "output-available",
- );
+ const complete = parts.every(isSettled);
@@
- const done =
- (part as { state?: string }).state === "output-available";
+ const done = isSettled(part);Consider also showing a distinct icon when state === "output-error", so a failed lookup is not reported as complete.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function DocsToolChain({ parts }: { parts: MessagePartValue[] }) { | |
| const [expanded, setExpanded] = useState(false); | |
| const complete = parts.every( | |
| (part) => (part as { state?: string }).state === "output-available", | |
| ); | |
| return ( | |
| <div | |
| className="rounded-2xl border border-grid-dimmed bg-charcoal-950/60 px-4 py-3" | |
| aria-label="Documentation lookups" | |
| > | |
| <button | |
| type="button" | |
| aria-expanded={expanded} | |
| onClick={() => setExpanded((value) => !value)} | |
| className="mb-1 flex min-h-10 w-full items-center gap-2 rounded-lg text-left" | |
| > | |
| <BookOpen className="size-3.5 text-apple-500" /> | |
| <span className="font-mono text-2xs uppercase tracking-widest text-dimmed"> | |
| Grounding in the docs | |
| </span> | |
| <span className="ml-auto font-mono text-2xs text-charcoal-500"> | |
| {complete ? "Complete" : "Searching"} | |
| </span> | |
| <ChevronDown | |
| className={`size-3.5 shrink-0 text-charcoal-500 transition-transform duration-150 ${expanded ? "rotate-180" : ""}`} | |
| /> | |
| </button> | |
| <div className="relative space-y-3 before:absolute before:bottom-2 before:left-[0.4375rem] before:top-2 before:w-px before:bg-grid-bright"> | |
| {parts.map((part, i) => { | |
| const done = | |
| (part as { state?: string }).state === "output-available"; | |
| const label = docsToolLabel(part); | |
| return ( | |
| <div key={i} className="relative flex min-w-0 items-start gap-3"> | |
| <span className="relative z-10 mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full bg-charcoal-950 text-dimmed"> | |
| <Globe2 className="size-3.5" /> | |
| </span> | |
| <span | |
| className={`min-w-0 flex-1 text-sm text-dimmed ${expanded ? "break-words leading-5" : "truncate"}`} | |
| title={label} | |
| > | |
| {label} | |
| </span> | |
| {done ? ( | |
| <Check | |
| className="mt-0.5 size-3.5 shrink-0 text-dimmed" | |
| aria-label="Complete" | |
| /> | |
| ) : ( | |
| <span | |
| className="relative mt-1 flex size-2 shrink-0" | |
| aria-label="Searching" | |
| > | |
| <span className="absolute inset-0 animate-ping rounded-full bg-apple-500/60 motion-reduce:animate-none" /> | |
| <span className="relative size-2 rounded-full bg-apple-500" /> | |
| </span> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| const TERMINAL_STATES = new Set(["output-available", "output-error"]); | |
| const isSettled = (part: MessagePartValue) => | |
| TERMINAL_STATES.has((part as { state?: string }).state ?? ""); | |
| function DocsToolChain({ parts }: { parts: MessagePartValue[] }) { | |
| const [expanded, setExpanded] = useState(false); | |
| const complete = parts.every(isSettled); | |
| return ( | |
| <div | |
| className="rounded-2xl border border-grid-dimmed bg-charcoal-950/60 px-4 py-3" | |
| aria-label="Documentation lookups" | |
| > | |
| <button | |
| type="button" | |
| aria-expanded={expanded} | |
| onClick={() => setExpanded((value) => !value)} | |
| className="mb-1 flex min-h-10 w-full items-center gap-2 rounded-lg text-left" | |
| > | |
| <BookOpen className="size-3.5 text-apple-500" /> | |
| <span className="font-mono text-2xs uppercase tracking-widest text-dimmed"> | |
| Grounding in the docs | |
| </span> | |
| <span className="ml-auto font-mono text-2xs text-charcoal-500"> | |
| {complete ? "Complete" : "Searching"} | |
| </span> | |
| <ChevronDown | |
| className={`size-3.5 shrink-0 text-charcoal-500 transition-transform duration-150 ${expanded ? "rotate-180" : ""}`} | |
| /> | |
| </button> | |
| <div className="relative space-y-3 before:absolute before:bottom-2 before:left-[0.4375rem] before:top-2 before:w-px before:bg-grid-bright"> | |
| {parts.map((part, i) => { | |
| const done = isSettled(part); | |
| const label = docsToolLabel(part); | |
| return ( | |
| <div key={i} className="relative flex min-w-0 items-start gap-3"> | |
| <span className="relative z-10 mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full bg-charcoal-950 text-dimmed"> | |
| <Globe2 className="size-3.5" /> | |
| </span> | |
| <span | |
| className={`min-w-0 flex-1 text-sm text-dimmed ${expanded ? "break-words leading-5" : "truncate"}`} | |
| title={label} | |
| > | |
| {label} | |
| </span> | |
| {done ? ( | |
| <Check | |
| className="mt-0.5 size-3.5 shrink-0 text-dimmed" | |
| aria-label="Complete" | |
| /> | |
| ) : ( | |
| <span | |
| className="relative mt-1 flex size-2 shrink-0" | |
| aria-label="Searching" | |
| > | |
| <span className="absolute inset-0 animate-ping rounded-full bg-apple-500/60 motion-reduce:animate-none" /> | |
| <span className="relative size-2 rounded-full bg-apple-500" /> | |
| </span> | |
| )} | |
| </div> | |
| ); | |
| })} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@trigger-chat-agent/src/components/chat.tsx` around lines 496 - 556, Update
DocsToolChain to treat both output-available and output-error states as terminal
when computing complete, so failed lookups show “Complete” and stop the
searching animation. Preserve a distinct output-error row indicator rather than
rendering the successful Check icon for failed parts.
| return ( | ||
| <div className="flex items-start gap-3 rounded-xl border border-error/40 bg-error/5 px-4 py-3"> | ||
| <AlertTriangle className="mt-0.5 size-4 shrink-0 text-error" /> | ||
| <div className="min-w-0 flex-1"> | ||
| <div className="text-sm font-medium text-error">{title}</div> | ||
| <p className="mt-0.5 break-words text-xs leading-relaxed text-dimmed">{detail}</p> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Announce the error to assistive technology.
The banner appears asynchronously after a failure. Without a live region, screen readers do not announce it. Add role="alert".
♿ Proposed fix
- <div className="flex items-start gap-3 rounded-xl border border-error/40 bg-error/5 px-4 py-3">
+ <div
+ role="alert"
+ className="flex items-start gap-3 rounded-xl border border-error/40 bg-error/5 px-4 py-3"
+ >📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return ( | |
| <div className="flex items-start gap-3 rounded-xl border border-error/40 bg-error/5 px-4 py-3"> | |
| <AlertTriangle className="mt-0.5 size-4 shrink-0 text-error" /> | |
| <div className="min-w-0 flex-1"> | |
| <div className="text-sm font-medium text-error">{title}</div> | |
| <p className="mt-0.5 break-words text-xs leading-relaxed text-dimmed">{detail}</p> | |
| return ( | |
| <div | |
| role="alert" | |
| className="flex items-start gap-3 rounded-xl border border-error/40 bg-error/5 px-4 py-3" | |
| > | |
| <AlertTriangle className="mt-0.5 size-4 shrink-0 text-error" /> | |
| <div className="min-w-0 flex-1"> | |
| <div className="text-sm font-medium text-error">{title}</div> | |
| <p className="mt-0.5 break-words text-xs leading-relaxed text-dimmed">{detail}</p> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@trigger-chat-agent/src/components/error-notice.tsx` around lines 53 - 58, Add
role="alert" to the root div returned by the error notice component so
asynchronously rendered failures are announced to assistive technology, leaving
the existing styling and content unchanged.
| useEffect(() => { | ||
| setStatuses(initialStatuses(nodes, sequence, reduceMotion)); | ||
| if (reduceMotion || !sequence || sequence.length === 0) return; | ||
| const timers = sequence.map((s) => | ||
| window.setTimeout(() => { | ||
| setStatuses((prev) => (s.nodeId in prev ? { ...prev, [s.nodeId]: s.status } : prev)); | ||
| }, Math.max(0, s.atMs)) | ||
| ); | ||
| return () => timers.forEach((t) => window.clearTimeout(t)); | ||
| }, [nodes, sequence, reduceMotion]); | ||
|
|
||
| // Edges fade in mid-cascade so they don't dangle off still-hidden nodes. | ||
| const [edgesVisible, setEdgesVisible] = useState(reduceMotion); | ||
| const revealSpan = useMemo(() => Math.max(0, ...Object.values(revealDelays)), [revealDelays]); | ||
| useEffect(() => { | ||
| if (reduceMotion) { | ||
| setEdgesVisible(true); | ||
| return; | ||
| } | ||
| setEdgesVisible(false); | ||
| const t = window.setTimeout(() => setEdgesVisible(true), revealSpan * 500 + 150); | ||
| return () => window.clearTimeout(t); | ||
| }, [reduceMotion, revealSpan]); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find FlowGraph call sites and check whether the node/edge/sequence arrays are memoized.
fd -e tsx -e ts . trigger-chat-agent/src | xargs rg -n -C 10 '\bFlowGraph\b'
# Inspect how visualization specs are parsed and passed down.
fd -a 'visualization.tsx|registry.tsx|catalog.ts' trigger-chat-agent/src --exec cat -n {}Repository: triggerdotdev/examples
Length of output: 49061
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- Visualization call sites ---'
rg -n -C 20 '\bVisualization\s*\(|normalizeSpec|renderVisualization|tool.*input|toolInvocation' trigger-chat-agent/src trigger-chat-agent --glob '!**/node_modules/**'
printf '%s\n' '--- Relevant package versions and renderer implementation references ---'
rg -n -C 8 '"`@json-render/`(react|core)"|json-render' trigger-chat-agent/package.json package.json trigger-chat-agent/pnpm-lock.yaml trigger-chat-agent/package-lock.json trigger-chat-agent/yarn.lock 2>/dev/null || true
rg -n -C 10 'JSONUIProvider|Renderer' trigger-chat-agent --glob '!**/node_modules/**'Repository: triggerdotdev/examples
Length of output: 50379
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- Chat render path ---'
sed -n '430,560p' trigger-chat-agent/src/components/chat.tsx
sed -n '560,625p' trigger-chat-agent/src/components/chat.tsx
printf '%s\n' '--- All Visualization rendering references ---'
rg -n 'Visualization|groupMessageParts|messages\.map|parts\.map|key=' trigger-chat-agent/src/components/chat.tsx trigger-chat-agent/src/components/visualization.tsx
printf '%s\n' '--- Package metadata ---'
for f in trigger-chat-agent/package.json package.json; do
if [ -f "$f" ]; then
sed -n '1,180p' "$f"
fi
doneRepository: triggerdotdev/examples
Length of output: 9882
🌐 Web query:
AI SDK v6 @ai-sdk/react useChat source message parts input object identity immutable updates
💡 Result:
In AI SDK v6 (and since v5.0), the useChat hook has transitioned to a transport-based architecture and no longer manages input state internally [1][2]. You must now manage input state (such as text fields) manually using standard React hooks like useState, and utilize the sendMessage function to send data [3][4]. Regarding your questions on message structure and updates: 1. Source Message Parts: The UIMessage object includes a parts property that contains an array of UIMessagePart objects [1][5]. This property is the recommended way to render message content, including text, tool invocations, tool results, and sources [3][5]. Specifically, sources are categorized into types such as source-url and source-document [5]. You should filter and map over the parts array to render these specific source types dynamically [5]. 2. Object Identity and Immutable Updates: The AI SDK manages chat state internally, providing an immutable messages array that updates automatically as new messages are streamed from the AI provider [1][3]. Since the SDK controls this state object, you should treat the messages array and the UIMessage objects within it as immutable [1][5]. When updating or interacting with chat state, use the provided methods (like sendMessage or setMessages) rather than directly mutating the state [1]. Because the SDK handles state management, React will trigger re-renders when the messages array is updated, allowing your components to stay synchronized with the latest streaming data [3][5]. By offloading input management to your own components and consuming the messages state through the hook, you ensure your UI remains responsive to the immutable state updates provided by the SDK [3][4].
Citations:
- 1: https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat
- 2: https://ai-sdk.dev/v7/docs/reference/ai-sdk-ui/use-chat
- 3: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot
- 4: https://ai-sdk.dev/docs/ai-sdk-ui/generative-user-interfaces?_sm_vck=J8qN47SpWnMDrN8LSrDW9NkMQHB0Q8RH7DJrRRslW9447Q7sqsRN
- 5: https://ai-sdk.dev/v7/docs/ai-sdk-ui/chatbot
🌐 Web query:
site:github.com/vercel/ai packages/react/src/use-chat.ts UIMessage parts input streaming source
💡 Result:
In the Vercel AI SDK, UIMessage is the primary interface used to represent chat messages within the UI [1][2]. It defines the structure of messages rendered in the chat interface, containing a unique id, a role (system, user, or assistant), metadata, and an array of parts [1][3]. The parts property is an array of UIMessagePart objects, which are designed specifically for flexible UI rendering [1]. These parts can represent various content types, including [1]: - Text: Standard text content (TextUIPart) - Reasoning: Thoughts or reasoning processes (ReasoningUIPart) - Tooling: Tool invocations or results (ToolUIPart, DynamicToolUIPart) - File support: Files or related metadata (FileUIPart, SourceUrlUIPart, etc.) - Data: Custom data structures (DataUIPart) The useChat hook, located in packages/react/src/use-chat.ts, manages the interaction and streaming state for these messages [4][5]. When a response is streamed, the SDK handles the incoming data stream by processing it into UIMessage objects [6][7]. The streaming process involves: 1. Request/Resume: useChat initiates a request or resumes an existing stream via the Chat class's transport layer [6][7][5]. 2. Streaming State: As chunks are received, the SDK creates or updates the message state [6]. The UIMessage content is progressively built using these chunks, allowing the UI to reactively display the streaming content (e.g., text, tool calls) [6][7]. 3. Updates: The useChat hook uses useSyncExternalStore to subscribe to the internal Chat state, ensuring the UI re-renders as messages are added or updated during the streaming process [4][8][5]. This architecture separates the core messaging logic (the Chat instance) from the React-specific hooks (useChat), ensuring that UIMessage parts provide a consistent way to handle multi-modal and interactive content during streaming [6][5][1].
Citations:
- 1: https://github.com/vercel/ai/blob/main/packages/ai/src/ui/ui-messages.ts
- 2: vercel/ai@8b86e99
- 3: https://github.com/vercel/ai/blob/83877a1e/content/docs/07-reference/02-ai-sdk-ui/01-use-chat.mdx
- 4: https://github.com/vercel/ai/blob/83877a1e/packages/react/src/use-chat.ts
- 5: https://github.com/vercel/ai/blob/main/packages/react/src/use-chat.ts
- 6: https://github.com/vercel/ai/blob/main/packages/ai/src/ui/chat.ts
- 7: https://github.com/vercel/ai/blob/83877a1e/packages/ai/src/ui/chat.ts
- 8: https://github.com/vercel/ai/blob/a23b6767/packages/react/src/use-chat.ts
🏁 Script executed:
#!/bin/bash
set -e
url='https://raw.githubusercontent.com/vercel/ai/main/packages/ai/src/ui/chat.ts'
tmp=$(mktemp)
curl -fsSL "$url" -o "$tmp"
printf '%s\n' '--- Chat state update paths ---'
rg -n -C 12 'setMessages|messages =|updateMessage|replace|process|onTool|tool' "$tmp" | head -n 260
rm -f "$tmp"Repository: triggerdotdev/examples
Length of output: 8275
🏁 Script executed:
#!/bin/bash
set -e
for path in packages/ai/src/ui/process-ui-message-stream.ts packages/ai/src/ui/chat.ts; do
url="https://raw.githubusercontent.com/vercel/ai/main/$path"
tmp=$(mktemp)
curl -fsSL "$url" -o "$tmp"
printf '\n--- %s ---\n' "$path"
rg -n -C 16 'messages|replaceMessage|parts|tool-input|tool-output|snapshot|append' "$tmp" | head -n 360
rm -f "$tmp"
doneRepository: triggerdotdev/examples
Length of output: 25338
🏁 Script executed:
#!/bin/bash
set -e
url='https://raw.githubusercontent.com/vercel/ai/main/packages/ai/src/ui/chat.ts'
tmp=$(mktemp)
curl -fsSL "$url" -o "$tmp"
printf '%s\n' '--- Stream state publication and snapshot ---'
rg -n -C 24 'runUpdateMessageJob|createStreamingUIMessageState|state\.messages|replaceMessage|snapshot|activeResponse|processUIMessageStream' "$tmp" | tail -n 420
rm -f "$tmp"Repository: triggerdotdev/examples
Length of output: 13545
🏁 Script executed:
#!/bin/bash
set -e
url='https://raw.githubusercontent.com/vercel/ai/main/packages/react/src/use-chat.ts'
tmp=$(mktemp)
curl -fsSL "$url" -o "$tmp"
printf '%s\n' '--- React state snapshot and subscriptions ---'
rg -n -C 24 'snapshot|replaceMessage|useSyncExternalStore|messages|pushMessage' "$tmp" | head -n 420
rm -f "$tmp"Repository: triggerdotdev/examples
Length of output: 6754
🏁 Script executed:
#!/bin/bash
set -e
url='https://raw.githubusercontent.com/vercel/ai/main/packages/react/src/chat.react.ts'
tmp=$(mktemp)
curl -fsSL "$url" -o "$tmp"
printf '%s\n' '--- React Chat message update implementation ---'
rg -n -C 18 'replaceMessage|pushMessage|registerMessages|messages|snapshot' "$tmp" | head -n 360
rm -f "$tmp"Repository: triggerdotdev/examples
Length of output: 4171
🏁 Script executed:
#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re
p = Path("trigger-chat-agent/src/components/flow-graph.tsx").read_text()
status = re.search(
r"useEffect\(\(\) => \{.*?setStatuses\(initialStatuses\(nodes, sequence, reduceMotion\)\);.*?\}, \s*\[([^\]]+)\]\);",
p,
re.S,
)
edges = re.search(
r"useEffect\(\(\) => \{.*?setEdgesVisible\(false\);.*?\}, \s*\[([^\]]+)\]\);",
p,
re.S,
)
print("status_effect_dependencies:", status.group(1).strip() if status else "not found")
print("edges_effect_dependencies:", edges.group(1).strip() if edges else "not found")
PY
node - <<'JS'
const message = {
parts: [{
type: "tool-renderVisualization",
input: {
spec: {
elements: {
graph: {
type: "FlowGraph",
props: {
nodes: [{ id: "a", label: "A", kind: "task" }],
sequence: [{ nodeId: "a", status: "running", atMs: 1000 }],
},
},
},
},
},
}],
};
const nextMessage = structuredClone(message);
const oldProps = message.parts[0].input.spec.elements.graph.props;
const newProps = nextMessage.parts[0].input.spec.elements.graph.props;
console.log("nodes_same_reference_after_replaceMessage:", oldProps.nodes === newProps.nodes);
console.log("sequence_same_reference_after_replaceMessage:", oldProps.sequence === newProps.sequence);
JSRepository: triggerdotdev/examples
Length of output: 371
Stabilize the status-effect inputs.
replaceMessage deep-clones each streamed message, so nodes and sequence receive new references even when their contents do not change. The status effect then resets the animation and recreates its timers on every stream update. Derive stable content keys or memoize these arrays before using them in the effect dependencies.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@trigger-chat-agent/src/components/flow-graph.tsx` around lines 450 - 472,
Stabilize the nodes and sequence inputs used by the status animation useEffect
so replaceMessage’s deep-cloned but unchanged data does not reset statuses or
recreate timers. Derive content-based keys or memoized array values from nodes
and sequence, and use those stable dependencies while preserving updates when
their actual contents change.
| <div className="space-y-2"> | ||
| {options.map((o, i) => { | ||
| const isCorrect = Boolean(o.correct); | ||
| const reveal = answered && (i === picked || isCorrect); | ||
| return ( | ||
| <button | ||
| key={i} | ||
| type="button" | ||
| disabled={answered} | ||
| onClick={() => setPicked(i)} | ||
| className={cn( | ||
| "flex min-h-11 w-full items-center gap-3 rounded-xl border px-4 py-2.5 text-left text-sm leading-5 transition-colors duration-150", | ||
| !reveal && "border-charcoal-700 bg-charcoal-800 text-bright enabled:hover:bg-charcoal-700", | ||
| reveal && isCorrect && "border-apple-500/60 bg-apple-500/10 text-apple-200", | ||
| reveal && !isCorrect && "border-error/60 bg-error/10 text-error" | ||
| )} | ||
| > | ||
| {reveal && isCorrect && <CheckCircle2 className="size-4 shrink-0 text-apple-500" />} | ||
| {reveal && !isCorrect && <XCircle className="size-4 shrink-0 text-error" />} | ||
| <span>{o.text}</span> | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> | ||
| {answered && explanation && ( | ||
| <p className="mt-4 border-t border-grid-bright pt-3 text-sm leading-relaxed text-dimmed">{explanation}</p> | ||
| )} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep the answered options focusable and announce the result.
After the user answers, line 44 sets disabled on every option. A disabled <button> leaves the tab order and cannot receive focus, so a keyboard user loses the focus position and cannot review the revealed states. The explanation at line 61 also appears without an announcement.
Use aria-disabled with a click guard instead of disabled, and mark the explanation as a live region.
♿ Proposed fix
- <div className="space-y-2">
+ <div className="space-y-2" role="group" aria-label={question}>
{options.map((o, i) => {
const isCorrect = Boolean(o.correct);
const reveal = answered && (i === picked || isCorrect);
return (
<button
key={i}
type="button"
- disabled={answered}
- onClick={() => setPicked(i)}
+ aria-disabled={answered}
+ onClick={() => {
+ if (!answered) setPicked(i);
+ }}
className={cn(
"flex min-h-11 w-full items-center gap-3 rounded-xl border px-4 py-2.5 text-left text-sm leading-5 transition-colors duration-150",
- !reveal && "border-charcoal-700 bg-charcoal-800 text-bright enabled:hover:bg-charcoal-700",
+ !reveal && "border-charcoal-700 bg-charcoal-800 text-bright aria-disabled:hover:bg-charcoal-800 [&:not([aria-disabled=true])]:hover:bg-charcoal-700",
reveal && isCorrect && "border-apple-500/60 bg-apple-500/10 text-apple-200",
reveal && !isCorrect && "border-error/60 bg-error/10 text-error"
)}
>
{answered && explanation && (
- <p className="mt-4 border-t border-grid-bright pt-3 text-sm leading-relaxed text-dimmed">{explanation}</p>
+ <p role="status" className="mt-4 border-t border-grid-bright pt-3 text-sm leading-relaxed text-dimmed">
+ {explanation}
+ </p>
)}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="space-y-2"> | |
| {options.map((o, i) => { | |
| const isCorrect = Boolean(o.correct); | |
| const reveal = answered && (i === picked || isCorrect); | |
| return ( | |
| <button | |
| key={i} | |
| type="button" | |
| disabled={answered} | |
| onClick={() => setPicked(i)} | |
| className={cn( | |
| "flex min-h-11 w-full items-center gap-3 rounded-xl border px-4 py-2.5 text-left text-sm leading-5 transition-colors duration-150", | |
| !reveal && "border-charcoal-700 bg-charcoal-800 text-bright enabled:hover:bg-charcoal-700", | |
| reveal && isCorrect && "border-apple-500/60 bg-apple-500/10 text-apple-200", | |
| reveal && !isCorrect && "border-error/60 bg-error/10 text-error" | |
| )} | |
| > | |
| {reveal && isCorrect && <CheckCircle2 className="size-4 shrink-0 text-apple-500" />} | |
| {reveal && !isCorrect && <XCircle className="size-4 shrink-0 text-error" />} | |
| <span>{o.text}</span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {answered && explanation && ( | |
| <p className="mt-4 border-t border-grid-bright pt-3 text-sm leading-relaxed text-dimmed">{explanation}</p> | |
| )} | |
| <div className="space-y-2" role="group" aria-label={question}> | |
| {options.map((o, i) => { | |
| const isCorrect = Boolean(o.correct); | |
| const reveal = answered && (i === picked || isCorrect); | |
| return ( | |
| <button | |
| key={i} | |
| type="button" | |
| aria-disabled={answered} | |
| onClick={() => { | |
| if (!answered) setPicked(i); | |
| }} | |
| className={cn( | |
| "flex min-h-11 w-full items-center gap-3 rounded-xl border px-4 py-2.5 text-left text-sm leading-5 transition-colors duration-150", | |
| !reveal && "border-charcoal-700 bg-charcoal-800 text-bright aria-disabled:hover:bg-charcoal-800 [&:not([aria-disabled=true])]:hover:bg-charcoal-700", | |
| reveal && isCorrect && "border-apple-500/60 bg-apple-500/10 text-apple-200", | |
| reveal && !isCorrect && "border-error/60 bg-error/10 text-error" | |
| )} | |
| > | |
| {reveal && isCorrect && <CheckCircle2 className="size-4 shrink-0 text-apple-500" />} | |
| {reveal && !isCorrect && <XCircle className="size-4 shrink-0 text-error" />} | |
| <span>{o.text}</span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {answered && explanation && ( | |
| <p role="status" className="mt-4 border-t border-grid-bright pt-3 text-sm leading-relaxed text-dimmed"> | |
| {explanation} | |
| </p> | |
| )} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@trigger-chat-agent/src/components/quiz.tsx` around lines 36 - 62, Update the
option buttons rendered in the options.map callback to remain focusable after
answered: replace the disabled attribute with aria-disabled and guard the
onClick handler so setPicked only runs before answering. Mark the conditional
explanation paragraph as an assertive live region so its appearance is announced
to assistive technologies.
| function AnimatedValue({ value, active, reduceMotion }: { value: string; active: boolean; reduceMotion: boolean }) { | ||
| const match = value.match(/^([^\d]*)([\d,]*\.?\d+)(.*)$/); | ||
| const [display, setDisplay] = useState(value); | ||
|
|
||
| useEffect(() => { | ||
| if (!match || !active || reduceMotion) { | ||
| setDisplay(value); | ||
| return; | ||
| } | ||
| const [, prefix, numText, suffix] = match; | ||
| const target = Number(numText.replace(/,/g, "")); | ||
| const decimals = numText.includes(".") ? numText.split(".")[1].length : 0; | ||
| const controls = animate(0, target, { | ||
| duration: 1, | ||
| delay: 0.25, | ||
| ease: easings.outExpo, | ||
| onUpdate: (v) => setDisplay(`${prefix}${v.toFixed(decimals)}${suffix}`), | ||
| }); | ||
| return () => controls.stop(); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [active, reduceMotion, value]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Preserve digit grouping during the animation.
Line 89 removes commas from a formatted value. If value is "1,234", the final animated value is "1234".
Format the animated number when numText contains grouping separators.
Proposed fix
- onUpdate: (v) => setDisplay(`${prefix}${v.toFixed(decimals)}${suffix}`),
+ onUpdate: (v) => {
+ const numeric = v.toFixed(decimals);
+ const formatted = numText.includes(",")
+ ? numeric.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
+ : numeric;
+ setDisplay(`${prefix}${formatted}${suffix}`);
+ },📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function AnimatedValue({ value, active, reduceMotion }: { value: string; active: boolean; reduceMotion: boolean }) { | |
| const match = value.match(/^([^\d]*)([\d,]*\.?\d+)(.*)$/); | |
| const [display, setDisplay] = useState(value); | |
| useEffect(() => { | |
| if (!match || !active || reduceMotion) { | |
| setDisplay(value); | |
| return; | |
| } | |
| const [, prefix, numText, suffix] = match; | |
| const target = Number(numText.replace(/,/g, "")); | |
| const decimals = numText.includes(".") ? numText.split(".")[1].length : 0; | |
| const controls = animate(0, target, { | |
| duration: 1, | |
| delay: 0.25, | |
| ease: easings.outExpo, | |
| onUpdate: (v) => setDisplay(`${prefix}${v.toFixed(decimals)}${suffix}`), | |
| }); | |
| return () => controls.stop(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [active, reduceMotion, value]); | |
| function AnimatedValue({ value, active, reduceMotion }: { value: string; active: boolean; reduceMotion: boolean }) { | |
| const match = value.match(/^([^\d]*)([\d,]*\.?\d+)(.*)$/); | |
| const [display, setDisplay] = useState(value); | |
| useEffect(() => { | |
| if (!match || !active || reduceMotion) { | |
| setDisplay(value); | |
| return; | |
| } | |
| const [, prefix, numText, suffix] = match; | |
| const target = Number(numText.replace(/,/g, "")); | |
| const decimals = numText.includes(".") ? numText.split(".")[1].length : 0; | |
| const controls = animate(0, target, { | |
| duration: 1, | |
| delay: 0.25, | |
| ease: easings.outExpo, | |
| onUpdate: (v) => { | |
| const numeric = v.toFixed(decimals); | |
| const formatted = numText.includes(",") | |
| ? numeric.replace(/\B(?=(\d{3})+(?!\d))/g, ",") | |
| : numeric; | |
| setDisplay(`${prefix}${formatted}${suffix}`); | |
| }, | |
| }); | |
| return () => controls.stop(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [active, reduceMotion, value]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@trigger-chat-agent/src/components/stat-card.tsx` around lines 73 - 93, Update
the onUpdate formatting in AnimatedValue so animated numbers retain digit
grouping when numText contains commas. Format the interpolated value with
grouping separators before combining it with prefix and suffix, while preserving
decimal precision and existing ungrouped-number behavior.
| class VisualizationErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { | ||
| state = { failed: false }; | ||
|
|
||
| static getDerivedStateFromError() { | ||
| return { failed: true }; | ||
| } | ||
|
|
||
| render() { | ||
| if (this.state.failed) { | ||
| return ( | ||
| <div className="my-3 rounded-lg border border-dashed px-3 py-2 text-xs text-muted-foreground"> | ||
| Couldn't render this visualization. | ||
| </div> | ||
| ); | ||
| } | ||
| return this.props.children; | ||
| } | ||
| } | ||
|
|
||
| export function Visualization({ spec }: { spec: VisualizationSpec }) { | ||
| return ( | ||
| <div> | ||
| <VisualizationErrorBoundary> | ||
| <JSONUIProvider registry={registry}> | ||
| <Renderer spec={spec} registry={registry} /> | ||
| </JSONUIProvider> | ||
| </VisualizationErrorBoundary> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Reset the error boundary when the spec changes.
failed is never cleared. MessagePart in trigger-chat-agent/src/components/chat.tsx keys parts by group index, so React reuses this boundary instance when the model repairs a rejected spec and streams a new one. The slot then stays on "Couldn't render this visualization." for the rest of the session. Key the boundary on the spec so a new spec mounts a fresh instance. Add componentDidCatch logging so the cause is visible during development.
🐛 Proposed fix
class VisualizationErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
+ componentDidCatch(error: unknown) {
+ console.error("Visualization render failed", error);
+ }
+
render() {
@@
export function Visualization({ spec }: { spec: VisualizationSpec }) {
+ // Remount on a new spec so a repaired spec is not blocked by the previous failure.
+ const specKey = JSON.stringify(spec);
return (
<div>
- <VisualizationErrorBoundary>
+ <VisualizationErrorBoundary key={specKey}>
<JSONUIProvider registry={registry}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
</VisualizationErrorBoundary>
</div>
);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| class VisualizationErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { | |
| state = { failed: false }; | |
| static getDerivedStateFromError() { | |
| return { failed: true }; | |
| } | |
| render() { | |
| if (this.state.failed) { | |
| return ( | |
| <div className="my-3 rounded-lg border border-dashed px-3 py-2 text-xs text-muted-foreground"> | |
| Couldn't render this visualization. | |
| </div> | |
| ); | |
| } | |
| return this.props.children; | |
| } | |
| } | |
| export function Visualization({ spec }: { spec: VisualizationSpec }) { | |
| return ( | |
| <div> | |
| <VisualizationErrorBoundary> | |
| <JSONUIProvider registry={registry}> | |
| <Renderer spec={spec} registry={registry} /> | |
| </JSONUIProvider> | |
| </VisualizationErrorBoundary> | |
| </div> | |
| ); | |
| } | |
| class VisualizationErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { | |
| state = { failed: false }; | |
| static getDerivedStateFromError() { | |
| return { failed: true }; | |
| } | |
| componentDidCatch(error: unknown) { | |
| console.error("Visualization render failed", error); | |
| } | |
| render() { | |
| if (this.state.failed) { | |
| return ( | |
| <div className="my-3 rounded-lg border border-dashed px-3 py-2 text-xs text-muted-foreground"> | |
| Couldn't render this visualization. | |
| </div> | |
| ); | |
| } | |
| return this.props.children; | |
| } | |
| } | |
| export function Visualization({ spec }: { spec: VisualizationSpec }) { | |
| // Remount on a new spec so a repaired spec is not blocked by the previous failure. | |
| const specKey = JSON.stringify(spec); | |
| return ( | |
| <div> | |
| <VisualizationErrorBoundary key={specKey}> | |
| <JSONUIProvider registry={registry}> | |
| <Renderer spec={spec} registry={registry} /> | |
| </JSONUIProvider> | |
| </VisualizationErrorBoundary> | |
| </div> | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@trigger-chat-agent/src/components/visualization.tsx` around lines 11 - 40,
Update VisualizationErrorBoundary usage in Visualization to key the boundary by
spec, ensuring repaired specifications mount a fresh boundary and reset failed
state. Add componentDidCatch to VisualizationErrorBoundary to log rendering
errors during development without changing the existing fallback UI.
| for (const child of element.children ?? []) { | ||
| if (!spec.elements[child]) { | ||
| errors.push(`elements.${key}: child "${child}" is not a key in elements`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return errors.length > 0 ? { ok: false, errors } : { ok: true }; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Reject cyclic children references.
validateSpec checks only that each child key exists. A spec where two elements list each other as children passes validation. The client Renderer then walks the graph without a depth bound, which hangs the tab or overflows the stack. The model produces these keys, so the input is not trusted.
Add a reachability walk with a visited set.
🐛 Proposed fix: detect cycles from the root
for (const child of element.children ?? []) {
if (!spec.elements[child]) {
errors.push(`elements.${key}: child "${child}" is not a key in elements`);
}
}
}
+ // Guard the renderer: a cycle in `children` makes the client recurse forever.
+ const visiting = new Set<string>();
+ const done = new Set<string>();
+ const walk = (key: string) => {
+ if (done.has(key)) return;
+ if (visiting.has(key)) {
+ errors.push(`elements.${key}: children form a cycle`);
+ return;
+ }
+ visiting.add(key);
+ for (const child of spec.elements[key]?.children ?? []) {
+ if (spec.elements[child]) walk(child);
+ }
+ visiting.delete(key);
+ done.add(key);
+ };
+ if (spec.elements[spec.root]) walk(spec.root);
+
return errors.length > 0 ? { ok: false, errors } : { ok: true };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| for (const child of element.children ?? []) { | |
| if (!spec.elements[child]) { | |
| errors.push(`elements.${key}: child "${child}" is not a key in elements`); | |
| } | |
| } | |
| } | |
| return errors.length > 0 ? { ok: false, errors } : { ok: true }; | |
| for (const child of element.children ?? []) { | |
| if (!spec.elements[child]) { | |
| errors.push(`elements.${key}: child "${child}" is not a key in elements`); | |
| } | |
| } | |
| } | |
| // Guard the renderer: a cycle in `children` makes the client recurse forever. | |
| const visiting = new Set<string>(); | |
| const done = new Set<string>(); | |
| const walk = (key: string) => { | |
| if (done.has(key)) return; | |
| if (visiting.has(key)) { | |
| errors.push(`elements.${key}: children form a cycle`); | |
| return; | |
| } | |
| visiting.add(key); | |
| for (const child of spec.elements[key]?.children ?? []) { | |
| if (spec.elements[child]) walk(child); | |
| } | |
| visiting.delete(key); | |
| done.add(key); | |
| }; | |
| if (spec.elements[spec.root]) walk(spec.root); | |
| return errors.length > 0 ? { ok: false, errors } : { ok: true }; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@trigger-chat-agent/src/lib/catalog.ts` around lines 304 - 311, Update
validateSpec to perform a reachability walk from the root element using a
visited set, following each element’s children and rejecting any node
encountered more than once as a cyclic or repeated reference. Preserve the
existing missing-child-key validation and return the accumulated validation
errors through the current result shape.
| async function loadDocsTools(): Promise<ToolSet> { | ||
| try { | ||
| const client = await createMCPClient({ | ||
| transport: { type: "http", url: DOCS_MCP_URL }, | ||
| }); | ||
| // Kept open for the life of the run process — the returned tools close over | ||
| // the client to execute, so we never call client.close() here. | ||
| return quarantineDocsTools(await client.tools()); | ||
| } catch (error) { | ||
| logger.warn("Docs MCP unavailable — continuing without doc grounding", { | ||
| url: DOCS_MCP_URL, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| return {}; | ||
| } | ||
| } | ||
|
|
||
| function getDocsTools(): Promise<ToolSet> { | ||
| return (docsToolsPromise ??= loadDocsTools()); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
ai-sdk MCP createMCPClient http transport options abortSignal timeout
💡 Result:
In the AI SDK's MCP implementation, timeout and AbortSignal support are managed through RequestOptions and initializationOptions to control the lifecycle of the client and individual requests [1][2]. Initialization Timeouts and AbortSignals You can bound the initialization process of an MCP client by providing initializationOptions in the createMCPClient configuration [2]. This configuration accepts RequestOptions, which include timeout (in milliseconds) and an AbortSignal [1][2]. - When a timeout is specified, the client will automatically trigger an abort if the initialization does not complete within the given duration [2]. - If an AbortSignal is provided (either directly or via AbortSignal.any if combining with a timeout), the initialization will respect that signal [2]. Request-Level Timeouts and AbortSignals For individual operations such as tool execution (e.g., callTool), you can pass options that support AbortSignal [2]. Recent updates to the AI SDK have addressed previous limitations regarding request cancellation [1][3]. - The RequestOptions object used in client operations allows for defining timeout and maxTotalTimeout [1]. - These settings ensure that if a server fails to respond or hangs, the client will clean up internal handlers and reject the promise, preventing memory leaks and orphaned requests [1][3]. Implementation Note When using the HTTP transport, the AI SDK's transport layer propagates these cancellation signals to the underlying fetch or stream requests [1][2]. If you are using a custom transport, ensure it is designed to respect the cancellation signals propagated from the client's request methods [1]. Always ensure that the client is properly closed (e.g., using client.close()) to free up transport resources, even if an initialization or request error occurs [4][5].
Citations:
- 1: MCP client ignores request timeouts and cannot bound initialization vercel/ai#18075
- 2: https://cdn.jsdelivr.net/npm/@ai-sdk/mcp@2.0.27/src/tool/mcp-client.ts
- 3: MCP
callToolnever settles (and leaks its response handler) when the request is aborted after being sent. vercel/ai#16580 - 4: https://ai-sdk.dev/docs/reference/ai-sdk-core/create-mcp-client
- 5: https://ai-sdk.dev/v5/docs/reference/ai-sdk-core/create-mcp-client
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
fd -i 'trigger-chat-agent.ts|package.json|pnpm-lock.yaml|package-lock.json|yarn.lock' . | head -80
printf '%s\n' '--- relevant source references ---'
rg -n -C 8 'createMCPClient|docsToolsPromise|loadDocsTools|getDocsTools|DOCS_MCP_URL|`@ai-sdk/mcp`|abortSignal|initializationOptions' . \
-g '*.ts' -g '*.tsx' -g '*.json' -g '*lock*' | head -300Repository: triggerdotdev/examples
Length of output: 19857
🏁 Script executed:
#!/bin/bash
set -eu
base='https://unpkg.com/@ai-sdk/mcp@1.0.66'
for path in /dist/index.d.ts /dist/index.js /dist/tool/mcp-client.d.ts /dist/tool/mcp-client.js /package.json; do
printf '\n--- %s ---\n' "$path"
curl -fsSL "$base$path" | head -240
doneRepository: triggerdotdev/examples
Length of output: 22187
🏁 Script executed:
#!/bin/bash
set -eu
base='https://unpkg.com/@ai-sdk/mcp@1.0.66'
for path in /dist/index.d.ts /src/index.ts /src/tool/mcp-client.ts /README.md; do
printf '\n--- %s ---\n' "$path"
curl -fsSL "$base$path" |
grep -n -E -C 12 'createMCPClient|initializationOptions|RequestOptions|abortSignal|timeout|tools\(' |
head -320 || true
doneRepository: triggerdotdev/examples
Length of output: 20543
🏁 Script executed:
#!/bin/bash
set -eu
curl -fsSL 'https://unpkg.com/@ai-sdk/mcp@1.0.66/src/tool/mcp-client.ts' |
sed -n '440,760p'Repository: triggerdotdev/examples
Length of output: 8740
🏁 Script executed:
#!/bin/bash
set -eu
curl -fsSL 'https://unpkg.com/@ai-sdk/mcp@1.0.66/src/tool/mcp-client.ts' |
grep -n -A90 -B12 -E 'async tools|toolsFromDefinitions|listTools\(' |
head -260
printf '\n--- runtime and project metadata ---\n'
cat trigger-chat-agent/package.json
printf '\n--- relevant source ---\n'
cat -n trigger-chat-agent/src/trigger/trigger-chat-agent.ts | sed -n '44,70p;245,290p'Repository: triggerdotdev/examples
Length of output: 12790
Bound MCP initialization and tool discovery, and retry failed loads.
loadDocsTools returns {} on failure, and docsToolsPromise caches that result. A transient outage then disables documentation grounding for the rest of the process. Clear the cached promise only when loading fails. Do not treat a valid empty tool set as a failure.
createMCPClient accepts timeout settings through initializationOptions, not top-level abortSignal. Also, client.tools() performs an unbounded tools/list request. Bound initialization with initializationOptions: { timeout: 10_000 }, and bound tool discovery with listTools({ options: { timeout: 10_000 } }) followed by toolsFromDefinitions(...).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@trigger-chat-agent/src/trigger/trigger-chat-agent.ts` around lines 48 - 67,
Update loadDocsTools to initialize createMCPClient with
initializationOptions.timeout set to 10,000 ms, and replace client.tools() with
listTools({ options: { timeout: 10_000 } }) followed by
toolsFromDefinitions(...). Clear docsToolsPromise only when loading throws so
failed loads can retry, while preserving a valid empty tool set as a successful
cached result.
Summary
A new example: a Trigger.dev AI Chat agent that teaches you Trigger.dev by drawing instead of dumping paragraphs. Ask how a fan-out with retries works and you get an interactive React Flow node-graph; ask about retries and you get a short explainer, a quiz, and a gotcha callout. Every turn ends with clickable next-step chips so the learning keeps going.
How it's built
The agent is a single
chat.agent()task, so the conversation is a durable run that survives redeploys and crashes with no database. It answers by calling arenderVisualizationtool with a json-render spec built from a fixed component catalog (FlowGraph, Quiz, Callout, CodeCard, and more), which the Next.js frontend renders live with React Flow and shadcn/ui. The model supplies data, not markup, so every card is cheap and always well-formed.Facts are grounded on the live docs through a documentation MCP server, and retrieved docs are quarantined as untrusted input before the model ever sees them. The system prompt is a versioned AI Prompt editable from the dashboard, and model calls emit standard GenAI telemetry spans, so you get token, cost and latency observability for free.
Also updates the root examples table with the new entry.
Summary by CodeRabbit