# Colloquy — AI Chat UI Component for React

> Agent chat panel composing the AI set — a role="log" transcript of right-aligned user bubbles and unbubbled assistant turns (thinking disclosure, inline tool-call cards on a progress spine, an inline approval request, a streamed answer) above a pinned prompt composer. Auto-scroll follows the stream only while you're at the bottom; scrolling up detaches it behind a spring-in "Jump to latest" pill. Empty-state greeting with suggestion chips, rise-in messages, and a seeded useScriptedConversation hook that waits on approvals. Instant scroll and fades under reduced motion.

- Page: https://crucibleui.com/components/ai/colloquy
- Category: [ai](https://crucibleui.com/components/ai)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/colloquy
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/colloquy`.

## Props (`ColloquyProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `messages?` | `ColloquyMessage[]` | `[]` | The controlled transcript, oldest first. An empty list shows the greeting and suggestions. |
| `onSend?` | `(text: string) => void` | — | Fires with the trimmed text when the user sends a message (Enter, the send button, or a suggestion in `"send"` mode). |
| `busy?` | `boolean` | `false` | The assistant is working: the composer shows a stop button and blocks Enter. |
| `onStop?` | `() => void` | — | Fires from the composer's stop button or Escape while `busy`. |
| `suggestions?` | `string[]` | — | Suggestion chips shown in the empty state. Pass `[]` to hide them. |
| `suggestionAction?` | `"fill" \| "send"` | `"fill"` | What clicking a suggestion does: `"fill"` puts it in the composer, `"send"` sends it right away. |
| `greeting?` | `React.ReactNode` | `"How can I help?"` | Heading of the empty state. |
| `placeholder?` | `string` | `"Ask anything…"` | Composer placeholder. |
| `draft?` | `string` | — | Controlled composer draft. Pair with `onDraftChange`. |
| `defaultDraft?` | `string` | `""` | Initial composer draft when uncontrolled. |
| `onDraftChange?` | `(draft: string) => void` | — | Fires with the next draft on every edit and when a suggestion fills the composer. |
| `composerProps?` | `Omit< SluiceProps, "value" \| "defaultValue" \| "onValueChange" \| "onSubmit" \| "busy" \| "onStop" \| "placeholder" \| "accentColor" >` | — | Extra props for the underlying `Sluice` composer (e.g. `models`, `hideAttach={false}`). |
| `followThreshold?` | `number` | `48` | How far from the bottom (px) still counts as "following the stream". |
| `accentColor?` | `string` | `"#a78bfa" (ion violet)` | Accent for the composer, streaming caret, tool spine, approval button and focus rings. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"section">, "children" | "onSubmit">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import {
  Colloquy,
  useScriptedConversation,
  type ColloquyScriptTurn,
} from "@/components/crucible/colloquy";

const SUGGESTIONS = [
  "Why is the date test failing?",
  "Summarize this week's open bugs",
  "Raise the request timeout to 30s",
];

const SCRIPT: ColloquyScriptTurn[] = [
  {
    match: SUGGESTIONS[0],
    reasoning:
      "The failure is probably in the date helpers. I'll find the failing test, read the function it covers, then re-run that suite to confirm before suggesting a fix.",
    thinkMs: 1800,
    tools: [
      {
        name: "search_code",
        summary: "Find the failing assertion",
        input: { query: "formatRelative", glob: "src/**/*.test.ts" },
        output: "2 matches\nsrc/utils/date.test.ts:31  expect(formatRelative(36 * HOUR)).toBe(\"1 day ago\")\nsrc/utils/date.ts:14  export function formatRelative(ms: number)",
        durationMs: 1200,
      },
      {
        name: "read_file",
        summary: "Read the date helper",
        input: { path: "src/utils/date.ts", lines: [12, 22] },
        output:
          "export function formatRelative(ms: number) {\n  if (ms < MINUTE) return \"just now\";\n  const days = Math.round(ms / DAY);\n  return `${days} day${days === 1 ? \"\" : \"s\"} ago`;\n}",
        durationMs: 950,
      },
    ],
    approval: {
      title: "run_tests",
      description: "Re-run the date suite to confirm the cause.",
      preview: { kind: "command", command: "npm test -- src/utils/date" },
      risk: "low",
    },
    answer:
      "The test fails because `formatRelative` rounds whole days with `Math.round`, so 36 hours reads as \"2 days ago\" while the test expects \"1 day ago\".\n\nThe suite confirms it: 1 failing, 23 passing. The fix is small:\n\n- Use `Math.floor` when counting whole days\n- Keep the \"just now\" branch for anything under a minute\n\nWant me to apply it?",
    deniedAnswer:
      "No problem — I skipped `run_tests`, so this is unconfirmed.\n\nFrom the code, `formatRelative` rounds whole days with `Math.round`, so 36 hours reads as \"2 days ago\" while the test expects \"1 day ago\". Switching to `Math.floor` should fix it.",
  },
  {
    match: SUGGESTIONS[1],
    reasoning: "I'll pull bugs opened in the last seven days, then group them by area so the summary is skimmable.",
    thinkMs: 1300,
    tools: [
      {
        name: "list_issues",
        summary: "Bugs opened in the last 7 days",
        input: { label: "bug", state: "open", since: "7d" },
        output: { count: 5, areas: { auth: 2, billing: 1, editor: 2 } },
        durationMs: 1100,
      },
      {
        name: "read_issues",
        summary: "Read the five reports",
        input: { ids: [412, 415, 418, 421, 422] },
        output: "5 issues loaded (2 with repro steps, 1 marked urgent)",
        durationMs: 900,
      },
    ],
    answer:
      "Five bugs were opened this week, across three areas:\n\n- Auth (2): session expires early on mobile, and one urgent sign-in loop after a password reset\n- Editor (2): undo skips pasted blocks; slow typing in very long documents\n- Billing (1): the invoice total rounds before tax\n\nThe sign-in loop is the one to pick up first — it has clean repro steps.",
  },
  {
    match: SUGGESTIONS[2],
    reasoning: "Find where the timeout is defined, then propose a one-line change for approval.",
    thinkMs: 1100,
    tools: [
      {
        name: "search_code",
        summary: "Locate the timeout setting",
        input: { query: "requestTimeoutMs" },
        output: "1 match\nsrc/config/http.ts:4  requestTimeoutMs: 10_000,",
        durationMs: 900,
      },
    ],
    approval: {
      title: "write_file",
      description: "Change the default request timeout from 10s to 30s.",
      preview: {
        kind: "diff",
        file: "src/config/http.ts",
        startLine: 3,
        lines: [
          { type: "ctx", text: "export const http = {" },
          { type: "del", text: "  requestTimeoutMs: 10_000," },
          { type: "add", text: "  requestTimeoutMs: 30_000," },
          { type: "ctx", text: "};" },
        ],
      },
      risk: "low",
    },
    answer:
      "Done — `requestTimeoutMs` is now `30_000` in `src/config/http.ts`. Requests that used to give up after 10 seconds will now wait up to 30.",
    deniedAnswer: "Okay — I skipped `write_file`, so the timeout is still 10 seconds. Nothing was changed.",
  },
  {
    thinkMs: 700,
    answer:
      "This preview plays a short script, so I can't answer that one for real. Try one of the suggestions to see thinking, tool calls and an approval step.",
  },
];

export default function ColloquyDemo() {
  const panelRef = React.useRef<HTMLDivElement>(null);
  const convo = useScriptedConversation(SCRIPT, { viewportRef: panelRef });
  const { send, reset } = convo;
  const [draft, setDraft] = React.useState("");
  const [inView, setInView] = React.useState(false);
  // Once anything has happened (auto-send, a click, typing) we stop driving.
  const [touched, setTouched] = React.useState(false);

  React.useEffect(() => {
    const el = panelRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([entry]) => setInView(!!entry?.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, []);

  // Auto-send the first suggestion after 1.5s on screen, unless the visitor got there first.
  React.useEffect(() => {
    if (touched || !inView) return;
    const t = window.setTimeout(() => {
      setTouched(true);
      send(SUGGESTIONS[0]);
    }, 1500);
    return () => window.clearTimeout(t);
  }, [touched, inView, send]);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950 px-4 py-8">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(167,139,250,0.06),transparent_70%)]"
      />
      <div className="relative z-10 flex w-full max-w-[42rem] flex-col gap-3">
        <div ref={panelRef}>
          <Colloquy
            className="h-[560px]"
            messages={convo.messages}
            busy={convo.busy}
            onSend={(text) => {
              setTouched(true);
              send(text);
            }}
            onStop={convo.stop}
            suggestions={SUGGESTIONS}
            suggestionAction="send"
            draft={draft}
            onDraftChange={(next) => {
              if (next) setTouched(true);
              setDraft(next);
            }}
          />
        </div>
        <div className="flex items-center justify-between gap-4 px-1">
          <p className="text-xs text-neutral-500">Pick a suggestion or type a message.</p>
          <button
            type="button"
            disabled={convo.messages.length === 0}
            onClick={() => {
              setTouched(true);
              setDraft("");
              reset();
            }}
            className="rounded-md border border-white/10 px-2.5 py-1 text-xs text-neutral-300 transition-colors hover:bg-white/5 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#a78bfa] disabled:pointer-events-none disabled:opacity-40"
          >
            Restart
          </button>
        </div>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.
