- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Usage
The composer accepts a controlled value and onValueChange, or manages its own draft. onSubmit receives the message and selected files. Enter sends the message; Shift+Enter inserts a line break. Supply status to add a status tab, models to provide a model list, and popupClassName to scope the theme of the popups. The optional onMicClick callback enables the voice control.
PermissionMenu, ModelPicker, and ComposerStatusTab are also exported for independent use. Permission and model selection are local to the composer.
The context ring draws in and its percentage counts up on mount; the attached-file chips rise into place one after another. Every animation stops at its final, readable state when the visitor prefers reduced motion. Pass popupClassName to ComposerStatusTab as well when it needs to scope the theme of its context hint.
Source
"use client";
import {
RiAddLine,
RiArrowDownSLine,
RiArrowUpLine,
RiCheckLine,
RiCloseLine,
RiDashboard3Line,
RiFolderLine,
RiGitBranchLine,
RiMicLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentGroup,
AttachmentTitle,
} from "@/components/ui/attachment";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ProgressCircle } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export type ComposerPermission = "automatic" | "ask" | "plan" | "unrestricted";
const permissions: {
value: ComposerPermission;
label: string;
description: string;
}[] = [
{ value: "automatic", label: "Automatic", description: "Choose when to ask" },
{ value: "ask", label: "Ask first", description: "Review each action" },
{
value: "plan",
label: "Plan",
description: "Discuss before making changes",
},
{
value: "unrestricted",
label: "Unrestricted",
description: "Allow actions without asking",
},
];
export interface ComposerModel {
id: string;
name: string;
provider: string;
}
const defaultModels: ComposerModel[] = [
{ id: "atlas-small", name: "Atlas Small", provider: "Northstar" },
{ id: "atlas-large", name: "Atlas Large", provider: "Northstar" },
{ id: "lumen-fast", name: "Lumen Fast", provider: "Aperture" },
{ id: "lumen-pro", name: "Lumen Pro", provider: "Aperture" },
];
// null until the media query resolves, so the first paint never animates for
// someone who asked for reduced motion.
// oxlint-disable-next-line cobalt/no-optimistic-reduced-motion -- starts null, not true: the count-up waits for the real preference and the shared hook cannot say "unknown"
function useReducedMotion() {
const [reduced, setReduced] = React.useState<boolean | null>(null);
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
return reduced;
}
// The count-up from blocks/orders-chart-card-01: 320ms, cubic ease-out, and
// the target as the readable resting state under reduced motion.
function useCountUp(target: number, reduced: boolean | null) {
const [display, setDisplay] = React.useState(0);
const fromRef = React.useRef(0);
React.useEffect(() => {
if (reduced === null) {
return;
}
const from = fromRef.current;
if (from === target) {
return;
}
if (reduced) {
fromRef.current = target;
setDisplay(target);
return;
}
const start = performance.now();
let frame = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / 320);
const value = Math.round(from + (target - from) * (1 - (1 - t) ** 3));
fromRef.current = value;
setDisplay(value);
if (t < 1) {
frame = requestAnimationFrame(tick);
} else {
fromRef.current = target;
}
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [target, reduced]);
return reduced === false ? display : target;
}
function WithHint({
hint,
popupClassName,
children,
}: {
hint: React.ReactNode;
popupClassName?: string;
children: React.ReactElement;
}) {
return (
<Tooltip>
<TooltipTrigger render={children} />
<TooltipContent size="sm" className={popupClassName}>
{hint}
</TooltipContent>
</Tooltip>
);
}
export function ComposerStatusTab({
branch,
project,
context,
popupClassName,
}: {
branch: string;
project: string;
context: number;
popupClassName?: string;
}) {
const percentage = Math.max(0, Math.min(100, context));
const reduced = useReducedMotion();
const display = useCountUp(percentage, reduced);
// Start empty so the primitive's own stroke transition draws the arc in.
const [ringValue, setRingValue] = React.useState(0);
React.useEffect(() => {
if (reduced === null) {
return;
}
setRingValue(percentage);
}, [percentage, reduced]);
return (
<TooltipProvider delay={200}>
<div className="@container/status mx-7 flex h-[34px] items-center gap-3 rounded-t-xl bg-muted px-3 text-xs text-muted-foreground">
<span className="hidden min-w-0 items-center gap-1.5 @min-[200px]/status:flex">
<RiGitBranchLine aria-hidden="true" className="size-3.5 shrink-0" />
<span className="truncate">{branch}</span>
</span>
<span className="hidden min-w-0 items-center gap-1.5 @min-[280px]/status:flex">
<RiFolderLine aria-hidden="true" className="size-3.5 shrink-0" />
<span className="truncate">{project}</span>
</span>
<span className="ml-auto flex shrink-0 items-center gap-1.5">
<WithHint
hint={`${percentage}% of the context window used`}
popupClassName={popupClassName}
>
<span className="flex items-center gap-1.5">
<ProgressCircle
size="sm"
value={ringValue}
aria-label={`Context window ${percentage}% used`}
/>
<span className="tabular-nums">{display}%</span>
</span>
</WithHint>
</span>
</div>
</TooltipProvider>
);
}
export function PermissionMenu({
value,
onChange,
popupClassName,
}: {
value: ComposerPermission;
onChange: (value: ComposerPermission) => void;
popupClassName?: string;
}) {
const selected =
permissions.find((item) => item.value === value) ?? permissions[0];
return (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="text" size="xs" shape="pill" />}
>
<RiDashboard3Line data-icon="inline-start" />
{selected.label}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("w-64", popupClassName)}>
<DropdownMenuRadioGroup
value={value}
onValueChange={(next) => onChange(next as ComposerPermission)}
>
<DropdownMenuLabel>Permissions</DropdownMenuLabel>
{permissions.map((item) => (
<DropdownMenuRadioItem key={item.value} value={item.value}>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm">{item.label}</span>
<span className="text-xs font-normal text-muted-foreground">
{item.description}
</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function ModelPicker({
value,
onChange,
models = defaultModels,
popupClassName,
}: {
value: string;
onChange: (value: string) => void;
models?: ComposerModel[];
popupClassName?: string;
}) {
const [open, setOpen] = React.useState(false);
const searchRef = React.useRef<HTMLInputElement>(null);
const selected = models.find((model) => model.id === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant="text"
size="xs"
shape="pill"
className="max-w-40"
/>
}
>
<span className="truncate">{selected?.name ?? "Choose model"}</span>
<RiArrowDownSLine
data-icon="inline-end"
className="transition-transform duration-150 ease-out group-aria-expanded/button:rotate-180 motion-reduce:transition-none"
/>
</PopoverTrigger>
<PopoverContent
align="end"
size="flush"
radius={24}
variant="ghost"
className={cn("w-[344px] overflow-hidden", popupClassName)}
initialFocus={searchRef}
>
<Command label="Models" className="max-h-none">
<div className="px-3 pt-3 pb-1 text-sm font-medium">Models</div>
<CommandInput
ref={searchRef}
aria-label="Search models"
placeholder="Search models"
/>
<CommandList className="max-h-56">
<CommandEmpty>No models found.</CommandEmpty>
<CommandGroup>
{models.map((model) => (
<CommandItem
key={model.id}
value={`${model.name} ${model.provider}`}
onSelect={() => {
onChange(model.id);
setOpen(false);
}}
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{model.name}</span>
<span className="truncate text-xs font-normal text-muted-foreground">
{model.provider}
</span>
</span>
{model.id === value && (
<RiCheckLine aria-hidden="true" className="size-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export interface ComposerPanelProps {
status?: React.ReactNode;
value?: string;
onValueChange?: (value: string) => void;
onSubmit?: (value: string, files: File[]) => void;
onMicClick?: () => void;
disabled?: boolean;
placeholder?: string;
popupClassName?: string;
models?: ComposerModel[];
className?: string;
}
export function ComposerPanel({
status,
value,
onValueChange,
onSubmit,
onMicClick,
disabled,
placeholder = "Write a message...",
popupClassName,
models = defaultModels,
className,
}: ComposerPanelProps) {
const [draft, setDraft] = React.useState("");
const [permission, setPermission] =
React.useState<ComposerPermission>("automatic");
const [model, setModel] = React.useState(models[0]?.id ?? "");
const [files, setFiles] = React.useState<File[]>([]);
const inputRef = React.useRef<HTMLInputElement>(null);
const textRef = React.useRef<HTMLTextAreaElement>(null);
const text = value ?? draft;
const update = (next: string) => {
if (value === undefined) {
setDraft(next);
}
onValueChange?.(next);
};
// Textarea re-fits its height on input only, so ask it to measure again when
// the value arrives from props or is cleared after sending.
React.useEffect(() => {
textRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
}, [text]);
const submit = () => {
if (disabled || !text.trim()) {
return;
}
onSubmit?.(text, files);
update("");
setFiles([]);
};
return (
<TooltipProvider delay={200}>
<div className={cn("w-full", className)}>
{status}
<Card radius={24} size="sm">
<div className="flex flex-col px-3">
{files.length > 0 && (
<AttachmentGroup className="mb-2 flex-wrap overflow-visible">
{files.map((file, index) => (
<div
key={`${file.name}-${index}`}
className="composer-panel-01-chip-in min-w-0 max-w-48"
style={
{
"--delay": `${Math.min(index, 8) * 40}ms`,
} as React.CSSProperties
}
>
<Attachment size="xs">
<AttachmentContent>
<AttachmentTitle>{file.name}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
type="button"
size="icon-2xs"
tooltip={`Remove ${file.name}`}
onClick={() =>
setFiles(files.filter((_, i) => i !== index))
}
>
<RiCloseLine className="size-3" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
))}
</AttachmentGroup>
)}
<Textarea
ref={textRef}
aria-label="Message"
rows={1}
autoResize
maxRows={10}
resize="none"
size="sm"
variant="ghost"
value={text}
disabled={disabled}
placeholder={placeholder}
onChange={(event) => update(event.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
submit();
}
}}
className="min-h-8"
/>
<div className="flex flex-wrap items-center gap-1">
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
setFiles((current) => [
...current,
...(event.target.files ?? []),
]);
event.target.value = "";
}}
/>
<Button
type="button"
variant="secondary"
size="icon-sm"
shape="pill"
tooltip={{ children: "Add files", className: popupClassName }}
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
<RiAddLine />
</Button>
<PermissionMenu
value={permission}
onChange={setPermission}
popupClassName={popupClassName}
/>
<div className="ml-auto flex min-w-0 items-center gap-1">
<ModelPicker
value={model}
onChange={setModel}
models={models}
popupClassName={popupClassName}
/>
{onMicClick && (
<Button
type="button"
variant="outline"
size="icon-sm"
shape="pill"
tooltip={{
children: "Voice input",
className: popupClassName,
}}
disabled={disabled}
onClick={onMicClick}
>
<RiMicLine />
</Button>
)}
<Button
type="button"
size="icon-sm"
shape="pill"
tooltip={{
children: "Send message",
className: popupClassName,
}}
disabled={disabled || !text.trim()}
onClick={submit}
>
<RiArrowUpLine />
</Button>
</div>
</div>
</div>
</Card>
</div>
</TooltipProvider>
);
}