Search for pages, actions, and quick links.
Prompt composers, tool calls, citations, model choice and spend. 8 blocks.
Auto-growing input, Enter to send, and Stop replacing Send while streaming.
src/components/blocks/ai/prompt-composer.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Paperclip, SendHorizonal, Square } from "lucide-react";
/**
* A prompt box that grows with its content and submits on Enter.
*
* The auto-grow is done by writing the scroll height back onto the element in the change handler,
* capped so a pasted essay cannot push the send button off screen. Bootstrap-style CSS cannot do
* this: a textarea has no intrinsic content height, which is why `field-sizing-content` exists but
* is not yet safe to rely on alone.
*
* Enter sends and Shift+Enter inserts a newline — the convention every chat UI uses. It is checked
* with `!event.shiftKey` rather than a keyCode, and `event.preventDefault()` stops the newline that
* would otherwise be inserted before state updates.
*
* While a response streams, the send button becomes Stop. Rendering both, with one disabled, gives
* the user two targets for one job and invites clicking the wrong one.
*/
export function PromptComposer() {
const [value, setValue] = useState("");
const [streaming, setStreaming] = useState(false);
const suggestions = ["Summarise last week", "Why did churn rise?", "Draft a status update"];
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{suggestions.map((suggestion) => (
<button key={suggestion} type="button" onClick={() => setValue(suggestion)}>
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/70">
{suggestion}
</Badge>
</button>
))}
</div>
<div className="rounded-lg border bg-card p-2 focus-within:border-primary/50">
<label htmlFor="ai-prompt" className="sr-only">
Ask a question
</label>
<textarea
id="ai-prompt"
rows={1}
value={value}
placeholder="Ask anything about your data…"
onChange={(event) => {
setValue(event.target.value);
// Reset first, or the height only ever ratchets upward as text is deleted.
event.target.style.height = "auto";
event.target.style.height = `${Math.min(event.target.scrollHeight, 160)}px`;
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (value.trim()) setStreaming(true);
}
}}
className="w-full resize-none bg-transparent px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground"
/>
<div className="flex items-center gap-2 pt-1">
<Button variant="ghost" size="sm" aria-label="Attach a file">
<Paperclip className="h-4 w-4" aria-hidden="true" />
</Button>
<p className="text-[11px] text-muted-foreground">
Enter to send · Shift+Enter for a new line
</p>
{streaming ? (
<Button size="sm" variant="outline" className="ms-auto" onClick={() => setStreaming(false)}>
<Square className="h-3.5 w-3.5" aria-hidden="true" />
Stop
</Button>
) : (
<Button
size="sm"
className="ms-auto"
disabled={!value.trim()}
onClick={() => setStreaming(true)}
>
<SendHorizonal className="h-4 w-4 rtl:rotate-180" aria-hidden="true" />
Send
</Button>
)}
</div>
</div>
</div>
);
}What the model ran and with which arguments, in a native disclosure.
src/components/blocks/ai/tool-call-card.tsx"use client";
import { useState } from "react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { cn } from "@dashboardpack/core/lib/utils";
import { ChevronRight, Database, Check } from "lucide-react";
/**
* What a model did, before it says what it found.
*
* A tool call is the part of an AI answer users are most suspicious of, so it should be inspectable
* rather than summarised. Collapsed it states the tool, the target and the duration; expanded it
* shows the arguments and the shape of the result.
*
* Built as a `<details>`/`<summary>` pair, not a div with an onClick. That gives keyboard operation,
* the correct expanded/collapsed semantics and in-page find-in-page behaviour with no ARIA and no
* state — and it still works if the JavaScript for this island never arrives.
*
* The chevron rotates via the `group-open:` variant, so the open state drives the visual from CSS
* rather than from a React boolean that could disagree with the element.
*/
export function ToolCallCard() {
const [calls] = useState([
{
tool: "query_orders",
target: "orders",
ms: 142,
rows: 1284,
args: '{ "status": "refunded", "since": "2026-06-01" }',
},
{
tool: "aggregate_revenue",
target: "invoices",
ms: 88,
rows: 96,
args: '{ "groupBy": "month", "currency": "USD" }',
},
]);
return (
<div className="space-y-2">
{calls.map((call) => (
<details key={call.tool} className="group rounded-lg border bg-card">
<summary className="flex cursor-pointer list-none items-center gap-3 p-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Database className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-xs font-medium">{call.tool}</span>
<span className="block text-xs text-muted-foreground">
{call.rows.toLocaleString("en-US")} rows from {call.target}
</span>
</span>
<Badge variant="secondary" className="gap-1 tabular-nums">
<Check className="h-3 w-3" aria-hidden="true" />
{call.ms}ms
</Badge>
<ChevronRight
aria-hidden="true"
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
"group-open:rotate-90 rtl:rotate-180 rtl:group-open:-rotate-90",
)}
/>
</summary>
<div className="space-y-2 border-t px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Arguments
</p>
<pre className="overflow-x-auto rounded-md bg-muted p-2 font-mono text-xs">
{call.args}
</pre>
</div>
</details>
))}
</div>
);
}Numbered markers that are real anchors into a source list.
src/components/blocks/ai/citation-list.tsx"use client";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { ExternalLink } from "lucide-react";
/**
* An answer whose claims are traceable to their sources.
*
* The numeric markers are `<sup>` inside real anchors pointing at the list below, so clicking a
* citation moves to its source and the browser's back button returns. A `<span>` with an onClick
* loses both, and loses the marker's meaning to a screen reader.
*
* Each marker's accessible name is written out — "Jump to source 1: Q3 revenue report" — because
* "1" read aloud in the middle of a sentence is noise. The visible text stays a bare numeral.
*
* `scroll-mt-4` on the list items keeps a jumped-to source clear of a sticky header, which is the
* usual reason in-page anchors appear to land in the wrong place.
*/
export function CitationList() {
const sources = [
{ n: 1, title: "Q3 revenue report", detail: "invoices · 96 records", href: "#" },
{ n: 2, title: "Churn cohort analysis", detail: "customers · 412 records", href: "#" },
{ n: 3, title: "Support ticket volume", detail: "tickets · 1,284 records", href: "#" },
];
return (
<div className="space-y-4">
<p className="text-sm leading-relaxed">
Revenue rose 12.4% quarter over quarter, driven mostly by expansion in existing accounts
<a
href="#src-1"
aria-label="Jump to source 1: Q3 revenue report"
className="text-primary hover:underline"
>
<sup className="ms-0.5 text-[10px] font-semibold">1</sup>
</a>
. Churn was flat at 2.1%, though the enterprise cohort improved
<a
href="#src-2"
aria-label="Jump to source 2: Churn cohort analysis"
className="text-primary hover:underline"
>
<sup className="ms-0.5 text-[10px] font-semibold">2</sup>
</a>
, and support volume per account fell for the third quarter running
<a
href="#src-3"
aria-label="Jump to source 3: Support ticket volume"
className="text-primary hover:underline"
>
<sup className="ms-0.5 text-[10px] font-semibold">3</sup>
</a>
.
</p>
<div className="space-y-2 border-t pt-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Sources
</p>
<ul className="space-y-1.5">
{sources.map((source) => (
<li key={source.n} id={`src-${source.n}`} className="scroll-mt-4">
<a
href={source.href}
className="flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-accent/50"
>
<Badge variant="secondary" className="h-5 w-5 justify-center p-0 text-[10px]">
{source.n}
</Badge>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{source.title}</span>
<span className="block text-xs text-muted-foreground">{source.detail}</span>
</span>
<ExternalLink
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</a>
</li>
))}
</ul>
</div>
</div>
);
}States the speed and cost trade-off, as a proper radio group.
src/components/blocks/ai/model-picker.tsx"use client";
import { useState } from "react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { cn } from "@dashboardpack/core/lib/utils";
import { Check, Gauge, Sparkles, Zap } from "lucide-react";
/**
* A model chooser that states the trade-off instead of just the name.
*
* Model names carry no information to anyone who has not memorised them, so each option states what
* it is for, its relative speed and its cost. That is the actual decision being made.
*
* Implemented as a `radiogroup` rather than a list of buttons: exactly one is chosen at a time,
* which is what radio semantics mean, and it gives arrow-key selection and a single tab stop for
* free. A group of `aria-pressed` buttons would let a screen reader believe several could be on.
*
* The check mark is `aria-hidden` — `aria-checked` on the option already conveys selection, and
* announcing both says it twice.
*/
export function ModelPicker() {
const models = [
{
id: "fast",
name: "Apex Flash",
blurb: "Best for quick lookups and summaries",
speed: "Fastest",
cost: "$0.12 / 1k",
Icon: Zap,
},
{
id: "balanced",
name: "Apex Standard",
blurb: "Good default for analysis and drafting",
speed: "Balanced",
cost: "$0.60 / 1k",
Icon: Gauge,
},
{
id: "deep",
name: "Apex Reasoning",
blurb: "Multi-step work where accuracy beats latency",
speed: "Slowest",
cost: "$2.40 / 1k",
Icon: Sparkles,
},
];
const [selected, setSelected] = useState("balanced");
return (
<div role="radiogroup" aria-label="Model" className="space-y-2">
{models.map((model) => {
const active = model.id === selected;
return (
<button
key={model.id}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => setSelected(model.id)}
className={cn(
"flex w-full items-start gap-3 rounded-lg border p-3 text-start transition-colors",
active ? "border-primary bg-primary/5" : "hover:bg-accent/40",
)}
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
active ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
)}
>
<model.Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1 space-y-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{model.name}</span>
<Badge variant="secondary" className="text-[10px]">
{model.speed}
</Badge>
</span>
<span className="block text-xs text-muted-foreground">{model.blurb}</span>
<span className="block text-xs tabular-nums text-muted-foreground">{model.cost}</span>
</span>
{active && <Check className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />}
</button>
);
})}
</div>
);
}Projects month-end from the run rate, and warns on the projection.
src/components/blocks/ai/token-usage-meter.tsx"use client";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* Spend against a budget, with the projection that makes it actionable.
*
* A usage bar showing 68% on day 20 of 30 is fine; the same 68% on day 8 is a problem. So the block
* projects the month-end total from the run rate and states whether that lands over budget. Without
* it the reader has to do the arithmetic, and mostly will not.
*
* The warning threshold compares the *projection* to the budget, not current usage. Alerting only
* when the bar is already full alerts after it is too late to change anything.
*/
export function TokenUsageMeter() {
const budget = 500;
const spent = 341.2;
const dayOfMonth = 19;
const daysInMonth = 31;
const projected = (spent / dayOfMonth) * daysInMonth;
const overBudget = projected > budget;
const pct = Math.round((spent / budget) * 100);
return (
<Card>
<CardContent className="space-y-4 p-4 sm:p-6">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm text-muted-foreground">Spend this month</p>
<p className="text-2xl font-bold tabular-nums">
${spent.toFixed(2)}{" "}
<span className="text-sm font-normal text-muted-foreground">of ${budget}</span>
</p>
</div>
<Badge variant={overBudget ? "warning" : "secondary"} className="tabular-nums">
{pct}%
</Badge>
</div>
<div
role="meter"
aria-label="Monthly spend against budget"
aria-valuemin={0}
aria-valuemax={budget}
aria-valuenow={spent}
aria-valuetext={`$${spent.toFixed(2)} of $${budget} spent`}
className="h-2 overflow-hidden rounded-full bg-muted"
>
<div
className={cn("h-full rounded-full", overBudget ? "bg-warning" : "bg-primary")}
style={{ width: `${Math.min(100, pct)}%` }}
/>
</div>
<dl className="grid grid-cols-2 gap-4 border-t pt-3">
<div>
<dt className="text-xs text-muted-foreground">Run rate</dt>
<dd className="text-sm font-semibold tabular-nums">
${(spent / dayOfMonth).toFixed(2)} / day
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Projected</dt>
<dd
className={cn(
"text-sm font-semibold tabular-nums",
overBudget ? "text-foreground" : "text-success",
)}
>
${projected.toFixed(0)}
<span className="ms-1 text-xs font-normal">
{overBudget ? "over budget" : "within budget"}
</span>
</dd>
</div>
</dl>
</CardContent>
</Card>
);
}Yields whole under prefers-reduced-motion, and clears its timer on unmount.
src/components/blocks/ai/streaming-answer.tsx"use client";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { RotateCcw, Square } from "lucide-react";
const ANSWER =
"Refunds rose 38% week over week, and all of the increase is in the EU store. The VAT threshold changed on the 25th, so checkout is quoting duty that customers are then charged again on delivery. Support tickets corroborate it: every one mentions an unexpected charge on arrival.";
/**
* `prefers-reduced-motion` as a subscription, so the value is known during render.
*
* Setting state from inside the effect to handle the reduced case triggers a second render pass
* before paint — React's lint rule flags it. Reading it here means the stream simply never starts.
* Server snapshot `false` matches what the static export prerenders.
*/
function usePrefersReducedMotion(): boolean {
return useSyncExternalStore(
useCallback((onChange: () => void) => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []),
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/**
* A streaming response that respects `prefers-reduced-motion`.
*
* Under reduced motion the answer appears whole rather than typing out. That is not a nicety — text
* revealing itself character by character is exactly the continuous motion the setting exists to
* suppress, and for some readers it makes the content unreadable while it runs.
*
* The region is `aria-live="polite"` with `aria-busy` while streaming, so a screen reader is not
* read a partial sentence on every tick. Announcing each chunk is worse than announcing nothing:
* the user hears the same sentence restart twenty times.
*
* The interval is cleared in the effect's cleanup, which matters because this block can be unmounted
* mid-stream by its lazy preview scrolling out of range.
*/
export function StreamingAnswer() {
const reduced = usePrefersReducedMotion();
const [shown, setShown] = useState(0);
const [streaming, setStreaming] = useState(true);
useEffect(() => {
if (!streaming || reduced) return;
const timer = setInterval(() => {
setShown((previous) => {
const next = previous + 3;
if (next >= ANSWER.length) {
clearInterval(timer);
return ANSWER.length;
}
return next;
});
}, 24);
return () => clearInterval(timer);
}, [streaming, reduced]);
// Reduced motion yields the whole response; otherwise show however much has arrived.
const visible = reduced ? ANSWER.length : shown;
const running = streaming && !reduced && visible < ANSWER.length;
return (
<div className="space-y-3">
<div
aria-live="polite"
aria-busy={running}
className="min-h-[96px] rounded-lg border bg-card p-4"
>
<p className="text-sm leading-relaxed">
{ANSWER.slice(0, visible)}
{running && (
<span
aria-hidden="true"
className="ms-0.5 inline-block h-4 w-1.5 animate-pulse bg-primary align-text-bottom"
/>
)}
</p>
</div>
<div className="flex gap-2">
{running ? (
<Button variant="outline" size="sm" onClick={() => setStreaming(false)}>
<Square className="h-3.5 w-3.5" aria-hidden="true" />
Stop
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={reduced}
onClick={() => {
setShown(0);
setStreaming(true);
}}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
Regenerate
</Button>
)}
</div>
</div>
);
}Grouped by recency, with delete reachable by keyboard.
src/components/blocks/ai/conversation-list.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
import { MessageSquarePlus, Trash2 } from "lucide-react";
/**
* A conversation sidebar grouped by recency.
*
* Grouped under "Today" / "Yesterday" / "Earlier" headings rather than a flat list, because a chat
* history is navigated by when rather than by title — and the groups are `<section>`s with real
* headings, so a screen reader can skip between them.
*
* The delete button appears on hover *and* on focus (`group-focus-within:`). Hover-only reveals are
* unreachable by keyboard, which makes the action effectively nonexistent for anyone not using a
* mouse — the single most common failure in this pattern.
*/
export function ConversationList() {
const groups = [
{
label: "Today",
items: [
{ id: "a", title: "Why did EU refunds spike?", preview: "The VAT threshold changed on the 25th…" },
{ id: "b", title: "Draft the Q3 board summary", preview: "Revenue rose 12.4% quarter over…" },
],
},
{
label: "Yesterday",
items: [{ id: "c", title: "Churn by cohort", preview: "Enterprise improved while SMB…" }],
},
{
label: "Earlier",
items: [
{ id: "d", title: "Latency regression after v3", preview: "p95 moved from 412ms to 680ms…" },
{ id: "e", title: "Which SKUs are unprofitable?", preview: "Four SKUs have negative margin…" },
],
},
];
const [active, setActive] = useState("a");
return (
<div className="space-y-3 rounded-lg border p-2">
<Button variant="outline" size="sm" className="w-full">
<MessageSquarePlus className="h-4 w-4" aria-hidden="true" />
New conversation
</Button>
{groups.map((group) => (
<section key={group.label} className="space-y-1">
<h3 className="px-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</h3>
<ul>
{group.items.map((item) => (
<li key={item.id} className="group relative">
<button
type="button"
onClick={() => setActive(item.id)}
aria-current={active === item.id ? "true" : undefined}
className={cn(
"w-full rounded-md p-2 pe-9 text-start transition-colors",
active === item.id ? "bg-accent" : "hover:bg-accent/50",
)}
>
<span className="block truncate text-sm font-medium">{item.title}</span>
<span className="block truncate text-xs text-muted-foreground">
{item.preview}
</span>
</button>
{/* Focus-within as well as hover: a hover-only control is invisible to a keyboard. */}
<button
type="button"
aria-label={`Delete conversation: ${item.title}`}
className="absolute top-1/2 -translate-y-1/2 rounded-md p-1.5 text-muted-foreground opacity-0 transition-opacity hover:bg-background hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 end-1"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</li>
))}
</ul>
</section>
))}
</div>
);
}A thumbs-down asks what was wrong, turning one bit into signal.
src/components/blocks/ai/response-feedback.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Copy, ThumbsDown, ThumbsUp } from "lucide-react";
/**
* Feedback controls where a thumbs-down asks what was wrong.
*
* A bare thumbs-down records that someone was unhappy and nothing about why, which is close to
* useless. Revealing reason chips turns one bit into something actionable, and it costs the user one
* extra click only when they were already dissatisfied.
*
* The two thumbs are mutually exclusive and use `aria-pressed`, so the chosen state is announced
* rather than only shown as a filled icon. Selecting one clears the other in state, not just
* visually — otherwise a rapid up-then-down submits both.
*/
export function ResponseFeedback() {
const [vote, setVote] = useState<"up" | "down" | null>(null);
const [reason, setReason] = useState<string | null>(null);
const reasons = ["Factually wrong", "Missed the question", "Too vague", "Wrong tone"];
return (
<div className="space-y-3 rounded-lg border p-3">
<p className="text-sm leading-relaxed text-muted-foreground">
Revenue rose 12.4% quarter over quarter, driven mostly by expansion in existing accounts.
</p>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
aria-label="Good response"
aria-pressed={vote === "up"}
onClick={() => {
setVote(vote === "up" ? null : "up");
setReason(null);
}}
className={vote === "up" ? "text-success" : undefined}
>
<ThumbsUp className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="sm"
aria-label="Poor response"
aria-pressed={vote === "down"}
onClick={() => setVote(vote === "down" ? null : "down")}
className={vote === "down" ? "text-destructive" : undefined}
>
<ThumbsDown className="h-4 w-4" aria-hidden="true" />
</Button>
<Button variant="ghost" size="sm" aria-label="Copy response" className="ms-auto">
<Copy className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
{/* Only asked when the answer is that something was wrong. */}
{vote === "down" && (
<div className="space-y-2 border-t pt-3">
<p className="text-xs text-muted-foreground">What was wrong with it?</p>
<div className="flex flex-wrap gap-2">
{reasons.map((option) => (
<button key={option} type="button" onClick={() => setReason(option)}>
<Badge
variant={reason === option ? "default" : "secondary"}
className="cursor-pointer"
>
{option}
</Badge>
</button>
))}
</div>
</div>
)}
<span role="status" aria-live="polite" className="sr-only">
{vote === "up" ? "Marked as a good response" : ""}
{vote === "down" && reason ? `Marked as poor: ${reason}` : ""}
</span>
</div>
);
}