Message Timing
message-timing
Badge with streaming stats: time to first token, total time, and tokens per second.
"use client";
import { useMessageTiming } from "@assistant-ui/react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { FC } from "react";
const formatTimingMs = (ms: number | undefined): string => {
if (ms === undefined) return "—";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
/**
* Shows streaming stats (TTFT, total time, tok/s, chunks) as a badge with a
* hover/focus tooltip. Renders nothing until the stream completes.
*
* Place it inside `ActionBarPrimitive.Root` in your `thread.tsx` so it
* inherits the action bar's autohide behaviour:
*
* ```tsx
* import { MessageTiming } from "@/components/assistant-ui/elements/message-timing.aui";
*
* <ActionBarPrimitive.Root >
* <ActionBarPrimitive.Copy />
* <ActionBarPrimitive.Reload />
* <MessageTiming /> // <-- add this
* </ActionBarPrimitive.Root>
* ```
*
* @param side - Side of the tooltip relative to the badge trigger.
* @default "right"
*/
export const MessageTiming: FC<{
className?: string;
side?: "top" | "right" | "bottom" | "left";
}> = ({ className, side = "right" }) => {
const timing = useMessageTiming();
if (timing?.totalStreamTime === undefined) return null;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="message-timing-trigger"
aria-label="Message timing"
className={cn(
"text-muted-foreground hover:bg-accent hover:text-accent-foreground flex items-center rounded-md p-1 font-mono text-xs tabular-nums transition-colors",
className,
)}
>
{formatTimingMs(timing.totalStreamTime)}
</button>
</TooltipTrigger>
<TooltipContent
side={side}
sideOffset={8}
data-slot="message-timing-popover"
className="bg-popover text-popover-foreground rounded-lg border px-3 py-2 [&_span>svg]:hidden!"
>
<div className="grid min-w-35 gap-1.5 text-xs">
{timing.firstTokenTime !== undefined && (
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">First token</span>
<span className="font-mono tabular-nums">
{formatTimingMs(timing.firstTokenTime)}
</span>
</div>
)}
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Total</span>
<span className="font-mono tabular-nums">
{formatTimingMs(timing.totalStreamTime)}
</span>
</div>
{timing.tokensPerSecond !== undefined && (
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Speed</span>
<span className="font-mono tabular-nums">
{timing.tokensPerSecond.toFixed(1)} tok/s
</span>
</div>
)}
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Chunks</span>
<span className="font-mono tabular-nums">
{timing.totalChunks}
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
More from @assistant-ui
Parallel workers with their own progress, models, and completions.
A unified diff with tinted additions and removals, sized for chat.
Files as received rather than staged: an image to open, a document with its page count.
Shared design language for the elements family: surface, ink, and motion class recipes plus the shimmer utility.
Everything the app can do, one keystroke away and grouped by where it acts.
A user bubble and a streaming assistant reply, with actions that appear on hover.
A live status line that names what the agent is doing right now, with elapsed time.
The unified input: attachments, commands, mentions, models, voice, and context in one surface.