# Conversation

> A transcript that follows streaming text, stops when you scroll up and offers Jump to latest with an unread count.

Category: AI. Docs: https://neonkit.heyaadi.com/components/conversation

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/conversation.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/conversation`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Icons](https://neonkit.heyaadi.com/components/icons.md). The CLI installs them too.

## Usage

```tsx
import { Conversation, ConversationDivider } from "@/components/ui/conversation"

<Conversation className="h-[600px]" streaming={streaming}>
  <ConversationDivider>Today</ConversationDivider>
  {messages.map((m) => <ChatMessage key={m.id} {...m} />)}
</Conversation>
```

## Examples

### An agent session

All six AI components in one session. The agent thinks, lays out a plan, runs the failing suite, reads the file and stops to ask before it edits. Approve it and watch the plan fill to the end; Deny it and it explains what it left alone.

```tsx
"use client";

import * as React from "react";
import { AgentPlan, type PlanStep } from "@/components/ui/agent-plan";
import { ChatMarkdown, ChatMessage } from "@/components/ui/chat-message";
import { CodeBlock } from "@/components/ui/code-block";
import { Conversation, ConversationDivider, type ConversationActions } from "@/components/ui/conversation";
import { PromptInput, type PromptMessage } from "@/components/ui/prompt-input";
import { Reasoning } from "@/components/ui/reasoning";
import { ToolCall, type ToolCallStatus } from "@/components/ui/tool-call";

/* ------------------------------------------------------------------------------------------------
 * The session: a coding agent fixing a test that broke when CI moved to a runner in Frankfurt.
 * ---------------------------------------------------------------------------------------------- */

const ASK =
  "`npm test` has been red on main since we moved CI runners to eu-central-1. One failure, in `date.test.ts`. Nothing in that file changed. Can you fix it?";

const THOUGHT = `A date test that broke on a region move, with no code change, points at time zones. The old runners were on UTC; eu-central-1 defaults to Europe/Berlin, which leaves summer time on October 25.

If \`addDays\` adds a fixed 86,400,000 ms per day, stepping across that night lands an hour short. Reproduce it with TZ set, then read \`src/date.ts\`.`;

const FAIL = ` FAIL  src/date.test.ts > addDays > keeps local midnight across the DST change
AssertionError: expected 2026-10-25T22:00:00.000Z to equal 2026-10-25T23:00:00.000Z

 ❯ src/date.test.ts:18:31
     18|     expect(addDays(start, 2)).toEqual(new Date(2026, 9, 26));

 Test Files  1 failed | 6 passed (7)
      Tests  1 failed | 13 passed (14)`;

const SOURCE = `const DAY = 86_400_000;

export function addDays(date: Date, days: number) {
  return new Date(date.getTime() + days * DAY);
}`;

const DIFF = ` export function addDays(date: Date, days: number) {
-  return new Date(date.getTime() + days * DAY);
+  const next = new Date(date);
+  next.setDate(next.getDate() + days);
+  return next;
 }`;

const PASS = ` Test Files  7 passed (7)
      Tests  14 passed (14)
   Duration  1.71s`;

const ANSWER = `Fixed. \`addDays\` added 86,400,000 ms per day, but October 25 is 25 hours long in Berlin, so stepping across it landed at 23:00 the day before. The UTC runners never had a 25-hour day, which is why it only broke on the move.

\`\`\`ts src/date.ts diff
${DIFF}
\`\`\`

All 14 tests pass with \`TZ=Europe/Berlin\`. \`formatRelative\` in \`src/format.ts\` divides by \`DAY\` the same way and will be off on the same night. Want me to fix that one too?`;

const DENIED = `Left \`src/date.ts\` as it was. The cause stands: \`addDays\` adds 86,400,000 ms per day and October 25 is 25 hours in Berlin. If you'd rather not change the helper, pinning \`TZ=UTC\` in the CI job hides it, but users in Europe would still get the wrong date.`;

const FOLLOW_UP = `Same bug, other direction: \`formatRelative\` divides the gap by \`DAY\` and floors it, so on the night of October 25 "tomorrow" reads as "today". Comparing calendar dates instead of milliseconds fixes it. I'll hold off until you've merged the first change, so the two don't land in one commit.`;

type Tool = {
  id: string;
  name: string;
  summary: string;
  args?: Record<string, unknown>;
  status: ToolCallStatus;
  duration?: number;
  meta?: string;
  result?: string;
  resultLanguage?: string;
  preview?: boolean;
};

type Reply = {
  id: string;
  from: "assistant";
  time: string;
  streaming: boolean;
  thought?: { text: string; streaming: boolean };
  plan?: PlanStep[];
  tools: Record<string, Tool>;
  text?: string;
};

type Ask = { id: string; from: "user"; time: string; text: string };
type Message = Ask | Reply;

const PLAN: PlanStep[] = [
  { id: "reproduce", title: "Reproduce with TZ=Europe/Berlin", status: "active" },
  { id: "cause", title: "Find why addDays is an hour short", status: "pending" },
  { id: "patch", title: "Step by calendar day in src/date.ts", status: "pending" },
  { id: "rerun", title: "Re-run the suite", status: "pending" },
];

// Which tool calls hang under which step of the plan.
const TOOLS_BY_STEP: Record<string, string[]> = {
  reproduce: ["test-1"],
  cause: ["read"],
  patch: ["edit"],
  rerun: ["test-2"],
};

const now = () => new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });

/* ------------------------------------------------------------------------------------------------
 * Rendering
 * ---------------------------------------------------------------------------------------------- */

function ToolRow({ tool, onDecide }: { tool: Tool; onDecide: (approved: boolean) => void }) {
  return (
    <ToolCall
      name={tool.name}
      summary={tool.summary}
      args={tool.args}
      status={tool.status}
      duration={tool.duration}
      meta={tool.meta}
      result={tool.result}
      resultLanguage={tool.resultLanguage}
      approvalNote={
        <>
          Edits <span className="font-mono text-[12px] text-white/85">src/date.ts</span>: 3 lines in, 1 out.
        </>
      }
      onApprove={() => onDecide(true)}
      onDeny={() => onDecide(false)}
    >
      {tool.preview && (
        <CodeBlock
          code={DIFF}
          language="ts"
          diff
          header={false}
          lineNumbers={false}
          className="rounded-lg border-white/[0.06] bg-black/25 [&_.nk-code-scroll]:text-[12px]"
        />
      )}
    </ToolCall>
  );
}

const Row = React.memo(function Row({ message, onDecide }: { message: Message; onDecide: (approved: boolean) => void }) {
  if (message.from === "user") {
    return <ChatMessage from="user" name="Maya Chen" time={message.time} content={message.text} />;
  }
  const steps = message.plan?.map((step) => {
    const tools = (TOOLS_BY_STEP[step.id ?? ""] ?? []).map((id) => message.tools[id]).filter(Boolean);
    return tools.length
      ? {
          ...step,
          content: (
            <div className="grid gap-2">
              {tools.map((tool) => (
                <ToolRow key={tool.id} tool={tool} onDecide={onDecide} />
              ))}
            </div>
          ),
        }
      : step;
  });
  const loose = Object.values(message.tools).filter((tool) => !Object.values(TOOLS_BY_STEP).flat().includes(tool.id));
  return (
    <ChatMessage
      from="assistant"
      name="Argon"
      time={message.time}
      streaming={message.streaming}
      copyText={message.text}
      actions={message.streaming ? "none" : "hover"}
    >
      {message.thought && <Reasoning streaming={message.thought.streaming}>{message.thought.text}</Reasoning>}
      {steps && <AgentPlan title="Fix the failing date test" steps={steps} timer={false} className="py-1" />}
      {loose.map((tool) => (
        <ToolRow key={tool.id} tool={tool} onDecide={onDecide} />
      ))}
      {message.text !== undefined && (
        <ChatMarkdown streaming={message.streaming}>{message.text}</ChatMarkdown>
      )}
    </ChatMessage>
  );
});

/* ------------------------------------------------------------------------------------------------
 * The director: plays the session, waits for your decision on the edit
 * ---------------------------------------------------------------------------------------------- */

class Stopped extends Error {}

export default function ConversationDemo() {
  const [messages, setMessages] = React.useState<Message[]>([
    { id: "ask", from: "user", time: "09:12", text: ASK },
    { id: "reply", from: "assistant", time: "09:12", streaming: true, tools: {} },
  ]);
  const [live, setLive] = React.useState(true);
  const conversation = React.useRef<ConversationActions>(null);
  const run = React.useRef(0);
  const decide = React.useRef<((approved: boolean) => void) | null>(null);
  const timers = React.useRef<Set<ReturnType<typeof setTimeout>>>(new Set());

  const patch = React.useCallback((id: string, change: (reply: Reply) => Reply) => {
    setMessages((list) => list.map((m) => (m.id === id && m.from === "assistant" ? change(m) : m)));
  }, []);

  const play = React.useCallback(
    async (id: string, script: (ctx: Director) => Promise<void>) => {
      const token = ++run.current;
      setLive(true);
      const sleep = (ms: number) =>
        new Promise<void>((resolve, reject) => {
          const timer = setTimeout(() => {
            timers.current.delete(timer);
            if (token === run.current) resolve();
            else reject(new Stopped());
          }, ms);
          timers.current.add(timer);
        });
      const type = async (text: string, write: (sofar: string) => void, pace: number) => {
        let at = 0;
        while (at < text.length) {
          const fences = (text.slice(0, at).match(/```/g) ?? []).length;
          if (fences % 2 === 1) {
            const line = text.indexOf("\n", at);
            at = line < 0 ? text.length : line + 1;
          } else {
            const word = /\s*\S+\s?/y;
            word.lastIndex = at;
            at = word.exec(text) ? word.lastIndex : text.length;
          }
          write(text.slice(0, at));
          await sleep(/[.:?]\s?$/.test(text.slice(0, at)) ? pace * 7 : pace + (at % 4) * 5);
        }
      };
      const ctx: Director = {
        sleep,
        type,
        update: (change) => patch(id, change),
        tool: (tool) => patch(id, (r) => ({ ...r, tools: { ...r.tools, [tool.id]: { ...r.tools[tool.id], ...tool } as Tool } })),
        step: (stepId, change) =>
          patch(id, (r) => ({ ...r, plan: r.plan?.map((s) => (s.id === stepId ? { ...s, ...change } : s)) })),
        approval: () =>
          new Promise<boolean>((resolve, reject) => {
            decide.current = (approved) => {
              decide.current = null;
              if (token === run.current) resolve(approved);
              else reject(new Stopped());
            };
          }),
      };
      try {
        await script(ctx);
        patch(id, (r) => ({ ...r, streaming: false }));
        setLive(false);
      } catch (error) {
        if (!(error instanceof Stopped)) throw error;
      }
    },
    [patch],
  );

  const cancel = React.useCallback(() => {
    run.current++;
    timers.current.forEach(clearTimeout);
    timers.current.clear();
    decide.current = null;
  }, []);

  React.useEffect(() => {
    const start = setTimeout(() => play("reply", session), 700);
    return () => {
      clearTimeout(start);
      cancel();
    };
  }, [play, cancel]);

  const stop = () => {
    cancel();
    setLive(false);
    setMessages((list) =>
      list.map((m) => {
        if (m.from !== "assistant" || !m.streaming) return m;
        const tools = Object.fromEntries(
          Object.entries(m.tools).map(([key, tool]) => [
            key,
            tool.status === "running" || tool.status === "approval" ? { ...tool, status: "denied" as const, meta: "Stopped" } : tool,
          ]),
        );
        return {
          ...m,
          streaming: false,
          tools,
          thought: m.thought && { ...m.thought, streaming: false },
          plan: m.plan?.map((s) =>
            s.status === "active" ? { ...s, status: "failed" as const, meta: "Stopped" } : s,
          ),
        };
      }),
    );
  };

  const send = ({ text }: PromptMessage) => {
    const id = `reply-${Date.now()}`;
    setMessages((list) => [
      ...list,
      { id: `ask-${Date.now()}`, from: "user", time: now(), text },
      { id, from: "assistant", time: now(), streaming: true, tools: {} },
    ]);
    conversation.current?.scrollToBottom();
    play(id, followUp);
  };

  const onDecide = React.useCallback((approved: boolean) => decide.current?.(approved), []);

  return (
    <div className="flex h-[720px] w-full max-w-3xl flex-col">
      <Conversation actionsRef={conversation} streaming={live} className="flex-1" contentClassName="px-1 pb-10 sm:px-3">
        <ConversationDivider>Today</ConversationDivider>
        {messages.map((message) => (
          <Row key={message.id} message={message} onDecide={onDecide} />
        ))}
      </Conversation>
      <PromptInput
        className="mt-1"
        placeholder="Reply to Argon"
        streaming={live}
        onStop={stop}
        onSubmit={send}
        maxRows={8}
      />
    </div>
  );
}

type Director = {
  sleep: (ms: number) => Promise<void>;
  type: (text: string, write: (sofar: string) => void, pace: number) => Promise<void>;
  update: (change: (reply: Reply) => Reply) => void;
  tool: (tool: Partial<Tool> & { id: string }) => void;
  step: (id: string, change: Partial<PlanStep>) => void;
  approval: () => Promise<boolean>;
};

async function session({ sleep, type, update, tool, step, approval }: Director) {
  await type(THOUGHT, (text) => update((r) => ({ ...r, thought: { text, streaming: true } })), 34);
  await sleep(500);
  update((r) => ({ ...r, thought: { text: THOUGHT, streaming: false }, plan: PLAN }));
  await sleep(500);

  const test = { name: "run_command", summary: "TZ=Europe/Berlin npm test", args: { command: "TZ=Europe/Berlin npm test", cwd: "~/src/ledger" } };
  tool({ id: "test-1", ...test, status: "running" });
  await sleep(1840);
  tool({ id: "test-1", status: "error", duration: 1.84, meta: "1 failed", result: FAIL, resultLanguage: "bash" });
  step("reproduce", { status: "done" });
  step("cause", { status: "active" });
  await sleep(600);

  tool({ id: "read", name: "read_file", summary: "src/date.ts", args: { path: "src/date.ts" }, status: "running" });
  await sleep(420);
  tool({ id: "read", status: "success", duration: 0.1, result: SOURCE, resultLanguage: "ts" });
  await sleep(500);
  step("cause", { status: "done", description: "It adds 86,400,000 ms per day. October 25 is 25 hours long in Berlin." });
  step("patch", { status: "active" });
  await sleep(500);

  tool({ id: "edit", name: "edit_file", summary: "src/date.ts", status: "approval", preview: true });
  const approved = await approval();
  if (!approved) {
    tool({ id: "edit", status: "denied", preview: false });
    step("patch", { status: "skipped", meta: "Denied" });
    step("rerun", { status: "skipped" });
    await sleep(500);
    await type(DENIED, (text) => update((r) => ({ ...r, text })), 22);
    return;
  }
  tool({ id: "edit", status: "running", preview: false });
  await sleep(500);
  tool({ id: "edit", status: "success", duration: 0.2, meta: "+3 −1", result: DIFF, resultLanguage: "diff" });
  step("patch", { status: "done" });
  step("rerun", { status: "active" });
  await sleep(400);

  tool({ id: "test-2", ...test, status: "running" });
  await sleep(1710);
  tool({ id: "test-2", status: "success", duration: 1.71, meta: "14 passed", result: PASS, resultLanguage: "bash" });
  step("rerun", { status: "done" });
  await sleep(700);
  await type(ANSWER, (text) => update((r) => ({ ...r, text })), 24);
}

async function followUp({ sleep, type, update, tool }: Director) {
  await type(
    "The user means `formatRelative`. Read it before saying anything about the fix.",
    (text) => update((r) => ({ ...r, thought: { text, streaming: true } })),
    34,
  );
  await sleep(400);
  update((r) => ({ ...r, thought: r.thought && { ...r.thought, streaming: false } }));
  tool({ id: "read-format", name: "read_file", summary: "src/format.ts", args: { path: "src/format.ts" }, status: "running" });
  await sleep(450);
  tool({ id: "read-format", status: "success", duration: 0.1, meta: "38 lines" });
  await sleep(400);
  await type(FOLLOW_UP, (text) => update((r) => ({ ...r, text })), 24);
}
```

### A debugging chat

Conversation, ChatMessage, PromptInput and CodeBlock together. The reply thinks, searches, reads a file and streams a fix as a diff; send a follow-up to get a test.

```tsx
"use client";

import * as React from "react";
import {
  ChatMarkdown,
  ChatMessage,
  ChatReasoning,
  ChatToolCall,
  type ChatAttachmentItem,
  type ChatFeedback,
  type ChatToolStatus,
} from "@/components/ui/chat-message";
import { Conversation, ConversationDivider, type ConversationActions } from "@/components/ui/conversation";
import { PromptInput, type PromptMessage } from "@/components/ui/prompt-input";
import { Select } from "@/components/ui/select";

/* A screenshot of the bug: "acme" in the box, results for "ac" underneath. */
const SCREENSHOT = `data:image/svg+xml,${encodeURIComponent(
  `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="224" viewBox="0 0 320 224"><rect width="320" height="224" fill="#0b0d13"/><rect x="16" y="16" width="288" height="34" rx="8" fill="#141823" stroke="#2a3040"/><text x="30" y="38" font-family="ui-sans-serif,system-ui" font-size="13" fill="#e8edf5">acme</text><rect x="62" y="26" width="1.5" height="15" fill="#22d3ee"/><g font-family="ui-sans-serif,system-ui" font-size="12"><rect x="16" y="64" width="288" height="40" rx="6" fill="#10131b"/><text x="30" y="88" fill="#c9d1de">Acorn Labs</text><text x="236" y="88" fill="#5a6276">#4410</text><rect x="16" y="110" width="288" height="40" rx="6" fill="#10131b"/><text x="30" y="134" fill="#c9d1de">Acadia Health</text><text x="236" y="134" fill="#5a6276">#3982</text><rect x="16" y="156" width="288" height="40" rx="6" fill="#10131b"/><text x="30" y="180" fill="#c9d1de">Arcadian Supply</text><text x="236" y="180" fill="#5a6276">#2077</text></g></svg>`,
)}`;

const FIX = `\`\`\`ts src/hooks/use-customer-search.ts diff {22}
 export function useCustomerSearch(query: string) {
   const [results, setResults] = useState<Customer[]>([]);
   const [loading, setLoading] = useState(false);

   useEffect(() => {
     if (!query) return setResults([]);
+    const controller = new AbortController();
     setLoading(true);
-    fetch(\`/api/customers?q=\${query}\`)
+    fetch(\`/api/customers?q=\${encodeURIComponent(query)}\`, {
+      signal: controller.signal,
+    })
       .then((res) => res.json())
       .then((data) => setResults(data.customers))
-      .finally(() => setLoading(false));
+      .catch((error) => {
+        if (error.name !== "AbortError") throw error;
+      })
+      .finally(() => {
+        if (!controller.signal.aborted) setLoading(false);
+      });
+    return () => controller.abort();
   }, [query]);

   return { results, loading };
 }
\`\`\``;

const HOOK_SOURCE = `export function useCustomerSearch(query: string) {
  const [results, setResults] = useState<Customer[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) return setResults([]);
    setLoading(true);
    fetch(\`/api/customers?q=\${query}\`)
      .then((res) => res.json())
      .then((data) => setResults(data.customers))
      .finally(() => setLoading(false));
  }, [query]);

  return { results, loading };
}`;

const TEST = `\`\`\`ts src/hooks/use-customer-search.test.ts
it("ignores a response for a stale query", async () => {
  const pending: Record<string, (body: unknown) => void> = {};
  vi.spyOn(globalThis, "fetch").mockImplementation((url, init) =>
    new Promise((resolve, reject) => {
      const q = new URL(String(url), "http://test").searchParams.get("q")!;
      init?.signal?.addEventListener("abort", () =>
        reject(new DOMException("Aborted", "AbortError")),
      );
      pending[q] = (body) => resolve(Response.json(body));
    }),
  );

  const { result, rerender } = renderHook(({ q }) => useCustomerSearch(q), {
    initialProps: { q: "ac" },
  });
  rerender({ q: "acme" });

  pending.acme({ customers: [{ id: 2, name: "Acme Freight" }] });
  pending.ac({ customers: [{ id: 1, name: "Acorn Labs" }] });

  await waitFor(() => expect(result.current.loading).toBe(false));
  expect(result.current.results).toEqual([{ id: 2, name: "Acme Freight" }]);
});
\`\`\``;

type Step =
  | { kind: "reasoning"; text: string }
  | { kind: "tool"; name: string; args: Record<string, unknown>; result: string; resultLanguage?: string; ms: number }
  | { kind: "text"; text: string };

type Part =
  | { kind: "reasoning"; text: string; streaming: boolean }
  | {
      kind: "tool";
      name: string;
      args: Record<string, unknown>;
      result?: string;
      resultLanguage?: string;
      status: ChatToolStatus;
      duration?: number;
      error?: string;
    }
  | { kind: "text"; text: string };

type Message = {
  id: string;
  from: "user" | "assistant";
  time: string;
  text?: string;
  attachments?: ChatAttachmentItem[];
  parts?: Part[];
  streaming?: boolean;
  script?: Step[];
};

const SCRIPTS: Step[][] = [
  [
    {
      kind: "reasoning",
      text: "Results for an older query winning means responses are resolving out of order. Every debounced change starts a fetch, and every fetch calls setResults when it lands. If the request for “ac” is slower than the one for “acme”, it lands last and overwrites the newer results.\n\nThe debounce only narrows that window. It can't close it. I need to see whether the hook cancels anything on cleanup.",
    },
    {
      kind: "tool",
      name: "search_code",
      args: { query: "setResults(", path: "src/" },
      result: "src/hooks/use-customer-search.ts:10\nsrc/pages/orders/order-search.tsx:41",
      ms: 900,
    },
    {
      kind: "tool",
      name: "read_file",
      args: { path: "src/hooks/use-customer-search.ts" },
      result: HOOK_SOURCE,
      resultLanguage: "ts",
      ms: 600,
    },
    {
      kind: "text",
      text: `Found it. \`useCustomerSearch\` starts a request for every debounced query but never cancels the previous one, so whichever response arrives **last** wins, even when it belongs to an older query.

Abort the in-flight request when the query changes:

${FIX}

Three things changed:

- The effect's cleanup aborts the previous request, so a slow “ac” response can't land after “acme”.
- \`loading\` only clears for the request that's still current.
- The query is URL-encoded, so “O'Brien & Sons” no longer breaks the request.

\`order-search.tsx\` has the same pattern on line 41. Want a test that resolves two requests out of order?`,
    },
  ],
  [
    {
      kind: "reasoning",
      text: "The test has to hold the first request open, let the second one resolve, then release the first. The fetch mock must reject on abort the way the real one does, or the stale response still lands.",
    },
    {
      kind: "text",
      text: `Here it is. The mock keeps each request pending until the test releases it, and rejects on abort like the real \`fetch\`:

${TEST}`,
    },
    {
      kind: "tool",
      name: "run_tests",
      args: { file: "src/hooks/use-customer-search.test.ts" },
      result: "✓ ignores a response for a stale query  14ms\n\nTest Files  1 passed (1)\n     Tests  1 passed (1)",
      resultLanguage: "bash",
      ms: 1400,
    },
    {
      kind: "text",
      text: "Passes on this branch. On `main` it fails with **Acorn Labs** in the results, so it pins down exactly the bug you saw.",
    },
  ],
  [
    {
      kind: "text",
      text: "I'd ship it as is. The cleanup also runs on unmount, so leaving the page mid-request no longer sets state on a component that's gone.",
    },
  ],
];

const INITIAL: Message[] = [
  {
    id: "m1",
    from: "user",
    time: "10:42",
    text: "Search on /customers sometimes shows results for an older query. Type “acme” quickly and you can end up looking at results for “ac”. Started after we added the 250ms debounce. Can you find it?",
    attachments: [{ name: "customers-search.png", type: "image/png", size: 184_320, url: SCREENSHOT }],
  },
  { id: "m2", from: "assistant", time: "10:42", parts: [], streaming: true, script: SCRIPTS[0] },
];

const MODELS = [
  { value: "argon-2", label: "Argon 2" },
  { value: "argon-2-mini", label: "Argon 2 Mini" },
];

/** Where the next chunk ends: a word at a time in prose, a line at a time inside code fences. */
function nextChunk(text: string, from: number) {
  const fences = (text.slice(0, from).match(/```/g) ?? []).length;
  if (fences % 2 === 1) {
    const line = text.indexOf("\n", from);
    return line < 0 ? text.length : line + 1;
  }
  const word = /\s*\S+\s?/y;
  word.lastIndex = from;
  return word.exec(text) ? word.lastIndex : text.length;
}

function delayAfter(text: string, at: number, base: number) {
  const prev = text[at - 1];
  if (text.slice(at - 2, at) === "\n\n") return base * 6;
  if (prev === "." || prev === "?" || prev === ":") return base * 4;
  if (prev === "\n") return base * 1.5;
  return base + (at % 5) * 6;
}

function textOf(message: Message) {
  if (message.text) return message.text;
  return (message.parts ?? [])
    .filter((part): part is Extract<Part, { kind: "text" }> => part.kind === "text")
    .map((part) => part.text)
    .join("\n\n");
}

const Row = React.memo(function Row({
  message,
  latest,
  onRetry,
}: {
  message: Message;
  latest: boolean;
  onRetry: (id: string) => void;
}) {
  const [feedback, setFeedback] = React.useState<ChatFeedback>(null);
  if (message.from === "user") {
    return (
      <ChatMessage
        from="user"
        name="Maya Chen"
        time={message.time}
        content={message.text}
        attachments={message.attachments}
      />
    );
  }
  return (
    <ChatMessage
      from="assistant"
      name="Argon"
      time={message.time}
      streaming={message.streaming}
      copyText={textOf(message)}
      actions={latest ? "always" : "hover"}
      feedback={feedback}
      onFeedbackChange={setFeedback}
      onRetry={() => onRetry(message.id)}
    >
      {message.parts?.map((part, index) => {
        if (part.kind === "reasoning") {
          return (
            <ChatReasoning key={index} streaming={part.streaming}>
              {part.text}
            </ChatReasoning>
          );
        }
        if (part.kind === "tool") {
          return (
            <ChatToolCall
              key={index}
              name={part.name}
              args={part.args}
              status={part.status}
              result={part.result}
              resultLanguage={part.resultLanguage}
              duration={part.duration}
              error={part.error}
            />
          );
        }
        const last = index === (message.parts?.length ?? 0) - 1;
        return (
          <ChatMarkdown key={index} streaming={message.streaming && last}>
            {part.text}
          </ChatMarkdown>
        );
      })}
    </ChatMessage>
  );
});

export default function ConversationChat() {
  const [messages, setMessages] = React.useState<Message[]>(INITIAL);
  const [model, setModel] = React.useState("argon-2");
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const live = React.useRef<string | null>(null);
  const replies = React.useRef(1);
  const conversation = React.useRef<ConversationActions>(null);
  const streaming = messages.some((message) => message.streaming);

  const patch = React.useCallback((id: string, change: (message: Message) => Message) => {
    setMessages((list) => list.map((message) => (message.id === id ? change(message) : message)));
  }, []);

  const run = React.useCallback(
    (id: string, script: Step[]) => {
      clearTimeout(timer.current);
      live.current = id;
      let step = 0;
      let at = 0;

      const setParts = (change: (parts: Part[]) => Part[]) =>
        patch(id, (message) => ({ ...message, parts: change(message.parts ?? []) }));
      const replaceLast = (part: Part) => setParts((parts) => [...parts.slice(0, -1), part]);

      const tick = () => {
        const current = script[step];
        if (!current) {
          live.current = null;
          patch(id, (message) => ({ ...message, streaming: false }));
          return;
        }
        if (current.kind === "tool") {
          if (at === 0) {
            at = 1;
            setParts((parts) => [...parts, { kind: "tool", name: current.name, args: current.args, status: "running" }]);
            timer.current = setTimeout(tick, current.ms);
          } else {
            replaceLast({
              kind: "tool",
              name: current.name,
              args: current.args,
              result: current.result,
              resultLanguage: current.resultLanguage,
              status: "done",
              duration: current.ms / 1000,
            });
            step++;
            at = 0;
            timer.current = setTimeout(tick, 320);
          }
          return;
        }
        if (at === 0) setParts((parts) => [...parts, current.kind === "reasoning" ? { kind: "reasoning", text: "", streaming: true } : { kind: "text", text: "" }]);
        at = nextChunk(current.text, at);
        const text = current.text.slice(0, at);
        const done = at >= current.text.length;
        replaceLast(current.kind === "reasoning" ? { kind: "reasoning", text, streaming: !done } : { kind: "text", text });
        if (done) {
          step++;
          at = 0;
          timer.current = setTimeout(tick, current.kind === "reasoning" ? 500 : 260);
        } else {
          timer.current = setTimeout(tick, delayAfter(current.text, at, current.kind === "reasoning" ? 34 : 26));
        }
      };
      tick();
    },
    [patch],
  );

  const stop = React.useCallback(() => {
    clearTimeout(timer.current);
    const id = live.current;
    live.current = null;
    if (!id) return;
    patch(id, (message) => ({
      ...message,
      streaming: false,
      parts: message.parts?.map((part) =>
        part.kind === "reasoning"
          ? { ...part, streaming: false }
          : part.kind === "tool" && part.status === "running"
            ? { ...part, status: "error", error: "Stopped before it finished." }
            : part,
      ),
    }));
  }, [patch]);

  const latest = React.useRef(messages);
  React.useEffect(() => {
    latest.current = messages;
  });

  const retry = React.useCallback(
    (id: string) => {
      const script = latest.current.find((message) => message.id === id)?.script;
      if (!script) return;
      patch(id, (message) => ({ ...message, parts: [], streaming: true }));
      run(id, script);
    },
    [patch, run],
  );

  React.useEffect(() => {
    const start = setTimeout(() => run("m2", SCRIPTS[0]), 700);
    return () => {
      clearTimeout(start);
      clearTimeout(timer.current);
    };
  }, [run]);

  const send = ({ text, attachments }: PromptMessage) => {
    const time = new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
    const script = SCRIPTS[Math.min(replies.current, SCRIPTS.length - 1)];
    replies.current += 1;
    const userId = `u${Date.now()}`;
    const replyId = `a${Date.now()}`;
    setMessages((list) => [
      ...list,
      {
        id: userId,
        from: "user",
        time,
        text,
        attachments: attachments.map(({ name, size, type, url }) => ({ name, size, type, url })),
      },
      { id: replyId, from: "assistant", time, parts: [], streaming: true, script },
    ]);
    // Sending is a request to see the reply, even if you'd scrolled up to reread something.
    conversation.current?.scrollToBottom();
    timer.current = setTimeout(() => run(replyId, script), 500);
  };

  const lastId = messages[messages.length - 1]?.id;

  return (
    <div className="flex h-[680px] w-full max-w-3xl flex-col">
      <Conversation actionsRef={conversation} streaming={streaming} className="flex-1" contentClassName="px-1 pb-10 sm:px-3">
        <ConversationDivider>Today</ConversationDivider>
        {messages.map((message) => (
          <Row key={message.id} message={message} latest={message.id === lastId} onRetry={retry} />
        ))}
      </Conversation>
      <PromptInput
        className="mt-1"
        placeholder="Reply to Argon"
        streaming={streaming}
        onStop={stop}
        onSubmit={send}
        maxRows={10}
        toolbar={
          <Select
            aria-label="Model"
            size="sm"
            options={MODELS}
            value={model}
            onValueChange={setModel}
            containerClassName="w-auto"
            className="h-8 w-auto gap-1.5 border-transparent bg-transparent px-2 text-[13px] text-white/65 hover:border-transparent hover:bg-white/[0.06] hover:text-white/90"
          />
        }
      />
    </div>
  );
}
```

### 300 messages

Scroll up and new messages stop pulling you down. Jump to latest appears with a count of what arrived since.

```tsx
"use client";

import * as React from "react";
import { ChatMessage } from "@/components/ui/chat-message";
import { Conversation, ConversationDivider } from "@/components/ui/conversation";

const PEOPLE = ["Maya Chen", "Sam Okafor", "Priya Raman", "Jonas Weber"];
const LINES = [
  "Deploy for web finished in 3m 12s.",
  "Can someone look at the flaky checkout test? It failed twice on main this morning.",
  "Looking. It waits for a toast that animates out before the assertion runs.",
  "Bumped the Postgres pool from 10 to 20 on the worker; queue depth is back under 50.",
  "Invite emails are going out again. The SMTP password had rotated.",
  "p95 on /api/search is 210ms, down from 380ms after the index.",
  "Reminder: freeze for 2.4 starts Thursday at 18:00.",
  "Merged the rate-limit change. Five invites per minute per address.",
];
const INCOMING = [
  "Staging is green again.",
  "Rolled the fix to 10% of traffic. Error rate is flat.",
  "Bumping to 50%.",
  "100%. Closing the incident.",
  "Postmortem doc is in the incidents folder.",
];

/** 300 messages over four days, the same on the server and the client. */
const HISTORY = Array.from({ length: 300 }, (_, i) => {
  const minutes = 9 * 60 + ((i * 7) % 540);
  return {
    id: `h${i}`,
    day: Math.floor(i / 75),
    name: PEOPLE[(i * 3) % PEOPLE.length],
    time: `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`,
    text: LINES[(i * 5) % LINES.length],
  };
});
const DAYS = ["Friday, October 9", "Monday, October 12", "Yesterday", "Today"];

const Row = React.memo(function Row({ name, time, text }: { name: string; time: string; text: string }) {
  return <ChatMessage from="user" name={name} time={time} content={text} actions="none" />;
});

export default function ConversationLongThread() {
  const [extra, setExtra] = React.useState<{ id: string; text: string }[]>([]);
  const [busy, setBusy] = React.useState(false);
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);

  React.useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const simulate = () => {
    setBusy(true);
    INCOMING.forEach((text, index) => {
      timers.current.push(
        setTimeout(() => {
          setExtra((list) => [...list, { id: `n${list.length}`, text }]);
          if (index === INCOMING.length - 1) setBusy(false);
        }, 1200 * (index + 1)),
      );
    });
  };

  return (
    <div className="grid w-full max-w-2xl gap-3">
      <Conversation className="h-[520px] rounded-xl border border-white/[0.07] bg-[#07080d]" contentClassName="gap-5">
        {HISTORY.map((message, index) => (
          <React.Fragment key={message.id}>
            {(index === 0 || HISTORY[index - 1].day !== message.day) && (
              <ConversationDivider>{DAYS[message.day]}</ConversationDivider>
            )}
            <Row name={message.name} time={message.time} text={message.text} />
          </React.Fragment>
        ))}
        {extra.map((message) => (
          <Row key={message.id} name="Jonas Weber" time="now" text={message.text} />
        ))}
      </Conversation>
      <div className="flex flex-wrap items-center justify-between gap-3 px-1 text-[13px] text-white/50">
        <span>{HISTORY.length + extra.length} messages. Scroll up, then let new ones arrive.</span>
        <button
          type="button"
          disabled={busy}
          onClick={simulate}
          className="cursor-pointer rounded-md px-2 py-1 font-medium text-white/80 outline-none transition-colors hover:bg-white/[0.06] focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] disabled:cursor-default disabled:opacity-50"
        >
          {busy ? "Receiving…" : "Receive 5 messages"}
        </button>
      </div>
    </div>
  );
}
```

### Empty state

The empty slot fills the space until the first message. Pick a suggestion or type one.

```tsx
"use client";

import * as React from "react";
import { ChatMessage } from "@/components/ui/chat-message";
import { Conversation } from "@/components/ui/conversation";
import { PromptInput } from "@/components/ui/prompt-input";

const SUGGESTIONS = [
  "Why is /api/search slower since Tuesday?",
  "Write a migration that adds expires_at to invites",
  "Explain the retry logic in the webhook worker",
];

const REPLY =
  "Pulling the traces for /api/search since Tuesday. The first slow requests start at 14:05, four minutes after the deploy that added the `include: { tags: true }` to the search query.";

type Message = { id: string; from: "user" | "assistant"; text: string; streaming?: boolean };

export default function ConversationEmpty() {
  const [messages, setMessages] = React.useState<Message[]>([]);
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  React.useEffect(() => () => clearTimeout(timer.current), []);

  const ask = (text: string) => {
    setMessages([
      { id: "q", from: "user", text },
      { id: "a", from: "assistant", text: "", streaming: true },
    ]);
    let at = 0;
    const tick = () => {
      const re = /\s*\S+\s?/y;
      re.lastIndex = at;
      at = re.exec(REPLY) ? re.lastIndex : REPLY.length;
      const done = at >= REPLY.length;
      setMessages((list) => list.map((m) => (m.id === "a" ? { ...m, text: REPLY.slice(0, at), streaming: !done } : m)));
      if (!done) timer.current = setTimeout(tick, 34);
    };
    timer.current = setTimeout(tick, 500);
  };

  const streaming = messages.some((m) => m.streaming);

  return (
    <div className="flex h-[460px] w-full max-w-2xl flex-col gap-2">
      <Conversation
        className="flex-1"
        streaming={streaming}
        empty={
          <div className="grid justify-items-start gap-5 px-1">
            <p className="max-w-[16ch] text-[28px] font-semibold leading-[1.05] tracking-[-0.03em] text-white/90 [font-stretch:112%]">
              What&apos;s slow, broken or unclear?
            </p>
            <ul className="-mx-3 grid w-[calc(100%+1.5rem)] gap-0.5">
              {SUGGESTIONS.map((suggestion) => (
                <li key={suggestion}>
                  <button
                    type="button"
                    onClick={() => ask(suggestion)}
                    className="w-full cursor-pointer rounded-lg px-3 py-2 text-left text-[14.5px] text-white/65 outline-none transition-colors hover:bg-white/[0.04] hover:text-white/90 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
                  >
                    {suggestion}
                  </button>
                </li>
              ))}
            </ul>
          </div>
        }
      >
        {messages.map((m) =>
          m.from === "user" ? (
            <ChatMessage key={m.id} from="user" layout="bubble" content={m.text} />
          ) : (
            <ChatMessage key={m.id} from="assistant" name="Argon" content={m.text} streaming={m.streaming} />
          ),
        )}
      </Conversation>
      <PromptInput
        placeholder="Ask Argon"
        streaming={streaming}
        onStop={() => {
          clearTimeout(timer.current);
          setMessages((list) => list.map((m) => ({ ...m, streaming: false })));
        }}
        onSubmit={({ text }) => ask(text)}
      />
    </div>
  );
}
```

### Dates and system notes

ConversationDivider separates days. initialScroll="top" opens a thread at its start.

```tsx
import { ChatMessage } from "@/components/ui/chat-message";
import { Conversation, ConversationDivider } from "@/components/ui/conversation";

export default function ConversationDividers() {
  return (
    <Conversation className="h-[440px] w-full max-w-2xl" initialScroll="top">
      <ConversationDivider>Monday, October 12</ConversationDivider>
      <ChatMessage
        from="user"
        layout="bubble"
        time="17:40"
        content="Draft the changelog entry for invite expiry. Keep it under 60 words."
      />
      <ChatMessage
        from="assistant"
        layout="bubble"
        name="Argon"
        time="17:40"
        content={"**Invites now expire.** New invites stop working after 7 days. Change the window per team in Settings → Members, or turn expiry off for teams that sign in with SSO. Existing invites keep their current behaviour until they're resent."}
      />
      <ConversationDivider>Today</ConversationDivider>
      <ChatMessage from="system" time="09:02" content="Priya Raman joined the thread." />
      <ChatMessage from="user" layout="bubble" time="09:05" content="Ship it with the 2.4 notes. Priya will add the screenshot." />
    </Conversation>
  );
}
```

## API reference

### Conversation

Renders a div around a scrolling log. Give it a height, or put it in a flex column.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `empty` | `ReactNode` | — | Shown when there are no children. |
| `streaming` | `boolean` | `false` | Sets aria-busy so screen readers wait for the finished reply. |
| `autoScroll` | `boolean` | `true` | Follow new content while the reader is at the bottom. |
| `threshold` | `number` | `56` | Pixels from the bottom that still count as at the bottom. |
| `initialScroll` | `"bottom" \| "top"` | `"bottom"` | Where it opens. |
| `jumpLabel` | `string` | `"Jump to latest"` | Text on the pill. |
| `contentClassName` | `string` | — | Classes for the column holding the messages. |
| `actionsRef` | `Ref<{ scrollToBottom, isAtBottom }>` | — | Imperative controls, e.g. to jump down after sending from elsewhere. |
| `onStickChange` | `(stuck: boolean) => void` | — | Called when following starts or stops. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Lights the unread count. |

### ConversationDivider

A labelled separator. Not counted as an unread message.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | `text content` | Accessible label when the children aren't a string. |

## Accessibility

- The scroller is a log region and focusable, so it scrolls with the keyboard. Arrow keys, Page Up and Home stop the follow like the wheel does.
- It follows by easing toward a bottom that keeps moving, so streamed text glides instead of jumping a line at a time.
- Call actionsRef.current.scrollToBottom() when someone sends, so their message and the reply come into view even if they'd scrolled up.
- Jump to latest is named with the unread count, and leaves the tab order while it's hidden.
- Under prefers-reduced-motion following and jumping are instant.
- Plain DOM, no virtualisation. 300 rich messages stay smooth when finished messages are memoized, as in the examples.

## Source

`components/ui/conversation.tsx`

```tsx
"use client";

import * as React from "react";
import { ArrowDown } from "@/components/ui/icons";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;

const reducedQuery = "(prefers-reduced-motion: reduce)";

function prefersReducedMotion() {
  return typeof window !== "undefined" && !!window.matchMedia?.(reducedQuery).matches;
}

/** Items are the content's element children, minus dividers. */
function countItems(content: HTMLElement | null) {
  if (!content) return 0;
  let count = 0;
  for (const child of Array.from(content.children)) {
    if (!(child as HTMLElement).hasAttribute("data-conversation-divider")) count++;
  }
  return count;
}

export interface ConversationActions {
  /** Scrolls to the newest content and follows it again. */
  scrollToBottom: (behavior?: "smooth" | "instant") => void;
  isAtBottom: () => boolean;
}

export interface ConversationProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Shown instead of the messages when there are none. */
  empty?: React.ReactNode;
  /** A reply is arriving. Holds screen reader announcements until it's done. */
  streaming?: boolean;
  /** Follow new content while the reader is at the bottom. */
  autoScroll?: boolean;
  /** Pixels from the bottom that still count as "at the bottom". */
  threshold?: number;
  /** Where to start on mount. */
  initialScroll?: "bottom" | "top";
  /** Text on the pill that appears after scrolling up. */
  jumpLabel?: string;
  /** Classes for the inner column holding the messages. */
  contentClassName?: string;
  /** Imperative scroll controls. */
  actionsRef?: React.Ref<ConversationActions>;
  /** Called when following the bottom starts or stops. */
  onStickChange?: (stuck: boolean) => void;
  color?: NeonColor;
}

const Conversation = React.forwardRef<HTMLDivElement, ConversationProps>(
  (
    {
      empty,
      streaming = false,
      autoScroll = true,
      threshold = 56,
      initialScroll = "bottom",
      jumpLabel = "Jump to latest",
      contentClassName,
      actionsRef,
      onStickChange,
      className,
      style,
      color,
      children,
      ...props
    },
    ref,
  ) => {
    const scrollerRef = React.useRef<HTMLDivElement>(null);
    const contentRef = React.useRef<HTMLDivElement>(null);
    const stuck = React.useRef(initialScroll === "bottom");
    const lastTop = React.useRef(0);
    const baseCount = React.useRef(0);
    const frame = React.useRef<number | null>(null);
    const onStickChangeRef = React.useRef(onStickChange);
    const autoScrollRef = React.useRef(autoScroll);
    const [atBottom, setAtBottom] = React.useState(true);
    const [scrolled, setScrolled] = React.useState(false);
    const [unread, setUnread] = React.useState(0);
    const [following, setFollowing] = React.useState(initialScroll === "bottom");

    React.useEffect(() => {
      onStickChangeRef.current = onStickChange;
      autoScrollRef.current = autoScroll;
    });

    const isEmpty = React.Children.toArray(children).length === 0;

    const setStuck = React.useCallback((next: boolean) => {
      if (stuck.current === next) return;
      stuck.current = next;
      setFollowing(next);
      if (!next) baseCount.current = countItems(contentRef.current);
      else setUnread(0);
      onStickChangeRef.current?.(next);
    }, []);

    const stop = () => {
      if (frame.current !== null) cancelAnimationFrame(frame.current);
      frame.current = null;
    };

    const glide = React.useCallback((behavior: "smooth" | "instant" = "smooth") => {
      const el = scrollerRef.current;
      if (!el) return;
      if (behavior === "instant" || prefersReducedMotion() || typeof requestAnimationFrame === "undefined") {
        stop();
        el.scrollTop = el.scrollHeight;
        return;
      }
      if (frame.current !== null) return; // Already gliding; it re-reads the target every frame.
      // Eases toward a target that keeps moving while text streams in, instead of jumping per chunk.
      const step = () => {
        const target = el.scrollHeight - el.clientHeight;
        const delta = target - el.scrollTop;
        if (Math.abs(delta) < 1 || !stuck.current) {
          frame.current = null;
          return;
        }
        el.scrollTop += Math.sign(delta) * Math.max(1, Math.abs(delta) * 0.2);
        frame.current = requestAnimationFrame(step);
      };
      frame.current = requestAnimationFrame(step);
    }, []);

    const scrollToBottom = React.useCallback(
      (behavior: "smooth" | "instant" = "smooth") => {
        setStuck(true);
        glide(behavior);
      },
      [glide, setStuck],
    );

    React.useImperativeHandle(
      actionsRef,
      () => ({
        scrollToBottom,
        isAtBottom: () => stuck.current,
      }),
      [scrollToBottom],
    );

    useIsomorphicLayoutEffect(() => {
      const el = scrollerRef.current;
      if (el && initialScroll === "bottom") el.scrollTop = el.scrollHeight;
    }, []);

    React.useEffect(() => {
      const el = scrollerRef.current;
      const content = contentRef.current;
      if (!el || !content) return;

      const measure = () => {
        const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
        const top = el.scrollTop;
        // Moving up past the threshold is the reader leaving; content growing never moves scrollTop up.
        if (top < lastTop.current - 1 && distance > threshold) {
          stop();
          setStuck(false);
        }
        if (distance <= threshold) setStuck(true);
        lastTop.current = top;
        setAtBottom(distance <= threshold);
        setScrolled(top > 2);
      };

      const onWheel = (event: WheelEvent) => {
        if (event.deltaY < 0 && el.scrollHeight > el.clientHeight) {
          stop();
          setStuck(false);
        }
      };

      const onKey = (event: KeyboardEvent) => {
        if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "Home") {
          stop();
          setStuck(false);
        }
      };

      const follow = () => {
        if (stuck.current && autoScrollRef.current) glide();
        else if (!stuck.current) setUnread(Math.max(0, countItems(content) - baseCount.current));
        measure();
      };

      const resize = typeof ResizeObserver !== "undefined" ? new ResizeObserver(follow) : null;
      resize?.observe(content);
      resize?.observe(el);

      const mutations = typeof MutationObserver !== "undefined" ? new MutationObserver(follow) : null;
      mutations?.observe(content, { childList: true });

      if (!stuck.current) baseCount.current = countItems(content);
      el.addEventListener("scroll", measure, { passive: true });
      el.addEventListener("wheel", onWheel, { passive: true });
      el.addEventListener("keydown", onKey);
      measure();
      return () => {
        stop();
        resize?.disconnect();
        mutations?.disconnect();
        el.removeEventListener("scroll", measure);
        el.removeEventListener("wheel", onWheel);
        el.removeEventListener("keydown", onKey);
      };
    }, [glide, setStuck, threshold]);

    // While following, a glide toward new content can be mid-flight; that isn't "away from the bottom".
    const showJump = !atBottom && !following && !isEmpty;

    return (
      <div
        ref={ref}
        style={neonVars(color, style)}
        className={cn("[--neon:var(--neon-accent,34_211_238)]", "relative flex min-h-0 flex-col", className)}
        {...props}
      >
        <div
          ref={scrollerRef}
          role="log"
          aria-live="polite"
          aria-relevant="additions"
          aria-busy={streaming || undefined}
          tabIndex={0}
          data-scrolled={scrolled || undefined}
          className={cn(
            "min-h-0 flex-1 overflow-y-auto outline-none",
            "[scrollbar-color:rgb(255_255_255/0.12)_transparent] [scrollbar-width:thin]",
            "data-[scrolled]:[mask-image:linear-gradient(to_bottom,transparent,black_32px)]",
            "focus-visible:shadow-[inset_0_0_0_1px_rgb(var(--neon)/0.5)]",
          )}
        >
          <div
            ref={contentRef}
            className={cn(
              "mx-auto flex w-full min-w-0 flex-col gap-7 px-4 py-6",
              isEmpty && "h-full justify-center",
              contentClassName,
            )}
          >
            {isEmpty ? empty : children}
          </div>
        </div>

        <div className="pointer-events-none absolute inset-x-0 bottom-4 z-10 flex justify-center">
          <button
            type="button"
            tabIndex={showJump ? 0 : -1}
            aria-hidden={!showJump || undefined}
            aria-label={unread > 0 ? `${jumpLabel}, ${unread} new` : jumpLabel}
            onClick={() => scrollToBottom()}
            className={cn(
              "inline-flex h-8 cursor-pointer items-center gap-2 rounded-full border bg-[#10131b]/90 pl-2.5 pr-3 text-[13px] font-medium text-white/85 outline-none backdrop-blur-md",
              "shadow-[0_10px_30px_-10px_rgb(0_0_0/0.9)] transition-[opacity,transform,translate,border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
              "hover:bg-[#151924] focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]",
              unread > 0
                ? "border-[rgb(var(--neon)/0.45)] shadow-[0_0_20px_-6px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1))),0_10px_30px_-10px_rgb(0_0_0/0.9)]"
                : "border-white/[0.12]",
              showJump ? "pointer-events-auto translate-y-0 opacity-100" : "translate-y-2 opacity-0",
            )}
          >
            <ArrowDown aria-hidden className="size-3.5 text-white/60" />
            {jumpLabel}
            {unread > 0 && (
              <span
                aria-hidden
                className="-mr-1 ml-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-[rgb(var(--neon))] px-1.5 text-[11px] font-semibold tabular-nums text-[#05060a] shadow-[0_0_10px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1)))]"
              >
                {unread > 99 ? "99+" : unread}
              </span>
            )}
          </button>
        </div>
      </div>
    );
  },
);
Conversation.displayName = "Conversation";

export interface ConversationDividerProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Accessible label. Defaults to the text content when it's a string. */
  label?: string;
}

const ConversationDivider = React.forwardRef<HTMLDivElement, ConversationDividerProps>(
  ({ label, className, children, ...props }, ref) => (
    <div
      ref={ref}
      role="separator"
      aria-label={label ?? (typeof children === "string" ? children : undefined)}
      data-conversation-divider=""
      className={cn("flex items-center gap-3 text-xs text-[#8a93a6]", className)}
      {...props}
    >
      <span aria-hidden className="h-px flex-1 bg-white/[0.07]" />
      <span aria-hidden className="shrink-0 tabular-nums">
        {children}
      </span>
      <span aria-hidden className="h-px flex-1 bg-white/[0.07]" />
    </div>
  ),
);
ConversationDivider.displayName = "ConversationDivider";

export { Conversation, ConversationDivider };
```
