For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Composer 01

A full chat composer block with add and model menus, a voice toggle, branch and folder controls, and a context meter.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/composer-01

The menus, models, and folders are sample content, and sending simulates three seconds of work. Replace them with your own data in components/chat-composer.tsx.

Source

"use client";

import {
  RiBookOpenLine,
  RiArrowDownSLine,
  RiFolderLine,
  RiGitBranchLine,
  RiGlobeLine,
  RiInfinityLine,
  RiLink,
  RiAttachment2,
  RiAddLine,
  RiTerminalLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { ContextMeter } from "@/components/blocks/composer-01/components/context-meter";
import { VoiceButton } from "@/components/blocks/composer-01/components/voice-button";
import { Button } from "@/components/ui/button";
import {
  Composer,
  ComposerFooter,
  ComposerInput,
  ComposerSend,
  ComposerShell,
  ComposerThinking,
  ComposerToolbar,
} from "@/components/ui/composer";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { InputGroupButton } from "@/components/ui/input-group";

import { ComposerModelPicker } from "./composer-model-picker";

const addItems = [
  {
    description: "Images, PDFs, or text",
    icon: RiAttachment2,
    title: "Upload a file",
  },
  { description: "Read a web page", icon: RiLink, title: "Paste a link" },
];

const toolItems = [
  { description: "Look up sources", icon: RiGlobeLine, title: "Web lookup" },
  {
    description: "Try it in a sandbox",
    icon: RiTerminalLine,
    title: "Run code",
  },
  { description: "Search your notes", icon: RiBookOpenLine, title: "Library" },
];

const models = ["Swift", "Steady", "Thorough", "Maximal"];
const effortLevels = ["Low", "Medium", "High", "Higher", "Highest", "Max"];

const folders = [
  { name: "harbor", path: "~/work/" },
  { name: "lighthouse", path: "~/work/" },
  { name: "garden", path: "~/notes/" },
];

const chevronClass =
  "size-3 transition-transform duration-150 ease-out motion-reduce:transition-none group-aria-expanded/button:rotate-180";

function MenuRows({ items }: { items: typeof addItems }) {
  return items.map(({ description, icon: Icon, title }) => (
    <DropdownMenuItem key={title}>
      <Icon />
      <span className="font-medium">{title}</span>
      <span className="truncate text-muted-foreground">{description}</span>
    </DropdownMenuItem>
  ));
}

export function ChatComposer({
  className,
  popupClassName,
  value,
  onValueChange,
  onSend,
}: {
  className?: string;
  popupClassName?: string;
  /** Controls the prompt text. Leave unset to let the composer keep it. */
  value?: string;
  onValueChange?: (value: string) => void;
  /** Called with the trimmed message when the user sends. */
  onSend?: (message: string) => void;
}) {
  const [folder, setFolder] = React.useState(folders[0].name);
  const [lastSent, setLastSent] = React.useState<string | null>(null);
  const [listening, setListening] = React.useState(false);
  const [model, setModel] = React.useState(models[1]);
  const [effort, setEffort] = React.useState(1);
  const [working, setWorking] = React.useState(false);
  const workTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);

  React.useEffect(
    () => () => {
      if (workTimer.current) {
        clearTimeout(workTimer.current);
      }
    },
    []
  );

  return (
    <div className={cn("flex w-full flex-col gap-3", className)}>
      <Composer
        value={value}
        onValueChange={onValueChange}
        onSend={(message) => {
          onSend?.(message);
          setLastSent(message);
          setWorking(true);
          workTimer.current = setTimeout(() => setWorking(false), 3000);
        }}
        working={working}
      >
        <ComposerThinking />
        <ComposerShell>
          <ComposerToolbar align="inline-start">
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <InputGroupButton
                    aria-label="Add"
                    tooltip="Add"
                    variant="tonal"
                    shape="pill"
                    size="icon"
                  >
                    <RiAddLine className="size-5" />
                  </InputGroupButton>
                }
              />
              <DropdownMenuContent
                size="sm"
                align="start"
                className={cn("w-80", popupClassName)}
                side="top"
                sideOffset={8}
              >
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Add</DropdownMenuLabel>
                  <MenuRows items={addItems} />
                </DropdownMenuGroup>
                <DropdownMenuSeparator />
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Tools</DropdownMenuLabel>
                  <MenuRows items={toolItems} />
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          </ComposerToolbar>
          <ComposerInput
            aria-label="Message"
            placeholder="Write a message..."
          />
          <ComposerToolbar>
            <ComposerModelPicker
              model={model}
              models={models}
              onModelChange={setModel}
              popupClassName={popupClassName}
              effort={{
                levels: effortLevels,
                onValueChange: setEffort,
                value: effort,
              }}
            />
            <VoiceButton
              listening={listening}
              onClick={() => setListening((current) => !current)}
            />
            <ComposerSend />
          </ComposerToolbar>
        </ComposerShell>
        <ComposerFooter>
          <div className="flex min-w-0 items-center gap-1">
            <Button size="xs" variant="text">
              <RiGitBranchLine />
              main
            </Button>
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button size="xs" variant="text">
                    <RiFolderLine />
                    {folder}
                    <RiArrowDownSLine className={chevronClass} />
                  </Button>
                }
              />
              <DropdownMenuContent
                size="sm"
                align="start"
                className={cn("w-64", popupClassName)}
                side="top"
                sideOffset={8}
              >
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Folders</DropdownMenuLabel>
                  {folders.map(({ name, path }) => (
                    <DropdownMenuItem
                      key={name}
                      onClick={() => setFolder(name)}
                    >
                      <RiFolderLine />
                      <span>
                        <span className="text-muted-foreground">{path}</span>
                        <span className="font-medium">{name}</span>
                      </span>
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
          <div className="flex min-w-0 items-center gap-1">
            <Button size="xs" variant="text">
              <RiInfinityLine />
              Auto
              <RiArrowDownSLine className={chevronClass} />
            </Button>
            <ContextMeter value={42} />
          </div>
        </ComposerFooter>
      </Composer>
      <p
        aria-live="polite"
        className={cn(
          "px-1 text-sm text-muted-foreground",
          !lastSent && "sr-only"
        )}
      >
        {lastSent ? `Sent: ${lastSent}` : null}
      </p>
    </div>
  );
}