Assistant panel
FreeA docked panel that holds a conversation beside the product.
pnpm dlx shadcn@latest add https://caliperui.site/r/assistant-panel.jsonProjects
- Atlas platformDeploy failing · 2h
- Billing revampReview due Friday
- Mobile onboardingOn track
When to use #
Use it for
- An assistant sits next to the page the user is working on.
- The panel should open and close without losing the thread.
Skip it when
- The assistant is the whole page; use the chat shell.
Installation #
terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/assistant-panel.json
Adds its 3 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage #
This is the code behind the preview above.
example.tsx
import { MessageSquarePlus } from "lucide-react" import { useEffect, useRef, useState, useSyncExternalStore } from "react" import { AssistantPanel } from "@/components/ui/assistant-panel" import { Button } from "@/components/ui/button" import { Message } from "@/components/ui/message" import { MessageScroller } from "@/components/ui/message-scroller" import { PromptInput } from "@/components/ui/prompt-input" type Turn = { id: string; role: "user" | "assistant"; text: string; status?: "pending" | "complete" }; const answers = [ "Here is a short answer. Start with the smallest change, check it with the keyboard, then add polish.", "Good question. I would keep one source of truth and let the components stay controlled.", "That depends on the screen. If you tell me where it lives, I can be more specific.", ]; function useScriptedChat(initial: Turn[]) { const [turns, setTurns] = useState(initial); const n = useRef(0); const timers = useRef<ReturnType<typeof setTimeout>[]>([]); useEffect(() => () => timers.current.forEach(clearTimeout), []); const send = (text: string) => { const id = n.current++; setTurns((t) => [...t, { id: `u${id}-${t.length}`, role: "user", text }, { id: `a${id}-${t.length}`, role: "assistant", text: "", status: "pending" }]); timers.current.push( setTimeout(() => setTurns((t) => t.map((x, i) => (i === t.length - 1 && x.status === "pending" ? { ...x, text: answers[id % answers.length], status: "complete" } : x))), 1100), ); }; return [turns, send, setTurns] as const; } function Turns({ turns }: { turns: Turn[] }) { return ( <> {turns.map((t) => ( <Message key={t.id} role={t.role} name={t.role === "assistant" ? "Assistant" : undefined} status={t.status === "pending" ? "pending" : "complete"}> {t.text || null} </Message> ))} </> ); } const WIDE = "(min-width: 768px)"; function subscribeWide(onChange: () => void) { const query = window.matchMedia(WIDE); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); } export function AssistantPanelDemo() { // Docked beside the page on wide screens; behind the button on phones, where it opens as a sheet. const wide = useSyncExternalStore(subscribeWide, () => window.matchMedia(WIDE).matches, () => false); const [choice, setOpen] = useState<boolean | null>(null); const open = choice ?? wide; const [turns, send] = useScriptedChat([{ id: "s0", role: "assistant", text: "Ask me about your projects, like which one needs attention first.", status: "complete" }]); return ( <div className="flex h-[520px] w-full overflow-hidden rounded-xl border border-border bg-background"> <div className="grid min-w-0 flex-1 content-start gap-4 p-5"> <div className="flex items-center justify-between gap-3"> <h3 className="text-sm font-semibold">Projects</h3> <Button variant="secondary" size="sm" aria-expanded={open} onClick={() => setOpen(!open)}> <MessageSquarePlus aria-hidden="true" className="size-4" /> {open ? "Close assistant" : "Ask the assistant"} </Button> </div> <ul className="grid text-sm"> {[["Atlas platform", "Deploy failing · 2h"], ["Billing revamp", "Review due Friday"], ["Mobile onboarding", "On track"]].map(([name, state]) => ( <li key={name} className="flex justify-between gap-3 border-t border-border py-3"><span className="truncate font-medium">{name}</span><span className="shrink-0 text-muted-foreground">{state}</span></li> ))} </ul> </div> <AssistantPanel open={open} onOpenChange={setOpen} footer={<PromptInput allowFiles={false} placeholder="Ask about this workspace" onSubmit={({ text }) => send(text)} />} > <MessageScroller label="Assistant conversation" followKey={turns.length}> <Turns turns={turns} /> </MessageScroller> </AssistantPanel> </div> ); }
API reference #
AssistantPanel
component
PropTypeDefaultDescription
open *boolean—
onOpenChange *(open: boolean) => void—
titlestring"Assistant"
actionsReactNode—Header buttons, such as New chat.
footerReactNode—Usually a PromptInput.
children *ReactNode—
widthstring"24rem"
classNamestring—
Accessibility #
It is built on native elements and keeps aria-hidden and aria-label in step with state.
KeyBehaviour
EscapeCloses or cancels, then returns focus to where it came from.
Motion #
Transitions use the shared duration and easing tokens. Under prefers-reduced-motion those tokens collapse to instant and travel distances go to zero, so state changes still happen without movement.