Typed useChat client and usage panel
The route streams, the tool reads real numbers, and the quota refuses gracefully, but the user still sees the placeholder client from the streaming-route lesson: a textarea bound to useState rendering raw text.
This lesson builds the real client: a typed useChat that renders text bubbles and invoice-stats cards across every tool-part state, plus a panel showing how much of today’s token budget is left.
Asking “how many overdue invoices do we have?” flashes a card-shaped skeleton, then the real numbers, then the assistant’s text bubble citing the count. A quota refusal surfaces as a friendly toast while the input stays usable. Here is the finished right rail:
Your mission
Section titled “Your mission”The route, the tool, and the quota are already standing; this last slice is three components in the /invoices right rail, and the type contract holds them together.
You typed the message in the tool lesson as InvoiceUIMessage = UIMessage<unknown, never, InferUITools<InvoiceTools>>.
Drive the chat with useChat<InvoiceUIMessage> and, inside the tool-getInvoiceStats branch, part.output is the projected { count, totalAmount, byStatus, oldestUnpaidDueDate } shape, a union with the { error } arm, not unknown.
Drop the generic and every switch branch needs an as cast, the seam where the client’s idea of the data and the server’s silently drift apart.
The typed message keeps them in lockstep, so the render carries no casts.
Two v5 shapes return. Input is a local useState, since v5 no longer auto-manages the field.
The submit handler guards on status === 'streaming' || status === 'submitted' plus an empty-input check before calling sendMessage, the same in-flight gate that blocked double-submits in the optimistic-mutations work.
The render walks messages.map then parts.map and switches on part.type: a text part is a bubble, a tool-getInvoiceStats part spreads the whole part into <InvoiceStatsCard {...part} />, and a default branch returns null so unknown or transient parts degrade quietly.
Spreading the whole part rather than picking off state, input, and output as separate props is what lets the discriminated union narrow inside the card.
The card takes the entire UIToolInvocation and switches on part.state across all four lifecycle states.
On input-available the arguments have arrived and execute is running, so render a card-shaped skeleton: stat-slot placeholders laid out where the real numbers will land, not a generic spinner, so the shape tells the user what is coming.
When the output arrives, guard 'error' in part.output before destructuring, because the union carries the { error } arm the tool returns on failure: a tool error becomes a one-line “I couldn’t load those stats” message, never a half-rendered card.
The usage panel polls GET /api/usage every ten seconds from the one useEffect this feature allows, since polling an external system is what the effects discipline reserves an effect for.
It opens an AbortController, fetches with the signal, and on unmount aborts the in-flight request and clears the interval.
The quota refusal surfaces through useChat’s onError as a sanitized toast, with the input left enabled, since tomorrow the budget resets.
Do not persist messages, so a refresh loses the conversation, which is fine here; and keep the chat in the /invoices rail beside the data it discusses, where the user asks “how many overdue?” while looking at the list.
The page already mounts the panel and chat in its <aside>, so you fill the three components, not the page.
submitted → streaming → ready, flashes a card-shaped skeleton while the tool’s input is streaming, renders the real numbers, then an assistant text bubble citing the count.tool-getInvoiceStats branch, part.output is the projected { count, totalAmount, byStatus, oldestUnpaidDueDate } type (a union with the { error } arm), not unknown, end to end — no casts.POST /api/chat.sendMessage, message.parts, locally managed input, DefaultChatTransport — with no append, reload, message.content, or ai/rsc.Coding time
Section titled “Coding time”Build the three client components against the brief and the type contract: invoice-chat.tsx, then invoice-stats-card.tsx, then token-usage-panel.tsx.
Let TypeScript guide you: if part.output reads as unknown inside the tool branch, the generic is missing or the part was destructured too early.
The page already mounts the chat and panel in its <aside>, so your work appears the moment it compiles.
Reach for the reference below only after you have a version of your own.
Reference solution and walkthrough
Three files, in build order: the chat that owns the conversation, the card it hands each tool part to, and the panel that polls usage.
invoice-chat.tsx
Section titled “invoice-chat.tsx”The client reads top to bottom: the useChat call, the local input, the submit gate, then the render loop.
'use client';
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';import { Send } from 'lucide-react';import { type FormEvent, useState } from 'react';import { toast } from 'sonner';import { InvoiceStatsCard } from '@/app/(app)/invoices/invoice-stats-card';import { Button } from '@/components/ui/button';import type { InvoiceUIMessage } from '@/lib/llm/tools';
type InvoiceChatProps = { orgName: string };
export const InvoiceChat = ({ orgName }: InvoiceChatProps) => { const { messages, sendMessage, status } = useChat<InvoiceUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: () => toast.error('Something went wrong. Try again.'), }); const [input, setInput] = useState('');
const inFlight = status === 'streaming' || status === 'submitted';
const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (inFlight || input.trim() === '') { return; } sendMessage({ text: input }); setInput(''); };
return ( <div className="flex h-full flex-col gap-3 rounded-lg border p-4"> <div> <h2 className="text-sm font-medium">Ask your invoices</h2> <p className="text-xs text-muted-foreground"> Questions about {orgName}'s invoices. </p> </div>
<div className="flex-1 space-y-3 overflow-y-auto text-sm"> {messages.map((message) => ( <div key={message.id} className="space-y-2"> <span className="text-xs font-medium text-muted-foreground"> {message.role === 'user' ? 'You' : 'Assistant'} </span> {message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return ( <p key={key} className="whitespace-pre-wrap"> {part.text} </p> ); case 'tool-getInvoiceStats': return <InvoiceStatsCard key={key} {...part} />; default: return null; } })} </div> ))} {status === 'submitted' && ( <p className="text-xs text-muted-foreground">Thinking…</p> )} </div>
<form onSubmit={onSubmit} className="flex items-end gap-2"> <textarea value={input} onChange={(event) => setInput(event.target.value)} rows={2} placeholder="How many overdue invoices do we have?" className="flex-1 resize-none rounded-md border bg-background px-2 py-1.5 text-sm" /> <Button type="submit" size="sm" disabled={inFlight}> <Send className="size-4" /> Send </Button> </form> </div> );};The InvoiceUIMessage generic is what narrows part.output in the render below; drop it and every tool branch needs a cast. The endpoint lives on the transport, since @ai-sdk/react@2 removed the top-level api option.
'use client';
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';import { Send } from 'lucide-react';import { type FormEvent, useState } from 'react';import { toast } from 'sonner';import { InvoiceStatsCard } from '@/app/(app)/invoices/invoice-stats-card';import { Button } from '@/components/ui/button';import type { InvoiceUIMessage } from '@/lib/llm/tools';
type InvoiceChatProps = { orgName: string };
export const InvoiceChat = ({ orgName }: InvoiceChatProps) => { const { messages, sendMessage, status } = useChat<InvoiceUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: () => toast.error('Something went wrong. Try again.'), }); const [input, setInput] = useState('');
const inFlight = status === 'streaming' || status === 'submitted';
const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (inFlight || input.trim() === '') { return; } sendMessage({ text: input }); setInput(''); };
return ( <div className="flex h-full flex-col gap-3 rounded-lg border p-4"> <div> <h2 className="text-sm font-medium">Ask your invoices</h2> <p className="text-xs text-muted-foreground"> Questions about {orgName}'s invoices. </p> </div>
<div className="flex-1 space-y-3 overflow-y-auto text-sm"> {messages.map((message) => ( <div key={message.id} className="space-y-2"> <span className="text-xs font-medium text-muted-foreground"> {message.role === 'user' ? 'You' : 'Assistant'} </span> {message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return ( <p key={key} className="whitespace-pre-wrap"> {part.text} </p> ); case 'tool-getInvoiceStats': return <InvoiceStatsCard key={key} {...part} />; default: return null; } })} </div> ))} {status === 'submitted' && ( <p className="text-xs text-muted-foreground">Thinking…</p> )} </div>
<form onSubmit={onSubmit} className="flex items-end gap-2"> <textarea value={input} onChange={(event) => setInput(event.target.value)} rows={2} placeholder="How many overdue invoices do we have?" className="flex-1 resize-none rounded-md border bg-background px-2 py-1.5 text-sm" /> <Button type="submit" size="sm" disabled={inFlight}> <Send className="size-4" /> Send </Button> </form> </div> );};You own the input field in v5; the SDK no longer manages it. onError turns any failure, quota refusal included, into one sanitized toast.
'use client';
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';import { Send } from 'lucide-react';import { type FormEvent, useState } from 'react';import { toast } from 'sonner';import { InvoiceStatsCard } from '@/app/(app)/invoices/invoice-stats-card';import { Button } from '@/components/ui/button';import type { InvoiceUIMessage } from '@/lib/llm/tools';
type InvoiceChatProps = { orgName: string };
export const InvoiceChat = ({ orgName }: InvoiceChatProps) => { const { messages, sendMessage, status } = useChat<InvoiceUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: () => toast.error('Something went wrong. Try again.'), }); const [input, setInput] = useState('');
const inFlight = status === 'streaming' || status === 'submitted';
const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (inFlight || input.trim() === '') { return; } sendMessage({ text: input }); setInput(''); };
return ( <div className="flex h-full flex-col gap-3 rounded-lg border p-4"> <div> <h2 className="text-sm font-medium">Ask your invoices</h2> <p className="text-xs text-muted-foreground"> Questions about {orgName}'s invoices. </p> </div>
<div className="flex-1 space-y-3 overflow-y-auto text-sm"> {messages.map((message) => ( <div key={message.id} className="space-y-2"> <span className="text-xs font-medium text-muted-foreground"> {message.role === 'user' ? 'You' : 'Assistant'} </span> {message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return ( <p key={key} className="whitespace-pre-wrap"> {part.text} </p> ); case 'tool-getInvoiceStats': return <InvoiceStatsCard key={key} {...part} />; default: return null; } })} </div> ))} {status === 'submitted' && ( <p className="text-xs text-muted-foreground">Thinking…</p> )} </div>
<form onSubmit={onSubmit} className="flex items-end gap-2"> <textarea value={input} onChange={(event) => setInput(event.target.value)} rows={2} placeholder="How many overdue invoices do we have?" className="flex-1 resize-none rounded-md border bg-background px-2 py-1.5 text-sm" /> <Button type="submit" size="sm" disabled={inFlight}> <Send className="size-4" /> Send </Button> </form> </div> );};The in-flight gate, the same one useTransition gave you in the optimistic-mutations work: one request at a time, no empty sends. A second click while the first streams returns early instead of firing a duplicate POST /api/chat.
'use client';
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';import { Send } from 'lucide-react';import { type FormEvent, useState } from 'react';import { toast } from 'sonner';import { InvoiceStatsCard } from '@/app/(app)/invoices/invoice-stats-card';import { Button } from '@/components/ui/button';import type { InvoiceUIMessage } from '@/lib/llm/tools';
type InvoiceChatProps = { orgName: string };
export const InvoiceChat = ({ orgName }: InvoiceChatProps) => { const { messages, sendMessage, status } = useChat<InvoiceUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: () => toast.error('Something went wrong. Try again.'), }); const [input, setInput] = useState('');
const inFlight = status === 'streaming' || status === 'submitted';
const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (inFlight || input.trim() === '') { return; } sendMessage({ text: input }); setInput(''); };
return ( <div className="flex h-full flex-col gap-3 rounded-lg border p-4"> <div> <h2 className="text-sm font-medium">Ask your invoices</h2> <p className="text-xs text-muted-foreground"> Questions about {orgName}'s invoices. </p> </div>
<div className="flex-1 space-y-3 overflow-y-auto text-sm"> {messages.map((message) => ( <div key={message.id} className="space-y-2"> <span className="text-xs font-medium text-muted-foreground"> {message.role === 'user' ? 'You' : 'Assistant'} </span> {message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return ( <p key={key} className="whitespace-pre-wrap"> {part.text} </p> ); case 'tool-getInvoiceStats': return <InvoiceStatsCard key={key} {...part} />; default: return null; } })} </div> ))} {status === 'submitted' && ( <p className="text-xs text-muted-foreground">Thinking…</p> )} </div>
<form onSubmit={onSubmit} className="flex items-end gap-2"> <textarea value={input} onChange={(event) => setInput(event.target.value)} rows={2} placeholder="How many overdue invoices do we have?" className="flex-1 resize-none rounded-md border bg-background px-2 py-1.5 text-sm" /> <Button type="submit" size="sm" disabled={inFlight}> <Send className="size-4" /> Send </Button> </form> </div> );};A default: return null lets any unknown or still-transient part degrade quietly rather than crash the tree.
'use client';
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';import { Send } from 'lucide-react';import { type FormEvent, useState } from 'react';import { toast } from 'sonner';import { InvoiceStatsCard } from '@/app/(app)/invoices/invoice-stats-card';import { Button } from '@/components/ui/button';import type { InvoiceUIMessage } from '@/lib/llm/tools';
type InvoiceChatProps = { orgName: string };
export const InvoiceChat = ({ orgName }: InvoiceChatProps) => { const { messages, sendMessage, status } = useChat<InvoiceUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: () => toast.error('Something went wrong. Try again.'), }); const [input, setInput] = useState('');
const inFlight = status === 'streaming' || status === 'submitted';
const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (inFlight || input.trim() === '') { return; } sendMessage({ text: input }); setInput(''); };
return ( <div className="flex h-full flex-col gap-3 rounded-lg border p-4"> <div> <h2 className="text-sm font-medium">Ask your invoices</h2> <p className="text-xs text-muted-foreground"> Questions about {orgName}'s invoices. </p> </div>
<div className="flex-1 space-y-3 overflow-y-auto text-sm"> {messages.map((message) => ( <div key={message.id} className="space-y-2"> <span className="text-xs font-medium text-muted-foreground"> {message.role === 'user' ? 'You' : 'Assistant'} </span> {message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return ( <p key={key} className="whitespace-pre-wrap"> {part.text} </p> ); case 'tool-getInvoiceStats': return <InvoiceStatsCard key={key} {...part} />; default: return null; } })} </div> ))} {status === 'submitted' && ( <p className="text-xs text-muted-foreground">Thinking…</p> )} </div>
<form onSubmit={onSubmit} className="flex items-end gap-2"> <textarea value={input} onChange={(event) => setInput(event.target.value)} rows={2} placeholder="How many overdue invoices do we have?" className="flex-1 resize-none rounded-md border bg-background px-2 py-1.5 text-sm" /> <Button type="submit" size="sm" disabled={inFlight}> <Send className="size-4" /> Send </Button> </form> </div> );};The load-bearing line for the type contract: spreading the whole part keeps the discriminated state/input/output union intact so it narrows inside the card. Pick those off as separate props and you throw the narrowing away before the card sees it.
invoice-stats-card.tsx
Section titled “invoice-stats-card.tsx”This is where the type narrowing earns its keep.
The card takes the whole tool invocation as its prop and switches on part.state.
Read the switch one state at a time; the trap is in how you read part, not in the markup.
export const InvoiceStatsCard = (part: StatsInvocation) => { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <StatsSkeleton />; case 'output-error': return <StatsError />; case 'output-available': { if ('error' in part.output) { return <StatsError />; }
const { count, totalAmount, byStatus, oldestUnpaidDueDate } = part.output; const filter = part.input.status;
return ( <div className="space-y-3 rounded-lg border p-4"> <h3 className="text-sm font-medium"> Invoice stats{filter ? ` · ${filter}` : ''} </h3> <div className="grid grid-cols-2 gap-3 text-sm"> <div> <p className="text-xs text-muted-foreground">Count</p> <p className="font-medium tabular-nums">{count}</p> </div> <div> <p className="text-xs text-muted-foreground">Total</p> <p className="font-medium tabular-nums"> {formatCurrency(totalAmount)} </p> </div> </div> <dl className="space-y-1 text-xs"> {Object.entries(byStatus).map(([status, n]) => ( <div key={status} className="flex justify-between"> <dt className="capitalize text-muted-foreground">{status}</dt> <dd className="tabular-nums">{n}</dd> </div> ))} </dl> <p className="text-xs text-muted-foreground"> Oldest unpaid due:{' '} <span className="tabular-nums text-foreground"> {formatDueDate(oldestUnpaidDueDate)} </span> </p> </div> ); } default: return null; }};The prop is the whole UIToolInvocation <InvoiceTools['getInvoiceStats']>, the same source the chat narrows from, so the card’s prop type can never drift from the message type.
export const InvoiceStatsCard = (part: StatsInvocation) => { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <StatsSkeleton />; case 'output-error': return <StatsError />; case 'output-available': { if ('error' in part.output) { return <StatsError />; }
const { count, totalAmount, byStatus, oldestUnpaidDueDate } = part.output; const filter = part.input.status;
return ( <div className="space-y-3 rounded-lg border p-4"> <h3 className="text-sm font-medium"> Invoice stats{filter ? ` · ${filter}` : ''} </h3> <div className="grid grid-cols-2 gap-3 text-sm"> <div> <p className="text-xs text-muted-foreground">Count</p> <p className="font-medium tabular-nums">{count}</p> </div> <div> <p className="text-xs text-muted-foreground">Total</p> <p className="font-medium tabular-nums"> {formatCurrency(totalAmount)} </p> </div> </div> <dl className="space-y-1 text-xs"> {Object.entries(byStatus).map(([status, n]) => ( <div key={status} className="flex justify-between"> <dt className="capitalize text-muted-foreground">{status}</dt> <dd className="tabular-nums">{n}</dd> </div> ))} </dl> <p className="text-xs text-muted-foreground"> Oldest unpaid due:{' '} <span className="tabular-nums text-foreground"> {formatDueDate(oldestUnpaidDueDate)} </span> </p> </div> ); } default: return null; }};Switch on part.state directly, not on a destructured const { state } = part: destructuring first widens part.output away from the narrowed arm.
export const InvoiceStatsCard = (part: StatsInvocation) => { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <StatsSkeleton />; case 'output-error': return <StatsError />; case 'output-available': { if ('error' in part.output) { return <StatsError />; }
const { count, totalAmount, byStatus, oldestUnpaidDueDate } = part.output; const filter = part.input.status;
return ( <div className="space-y-3 rounded-lg border p-4"> <h3 className="text-sm font-medium"> Invoice stats{filter ? ` · ${filter}` : ''} </h3> <div className="grid grid-cols-2 gap-3 text-sm"> <div> <p className="text-xs text-muted-foreground">Count</p> <p className="font-medium tabular-nums">{count}</p> </div> <div> <p className="text-xs text-muted-foreground">Total</p> <p className="font-medium tabular-nums"> {formatCurrency(totalAmount)} </p> </div> </div> <dl className="space-y-1 text-xs"> {Object.entries(byStatus).map(([status, n]) => ( <div key={status} className="flex justify-between"> <dt className="capitalize text-muted-foreground">{status}</dt> <dd className="tabular-nums">{n}</dd> </div> ))} </dl> <p className="text-xs text-muted-foreground"> Oldest unpaid due:{' '} <span className="tabular-nums text-foreground"> {formatDueDate(oldestUnpaidDueDate)} </span> </p> </div> ); } default: return null; }};input-streaming returns null while args arrive; input-available renders the card-shaped <StatsSkeleton />, so the shape conveys what is coming.
export const InvoiceStatsCard = (part: StatsInvocation) => { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <StatsSkeleton />; case 'output-error': return <StatsError />; case 'output-available': { if ('error' in part.output) { return <StatsError />; }
const { count, totalAmount, byStatus, oldestUnpaidDueDate } = part.output; const filter = part.input.status;
return ( <div className="space-y-3 rounded-lg border p-4"> <h3 className="text-sm font-medium"> Invoice stats{filter ? ` · ${filter}` : ''} </h3> <div className="grid grid-cols-2 gap-3 text-sm"> <div> <p className="text-xs text-muted-foreground">Count</p> <p className="font-medium tabular-nums">{count}</p> </div> <div> <p className="text-xs text-muted-foreground">Total</p> <p className="font-medium tabular-nums"> {formatCurrency(totalAmount)} </p> </div> </div> <dl className="space-y-1 text-xs"> {Object.entries(byStatus).map(([status, n]) => ( <div key={status} className="flex justify-between"> <dt className="capitalize text-muted-foreground">{status}</dt> <dd className="tabular-nums">{n}</dd> </div> ))} </dl> <p className="text-xs text-muted-foreground"> Oldest unpaid due:{' '} <span className="tabular-nums text-foreground"> {formatDueDate(oldestUnpaidDueDate)} </span> </p> </div> ); } default: return null; }};output-error is the SDK-level failure; the 'error' in part.output guard catches the tool’s own { error } arm before destructuring. Both land on <StatsError />.
export const InvoiceStatsCard = (part: StatsInvocation) => { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <StatsSkeleton />; case 'output-error': return <StatsError />; case 'output-available': { if ('error' in part.output) { return <StatsError />; }
const { count, totalAmount, byStatus, oldestUnpaidDueDate } = part.output; const filter = part.input.status;
return ( <div className="space-y-3 rounded-lg border p-4"> <h3 className="text-sm font-medium"> Invoice stats{filter ? ` · ${filter}` : ''} </h3> <div className="grid grid-cols-2 gap-3 text-sm"> <div> <p className="text-xs text-muted-foreground">Count</p> <p className="font-medium tabular-nums">{count}</p> </div> <div> <p className="text-xs text-muted-foreground">Total</p> <p className="font-medium tabular-nums"> {formatCurrency(totalAmount)} </p> </div> </div> <dl className="space-y-1 text-xs"> {Object.entries(byStatus).map(([status, n]) => ( <div key={status} className="flex justify-between"> <dt className="capitalize text-muted-foreground">{status}</dt> <dd className="tabular-nums">{n}</dd> </div> ))} </dl> <p className="text-xs text-muted-foreground"> Oldest unpaid due:{' '} <span className="tabular-nums text-foreground"> {formatDueDate(oldestUnpaidDueDate)} </span> </p> </div> ); } default: return null; }};Only here, past both guards, does part.output narrow to the real shape. part.input.status is the optional filter the model passed, surfaced as a title hint.
The rest of the file is supporting pieces: the prop type, the skeleton, the error message, and the format helpers.
'use client';
import type { UIToolInvocation } from 'ai';import { Temporal } from 'temporal-polyfill';import { Skeleton } from '@/components/ui/skeleton';import type { InvoiceTools } from '@/lib/llm/tools';
type StatsInvocation = UIToolInvocation<InvoiceTools['getInvoiceStats']>;
// The card-shaped loading affordance the tool-parts model provides (107 L2) —// stat-slot placeholders, not a generic loading glyph. Mapped over a stable// string-key tuple so Biome's `noArrayIndexKey` stays happy.const STAT_SLOTS = ['count', 'total', 'oldest'] as const;
const StatsSkeleton = () => ( <div data-testid="invoice-stats-skeleton" className="space-y-3 rounded-lg border p-4" > <Skeleton className="h-4 w-32" /> <div className="grid grid-cols-3 gap-3"> {STAT_SLOTS.map((slot) => ( <Skeleton key={slot} className="h-10 w-full" /> ))} </div> </div>);
const StatsError = () => ( <p className="text-sm text-destructive"> I couldn't load those stats. Try rephrasing. </p>);
14 collapsed lines
const formatCurrency = (amount: number): string => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(amount);
const formatDueDate = (iso: string | null): string => iso === null ? '—' : Temporal.PlainDate.from(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', });
InvoiceStatsCard.Skeleton = StatsSkeleton;Two pieces are worth a word beyond the comments:
StatsInvocationderives from the sameInvoiceToolssource the chat narrows from, so changing the tool’soutputSchemamoves both ends together.InvoiceStatsCard.Skeleton = StatsSkeletonexposes the loading shape as a static property, so any surface can render<InvoiceStatsCard.Skeleton />without a live tool part.formatCurrencyandformatDueDateare theIntl/Temporalformatters from the internationalization unit, reused.
token-usage-panel.tsx
Section titled “token-usage-panel.tsx”The panel is a single polling effect plus some derived display values. It takes no props and reads the acting user’s usage straight from the endpoint.
'use client';
import { useEffect, useState } from 'react';
type Usage = { used: number; cap: number; remaining: number };
// Green over half the budget left, amber in the 10–50% band, red under 10%.const barColor = (fraction: number): string => { if (fraction > 0.5) return 'bg-emerald-500'; if (fraction >= 0.1) return 'bg-amber-500'; return 'bg-red-500';};
export const TokenUsagePanel = () => { const [usage, setUsage] = useState<Usage | null>(null);
useEffect(() => { const controller = new AbortController();
const poll = async () => { try { const res = await fetch('/api/usage', { signal: controller.signal }); if (res.ok) setUsage((await res.json()) as Usage); } catch { // Ignore transient/aborted poll failures; the next tick retries. } };
void poll(); const interval = setInterval(() => void poll(), 10_000);
return () => { controller.abort(); clearInterval(interval); }; }, []);
const used = usage?.used ?? 0; const cap = usage?.cap ?? 100_000; const remaining = usage?.remaining ?? cap; const remainingFraction = cap === 0 ? 0 : remaining / cap; const usedPercent = Math.min(100, Math.round((used / cap) * 100));
return ( <div className="space-y-1 rounded-lg border p-3 text-xs"> <div className="flex justify-between text-muted-foreground"> <span>Daily token budget</span> <span className="tabular-nums"> {used.toLocaleString()} / {cap.toLocaleString()} </span> </div> <div className="h-2 w-full overflow-hidden rounded-full bg-muted"> <div className={`h-full transition-all motion-reduce:transition-none ${barColor(remainingFraction)}`} style={{ width: `${usedPercent}%` }} /> </div> <p className="text-muted-foreground tabular-nums"> {remaining.toLocaleString()} remaining </p> </div> );};The bar is colored by remaining budget, not used. A pure helper keeps the render declarative.
'use client';
import { useEffect, useState } from 'react';
type Usage = { used: number; cap: number; remaining: number };
// Green over half the budget left, amber in the 10–50% band, red under 10%.const barColor = (fraction: number): string => { if (fraction > 0.5) return 'bg-emerald-500'; if (fraction >= 0.1) return 'bg-amber-500'; return 'bg-red-500';};
export const TokenUsagePanel = () => { const [usage, setUsage] = useState<Usage | null>(null);
useEffect(() => { const controller = new AbortController();
const poll = async () => { try { const res = await fetch('/api/usage', { signal: controller.signal }); if (res.ok) setUsage((await res.json()) as Usage); } catch { // Ignore transient/aborted poll failures; the next tick retries. } };
void poll(); const interval = setInterval(() => void poll(), 10_000);
return () => { controller.abort(); clearInterval(interval); }; }, []);
const used = usage?.used ?? 0; const cap = usage?.cap ?? 100_000; const remaining = usage?.remaining ?? cap; const remainingFraction = cap === 0 ? 0 : remaining / cap; const usedPercent = Math.min(100, Math.round((used / cap) * 100));
return ( <div className="space-y-1 rounded-lg border p-3 text-xs"> <div className="flex justify-between text-muted-foreground"> <span>Daily token budget</span> <span className="tabular-nums"> {used.toLocaleString()} / {cap.toLocaleString()} </span> </div> <div className="h-2 w-full overflow-hidden rounded-full bg-muted"> <div className={`h-full transition-all motion-reduce:transition-none ${barColor(remainingFraction)}`} style={{ width: `${usedPercent}%` }} /> </div> <p className="text-muted-foreground tabular-nums"> {remaining.toLocaleString()} remaining </p> </div> );};The one useEffect this feature is allowed: polling an external system is exactly the escape hatch the React-effects discipline reserves an effect for. The AbortController cancels the in-flight fetch on unmount.
'use client';
import { useEffect, useState } from 'react';
type Usage = { used: number; cap: number; remaining: number };
// Green over half the budget left, amber in the 10–50% band, red under 10%.const barColor = (fraction: number): string => { if (fraction > 0.5) return 'bg-emerald-500'; if (fraction >= 0.1) return 'bg-amber-500'; return 'bg-red-500';};
export const TokenUsagePanel = () => { const [usage, setUsage] = useState<Usage | null>(null);
useEffect(() => { const controller = new AbortController();
const poll = async () => { try { const res = await fetch('/api/usage', { signal: controller.signal }); if (res.ok) setUsage((await res.json()) as Usage); } catch { // Ignore transient/aborted poll failures; the next tick retries. } };
void poll(); const interval = setInterval(() => void poll(), 10_000);
return () => { controller.abort(); clearInterval(interval); }; }, []);
const used = usage?.used ?? 0; const cap = usage?.cap ?? 100_000; const remaining = usage?.remaining ?? cap; const remainingFraction = cap === 0 ? 0 : remaining / cap; const usedPercent = Math.min(100, Math.round((used / cap) * 100));
return ( <div className="space-y-1 rounded-lg border p-3 text-xs"> <div className="flex justify-between text-muted-foreground"> <span>Daily token budget</span> <span className="tabular-nums"> {used.toLocaleString()} / {cap.toLocaleString()} </span> </div> <div className="h-2 w-full overflow-hidden rounded-full bg-muted"> <div className={`h-full transition-all motion-reduce:transition-none ${barColor(remainingFraction)}`} style={{ width: `${usedPercent}%` }} /> </div> <p className="text-muted-foreground tabular-nums"> {remaining.toLocaleString()} remaining </p> </div> );};Poll once immediately, then every ten seconds. Without the cleanup, every remount leaks a request and an interval; the abort lands in poll’s catch and is correctly swallowed. Ten seconds suits a slow-moving personal budget; a team dashboard would re-poll off the chat’s own onFinish instead.
'use client';
import { useEffect, useState } from 'react';
type Usage = { used: number; cap: number; remaining: number };
// Green over half the budget left, amber in the 10–50% band, red under 10%.const barColor = (fraction: number): string => { if (fraction > 0.5) return 'bg-emerald-500'; if (fraction >= 0.1) return 'bg-amber-500'; return 'bg-red-500';};
export const TokenUsagePanel = () => { const [usage, setUsage] = useState<Usage | null>(null);
useEffect(() => { const controller = new AbortController();
const poll = async () => { try { const res = await fetch('/api/usage', { signal: controller.signal }); if (res.ok) setUsage((await res.json()) as Usage); } catch { // Ignore transient/aborted poll failures; the next tick retries. } };
void poll(); const interval = setInterval(() => void poll(), 10_000);
return () => { controller.abort(); clearInterval(interval); }; }, []);
const used = usage?.used ?? 0; const cap = usage?.cap ?? 100_000; const remaining = usage?.remaining ?? cap; const remainingFraction = cap === 0 ? 0 : remaining / cap; const usedPercent = Math.min(100, Math.round((used / cap) * 100));
return ( <div className="space-y-1 rounded-lg border p-3 text-xs"> <div className="flex justify-between text-muted-foreground"> <span>Daily token budget</span> <span className="tabular-nums"> {used.toLocaleString()} / {cap.toLocaleString()} </span> </div> <div className="h-2 w-full overflow-hidden rounded-full bg-muted"> <div className={`h-full transition-all motion-reduce:transition-none ${barColor(remainingFraction)}`} style={{ width: `${usedPercent}%` }} /> </div> <p className="text-muted-foreground tabular-nums"> {remaining.toLocaleString()} remaining </p> </div> );};The ?? fallbacks let the panel render sane numbers before the first response lands. usedPercent is clamped to 100 so the bar never overshoots its track.
The project now runs end to end: a typed chat over real invoice aggregates, capped per user per day, refusing gracefully. Persisting messages across a refresh is the obvious next surface.
The typed tool parts and the four states — input-streaming, input-available, output-available, output-error — your card switches on.
Spreading a tool part into a React component — the same {...part} pattern that feeds InvoiceStatsCard.
The v5 transport-based API: sendMessage, status, onError, and DefaultChatTransport options.
Moment of truth
Section titled “Moment of truth”This project has no per-lesson tests; the check is the working surface and your own eyes. First run the full verification to confirm the client typechecks and the app builds:
pnpm verifyThat runs Biome’s CI lint, tsc --noEmit, and next build with SKIP_ENV_VALIDATION=true.
A missing generic or a part destructured before the switch widens part.output to unknown, and tsc fails here.
Expect green:
$ pnpm verify✓ biome check — no diagnostics✓ tsc --noEmit — no errors▲ next build ✓ Compiled successfully ✓ Generating static pagesDone.Then drive the surface by hand.
The live-chat checks make real model calls, so set AI_GATEWAY_API_KEY in .env first.
The inspector at /inspector carries the controls each step needs, and “Reset and re-seed” restores the seed between demos.
submitted → streaming → ready, flashes the InvoiceStatsCard.Skeleton while the tool input streams, renders the real card, then an assistant text bubble citing the count.<Spinner finds none in the chat tree — the loading shape is the per-tool skeleton.part.output in the tool-getInvoiceStats branch shows the projected { count, totalAmount, byStatus, oldestUnpaidDueDate } shape, not unknown; the card’s prop is typed from the same UIToolInvocation<InvoiceTools['getInvoiceStats']> source.output-error message and the model’s follow-up asks for a rephrase. Toggle it back off.onError and leaves the input enabled. Reset and re-seed after.POST /api/chat in the network tab.orgId reads org-globex — the scopedInvoices(ctx.orgId).active() inside execute is the structural reason.append(, reload(, message.content, ai/rsc, or streamUI; hits for sendMessage(, message.parts, and DefaultChatTransport; and the only importers of @/lib/llm/ are the two route handlers, with-llm-quota.ts, and invoice-chat.tsx (for the message type) — no Server Component imports the tools or the prompt.The project is complete: an ask-your-invoices chat that streams grounded answers inside your auth boundary, gives each tool its own loading shape, caps every user’s daily spend, and meets failures with a typed shape instead of a thrown 500.