shadimage
neon-ui
componentagent-chatsource: ok · hit

AgentChat

Full agent chat pane: streamed markdown turns, tool chips, reasoning, and a composer with a model-controls slot.

npx shadcn add "https://ui.neon.com/r/agent-chat.json"
free

Where it goes

chat

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Registry dependencies

utilshttps://ui.neon.com/r/message.jsonhttps://ui.neon.com/r/message-scroller.jsonhttps://ui.neon.com/r/bubble.jsonhttps://ui.neon.com/r/marker.jsonhttps://ui.neon.com/r/button.jsonhttps://ui.neon.com/r/tool-call-chip.jsonhttps://ui.neon.com/r/neon-tokens.jsonhttps://ui.neon.com/r/empty-state.jsonhttps://ui.neon.com/r/neon-loader.json

Files1 · source cached 10/13/2026, 9:29:27 AM

src/components/agent-chat/agent-chat.tsx
"use client";

import {
  ArrowRight01Icon,
  Loading03Icon,
  SentIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ChatStatus, UIMessage } from "ai";
import { domAnimation, LazyMotion, m, useReducedMotion } from "motion/react";
import type {
  ComponentProps,
  FormEvent,
  KeyboardEvent,
  ReactNode,
} from "react";
import { useCallback, useRef, useState } from "react";
import { Streamdown } from "streamdown";

import { EmptyState } from "@/components/empty-state/empty-state";
import { NeonMarkShimmer } from "@/components/neon-loader/neon-loader";
import { ToolCallChip } from "@/components/tool-call-chip/tool-call-chip";
import type { ToolCallState } from "@/components/tool-call-chip/tool-call-chip";
import { Button } from "@/components/ui/button";
import { Marker, MarkerContent } from "@/components/ui/marker";
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller";
import { cn } from "@/lib/utils";

/* ─────────────────────────────────────────────────────────
 * BLOCK STORYBOARD
 *
 * A full agent chat pane.
 *
 *  entrance   each new turn rises 8px on a quick spring
 *  rhythm     a reply sits tight under its prompt; turns
 *             breathe with more air between exchanges
 *  streaming  the scroller follows while pinned, backs off
 *             when the reader scrolls up, and offers a
 *             jump-to-latest button
 *  busy       the Neon mark and status line shimmer in sync
 *             "Agent is working" until tokens arrive
 *  switches   a user turn whose metadata carries a new
 *             model/effort gets a quiet "switched to"
 *             marker above it
 *  composer   flush foot of the pane: textarea on top,
 *             model controls + send below, the top border
 *             warms to primary on focus
 * ───────────────────────────────────────────────────────── */
const TURN_SPRING = {
  damping: 32,
  stiffness: 420,
  type: "spring" as const,
};

const TURN_RISE_PX = 8;

/** Rises new turns in; static under reduced motion. */
const TurnEntrance = ({ children }: { children: ReactNode }) => {
  const reduced = useReducedMotion();

  if (reduced) {
    return <div>{children}</div>;
  }

  return (
    <m.div
      animate={{ opacity: 1, y: 0 }}
      initial={{ opacity: 0, y: TURN_RISE_PX }}
      transition={TURN_SPRING}
    >
      {children}
    </m.div>
  );
};

/** Attach to user turns via sendMessage metadata to record the controls used. */
export interface AgentChatTurnMetadata {
  /** Model id, e.g. "gpt-5-2". */
  model?: string;
  /** Display name for the marker, e.g. "GPT-5.2". Falls back to model. */
  modelName?: string;
  /** Reasoning effort used for the turn. */
  effort?: string;
}

const turnMetadata = (message: UIMessage): AgentChatTurnMetadata | null => {
  const { metadata } = message;

  if (metadata && typeof metadata === "object" && "model" in metadata) {
    return metadata as AgentChatTurnMetadata;
  }

  return null;
};

/** "switched to GPT-5.2 · high thinking" when a turn changes controls. */
const switchLabel = (
  current: AgentChatTurnMetadata,
  previous: AgentChatTurnMetadata | null
): string | null => {
  const modelChanged =
    previous !== null && current.model !== undefined
      ? current.model !== previous.model
      : false;
  const effortChanged =
    previous !== null && current.effort !== undefined
      ? current.effort !== previous.effort
      : false;

  if (!(modelChanged || effortChanged)) {
    return null;
  }

  const name = current.modelName ?? current.model ?? "";
  const effort =
    current.effort && current.effort !== "off"
      ? ` · ${current.effort} thinking`
      : "";

  return `switched to ${name}${effort}`;
};

const chipState = (state: string): ToolCallState => {
  if (state === "output-error") {
    return "error";
  }

  return state === "output-available" ? "done" : "running";
};

type Part = UIMessage["parts"][number];

/**
 * A finished turn has no running tools. History-hydrated parts don't carry
 * the live stream's state strings, so anything still "running" in a settled
 * message is actually done.
 */
const settleTools = (tools: ToolEntry[]): ToolEntry[] =>
  tools.map((tool) =>
    tool.state === "running" ? { ...tool, state: "done" } : tool
  );

export interface ToolEntry {
  detail?: string;
  name: string;
  state: ToolCallState;
}

type Segment =
  | { kind: "text"; text: string }
  | { kind: "reasoning"; text: string }
  | { kind: "tools"; tools: ToolEntry[] };

/** First string value of a tool's input, e.g. the path or command. */
const toolDetail = (part: Part): string | undefined => {
  if (!("input" in part) || typeof part.input !== "object" || !part.input) {
    return undefined;
  }

  return Object.values(part.input).find(
    (value): value is string => typeof value === "string"
  );
};

/** Fold message parts into render segments, grouping tool runs. */
const segmentParts = (parts: Part[]): Segment[] => {
  const segments: Segment[] = [];

  for (const part of parts) {
    if (part.type === "text") {
      segments.push({ kind: "text", text: part.text });
      continue;
    }

    if (part.type === "reasoning") {
      segments.push({ kind: "reasoning", text: part.text });
      continue;
    }

    let tool: ToolEntry | null = null;

    if (part.type === "dynamic-tool") {
      tool = {
        detail: toolDetail(part),
        name: part.toolName,
        state: chipState(part.state),
      };
    } else if (part.type.startsWith("tool-") && "state" in part) {
      tool = {
        detail: toolDetail(part),
        name: part.type.slice("tool-".length),
        state: chipState(String(part.state)),
      };
    }

    if (tool) {
      const last = segments.at(-1);

      if (last?.kind === "tools") {
        last.tools.push(tool);
      } else {
        segments.push({ kind: "tools", tools: [tool] });
      }
    }
  }

  return segments;
};

/* ─────────────────────────────────────────────────────
 * A run of tool calls folded into one working block: while any
 * call is live the header shimmers ("Working…") and the log is
 * open; when the run lands it collapses to a one-line receipt
 * ("n steps") the reader can reopen. The chips inside stay the
 * house log-line vocabulary.
 * ─────────────────────────────────────────────────── */
export const ToolGroup = ({
  tools,
  live = false,
}: {
  tools: ToolEntry[];
  /** The turn is still streaming — hold open across gaps between calls. */
  live?: boolean;
}) => {
  const running = tools.some((tool) => tool.state === "running");
  // "Working" for the entire live turn: between tool calls every chip is
  // momentarily settled, and folding in those gaps reads as a glitch.
  const working = live || running;
  const failed = tools.filter((tool) => tool.state === "error").length;
  // User toggle wins; otherwise open while working, closed at rest.
  const [userOpen, setUserOpen] = useState<boolean | null>(null);
  const open = userOpen ?? working;

  const summary = working
    ? "Working…"
    : `${tools.length} step${tools.length === 1 ? "" : "s"}${failed > 0 ? ` · ${failed} failed` : ""}`;

  return (
    <div
      className="border-border/60 border-l-2 pl-2.5"
      data-slot="tool-group"
      data-state={working ? "working" : "done"}
    >
      <button
        aria-expanded={open}
        className="group inline-flex items-center gap-1 py-0.5 text-muted-foreground text-xs transition-colors hover:text-foreground"
        onClick={() => setUserOpen(!open)}
        type="button"
      >
        <HugeiconsIcon
          aria-hidden
          icon={ArrowRight01Icon}
          strokeWidth={2}
          className={cn(
            "size-3 transition-transform duration-200 motion-reduce:transition-none",
            open && "rotate-90"
          )}
        />
        {working ? (
          <span className="shimmer shimmer-duration-2400">{summary}</span>
        ) : (
          <span>{summary}</span>
        )}
      </button>
      {open && (
        <div className="mt-1 flex flex-col items-start gap-1 pb-0.5">
          {tools.map((tool, toolIndex) => (
            <ToolCallChip
              detail={tool.detail}
              key={`${tool.name}-${toolIndex.toString()}`}
              name={tool.name}
              state={tool.state}
            />
          ))}
        </div>
      )}
    </div>
  );
};

/** Explicit markdown styling — no typography plugin required. */
const MARKDOWN_CLASS = cn(
  "text-sm leading-relaxed",
  "[&_p]:my-2 first:[&_p]:mt-0 last:[&_p]:mb-0",
  "[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:font-semibold [&_h1]:text-base",
  "[&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:font-semibold [&_h2]:text-sm",
  "[&_h3]:mt-3 [&_h3]:mb-1.5 [&_h3]:font-medium [&_h3]:text-sm",
  "[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5",
  "[&_code]:bg-muted/60 [&_code]:px-1 [&_code]:py-px [&_code]:font-mono [&_code]:text-[0.85em]",
  "[&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:border [&_pre]:border-border/60 [&_pre]:bg-muted/30 [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0",
  "[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
  "[&_blockquote]:my-2 [&_blockquote]:border-border/60 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
  "[&_strong]:font-semibold"
);

export type ChatMessageProps = Omit<ComponentProps<"div">, "children"> & {
  message: UIMessage;
  /**
   * The turn is still streaming: tool groups hold open for the whole run
   * instead of folding in the gaps between calls.
   */
  isLive?: boolean;
};

/** One conversation turn: user bubble or agent markdown with tool chips. */
export const ChatMessage = ({
  className,
  message,
  isLive = false,
  ...props
}: ChatMessageProps) => {
  if (message.role === "user") {
    const text = message.parts
      .map((part) => (part.type === "text" ? part.text : ""))
      .join("");

    return (
      <div
        className={cn("flex justify-end", className)}
        data-role="user"
        {...props}
      >
        <div className="max-w-[85%] whitespace-pre-wrap bg-primary px-3 py-2 text-primary-foreground text-sm">
          {text}
        </div>
      </div>
    );
  }

  return (
    <div
      className={cn("space-y-2.5", className)}
      data-role="assistant"
      {...props}
    >
      {segmentParts(message.parts).map((segment, index) => {
        const key = `${message.id}-${index.toString()}`;

        if (segment.kind === "reasoning") {
          return (
            <p
              className="border-border/60 border-l-2 pl-2.5 text-muted-foreground/80 text-xs leading-relaxed"
              key={key}
            >
              {segment.text}
            </p>
          );
        }

        if (segment.kind === "tools") {
          const tools = isLive ? segment.tools : settleTools(segment.tools);
          // One call is a log line, not a folder.
          if (tools.length === 1 && tools[0]) {
            return (
              <div className="border-border/60 border-l-2 pl-2.5" key={key}>
                <ToolCallChip
                  detail={tools[0].detail}
                  name={tools[0].name}
                  state={tools[0].state}
                />
              </div>
            );
          }
          return <ToolGroup key={key} live={isLive} tools={tools} />;
        }

        return (
          <div className={MARKDOWN_CLASS} key={key}>
            <Streamdown>{segment.text}</Streamdown>
          </div>
        );
      })}
    </div>
  );
};

const autoGrow = (event: FormEvent<HTMLTextAreaElement>) => {
  const textarea = event.currentTarget;
  textarea.style.height = "auto";
  textarea.style.height = `${Math.min(textarea.scrollHeight, 160)}px`;
};

export interface ChatInputProps {
  onSend: (text: string) => void;
  disabled?: boolean;
  busy?: boolean;
  placeholder?: string;
  /** Slot at the left of the composer's control row, e.g. ThinkingModelSelect. */
  controls?: ReactNode;
  className?: string;
}

/** Composer: auto-growing textarea, controls slot, and a send action. */
export const ChatInput = ({
  busy = false,
  className,
  controls,
  disabled = false,
  onSend,
  placeholder = "Describe a change…",
}: ChatInputProps) => {
  const [text, setText] = useState("");
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const submit = useCallback(() => {
    const trimmed = text.trim();

    if (!trimmed || busy || disabled) {
      return;
    }

    onSend(trimmed);
    setText("");

    const textarea = textareaRef.current;

    if (textarea) {
      textarea.style.height = "auto";
      textarea.focus();
    }
  }, [busy, disabled, onSend, text]);

  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      submit();
    }
  };

  return (
    <div
      className={cn(
        "border-border/60 border-t bg-card transition-colors focus-within:border-primary/50",
        disabled && "opacity-60",
        className
      )}
      data-slot="chat-input"
    >
      <textarea
        aria-label="Message the agent"
        className="block max-h-40 w-full resize-none bg-transparent px-3.5 pt-3 pb-1.5 text-base outline-none placeholder:text-muted-foreground sm:text-sm"
        disabled={disabled}
        onChange={(event) => setText(event.target.value)}
        onInput={autoGrow}
        onKeyDown={handleKeyDown}
        placeholder={placeholder}
        ref={textareaRef}
        rows={1}
        value={text}
      />
      <div className="flex items-end justify-between gap-2 px-2.5 pb-2.5">
        <div className="flex min-w-0 items-center gap-2">{controls}</div>
        <Button
          aria-label={busy ? "Waiting for the agent" : "Send message"}
          className="shrink-0"
          disabled={disabled || busy || text.trim().length === 0}
          onClick={submit}
          size="icon-sm"
        >
          {busy ? (
            <HugeiconsIcon
              className="animate-spin"
              icon={Loading03Icon}
              strokeWidth={2}
            />
          ) : (
            <HugeiconsIcon icon={SentIcon} strokeWidth={2} />
          )}
        </Button>
      </div>
    </div>
  );
};

const DefaultEmptyState = () => (
  <EmptyState
    className="h-full py-10"
    description="Describe a feature and the agent edits the live app, database included."
    title="Start building"
  />
);

export type ChatWorkingIndicatorProps = ComponentProps<"div"> & {
  /** Status line beside the resolving Neon mark. */
  label?: string;
};

/** The busy row: the Neon mark and a status line sharing one shimmer sweep. */
export const ChatWorkingIndicator = ({
  className,
  label = "Agent is working\u2026",
  ...props
}: ChatWorkingIndicatorProps) => (
  <div
    aria-live="polite"
    className={cn(
      "flex items-center gap-2 text-muted-foreground text-xs",
      className
    )}
    data-slot="chat-working-indicator"
    {...props}
  >
    <NeonMarkShimmer className="text-primary" size={14} />
    <span className="shimmer">{label}</span>
  </div>
);

export type AgentChatProps = Omit<ComponentProps<"div">, "children"> & {
  messages: UIMessage[];
  /** useChat status: "submitted" | "streaming" | "ready" | "error". */
  status: ChatStatus;
  onSend: (text: string) => void;
  /** Disable the composer, e.g. while an environment provisions. */
  disabled?: boolean;
  placeholder?: string;
  /** Rendered while the conversation is empty. */
  emptyState?: ReactNode;
  /** Slot at the left of the composer's control row. */
  controls?: ReactNode;
  /**
   * The controls currently selected in the composer. When they differ from
   * the last sent turn's metadata, a "switched to" marker previews at the
   * end of the timeline immediately.
   */
  activeControls?: AgentChatTurnMetadata;
  /** Shown in the error state; called to resend the last message. */
  onRetry?: () => void;
};

export const AgentChat = ({
  activeControls,
  className,
  controls,
  disabled = false,
  emptyState,
  messages,
  onRetry,
  onSend,
  placeholder,
  status,
  ...props
}: AgentChatProps) => {
  const busy = status === "submitted" || status === "streaming";
  const lastUserIndex = messages.findLastIndex(
    (message) => message.role === "user"
  );
  const lastUserMetadata =
    lastUserIndex !== -1 && messages[lastUserIndex]
      ? turnMetadata(messages[lastUserIndex])
      : null;
  const pendingSwitch =
    activeControls && lastUserMetadata
      ? switchLabel(activeControls, lastUserMetadata)
      : null;

  return (
    <LazyMotion features={domAnimation} strict>
      <div
        className={cn("flex min-h-0 flex-col", className)}
        data-slot="agent-chat"
        {...props}
      >
        <MessageScrollerProvider autoScroll defaultScrollPosition="end">
          <MessageScroller className="relative min-h-0 flex-1">
            <MessageScrollerViewport className="neon-scroll-fade h-full">
              <MessageScrollerContent className="p-4">
                {messages.length === 0 && (emptyState ?? <DefaultEmptyState />)}
                {messages.map((message, index) => {
                  let marker: string | null = null;

                  if (message.role === "user") {
                    const metadata = turnMetadata(message);
                    const previous = messages
                      .slice(0, index)
                      .findLast((entry) => entry.role === "user");

                    if (metadata) {
                      marker = switchLabel(
                        metadata,
                        previous ? turnMetadata(previous) : null
                      );
                    }
                  }

                  return (
                    <MessageScrollerItem
                      className={cn(
                        message.role === "user"
                          ? "mt-6 first:mt-0"
                          : "mt-3 first:mt-0"
                      )}
                      key={message.id}
                      scrollAnchor={index === lastUserIndex}
                    >
                      <TurnEntrance>
                        {marker ? (
                          <Marker className="mb-3" variant="separator">
                            <MarkerContent className="font-mono text-[10px]">
                              {marker}
                            </MarkerContent>
                          </Marker>
                        ) : null}
                        <ChatMessage
                          isLive={busy && index === messages.length - 1}
                          message={message}
                        />
                      </TurnEntrance>
                    </MessageScrollerItem>
                  );
                })}
                {status === "submitted" && (
                  <ChatWorkingIndicator className="mt-3" />
                )}
                {pendingSwitch && !busy ? (
                  <TurnEntrance>
                    <Marker
                      aria-live="polite"
                      className="mt-3"
                      variant="separator"
                    >
                      <MarkerContent className="font-mono text-[10px]">
                        {pendingSwitch}
                      </MarkerContent>
                    </Marker>
                  </TurnEntrance>
                ) : null}
                {status === "error" && (
                  <div
                    className="mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/[0.045] px-3 py-2"
                    role="alert"
                  >
                    <p className="text-destructive text-xs">
                      The agent hit an error. Your message wasn’t lost.
                    </p>
                    {onRetry ? (
                      <button
                        className="shrink-0 rounded-sm border border-border/60 px-2 py-1 text-foreground text-xs transition-colors hover:border-border"
                        onClick={onRetry}
                        type="button"
                      >
                        Retry
                      </button>
                    ) : null}
                  </div>
                )}
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton className="-translate-x-1/2 absolute bottom-3 left-1/2" />
          </MessageScroller>
        </MessageScrollerProvider>

        <ChatInput
          busy={busy}
          className="shrink-0"
          controls={controls}
          disabled={disabled}
          onSend={onSend}
          placeholder={placeholder}
        />
      </div>
    </LazyMotion>
  );
};

More from neon-ui

Browse all components in this library →