caliper uidocs

Assistant panel

Free

A docked panel that holds a conversation beside the product.

Installs components/ui/assistant-panel.tsxDepends on @radix-ui/react-slot, lucide-reactMotion shared motion tokens
pnpm dlx shadcn@latest add https://caliperui.site/r/assistant-panel.json

Projects

  • 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.