# Agent Composer
A message composer for AI agents with slash commands, file mentions, attachments, model picker and stop control.
## 1. Setup
// npm install framer-motion
File: components-workspace/agent-composer/index.tsx · 'use client' · export default function AgentComposer()
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import type { ComponentType } from 'react'
import type * as React from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { BookOpenDuotone } from '@/app/_icons/icons/book-open-duotone'
import { Check } from '@/app/_icons/icons/check'
import { ChevronDown } from '@/app/_icons/icons/chevron-down'
import { CircleXDuotone } from '@/app/_icons/icons/circle-x-duotone'
import { Command } from '@/app/_icons/icons/command'
import { FileDuotone } from '@/app/_icons/icons/file-duotone'
import { FileImageDuotone } from '@/app/_icons/icons/file-image-duotone'
import { FlaskDuotone } from '@/app/_icons/icons/flask-duotone'
import { ListChecksDuotone } from '@/app/_icons/icons/list-checks-duotone'
import { Paperclip } from '@/app/_icons/icons/paperclip'
import { PaperclipDuotone } from '@/app/_icons/icons/paperclip-duotone'
import { RefreshDuotone } from '@/app/_icons/icons/refresh-duotone'
import { Send } from '@/app/_icons/icons/send'
import { WrenchDuotone } from '@/app/_icons/icons/wrench-duotone'
Styling is Tailwind CSS v4 (the bg-(--var) and text-(color:--var) syntax, pointer-coarse: and class-based dark:). The @/app/_icons imports are the AI Canvas icon set; without it, use any icons that accept the same size, weight and className props.
## 2. Constants
export type Tuning = {
listOpenMs: number
listCloseMs: number
listRiseY: number
glyphFadeMs: number
stopPulseS: number
chipEnterMs: number
chipExitMs: number
stateMs: number
lightMs: number
errorInMs: number
errorOutMs: number
panelMaxW: number
lineHeight: number
minTextareaH: number
maxTextareaH: number
chipMaxW: number
modelListW: number
modelCostMinW: number
listRowH: number
listMaxH: number
listMinW: number
listOffset: number
dotPitch: number
dotFieldH: number
counterThreshold: number
counterWarnThreshold: number
truncateChars: number
tokensPerKb: number
demoBusyMs: number
}
export const TUNING: Tuning = {
listOpenMs: 200,
listCloseMs: 150,
listRiseY: 6,
glyphFadeMs: 150,
stopPulseS: 1.6,
chipEnterMs: 200,
chipExitMs: 150,
stateMs: 200,
lightMs: 600,
errorInMs: 200,
errorOutMs: 150,
panelMaxW: 480,
lineHeight: 20,
minTextareaH: 20,
maxTextareaH: 120,
chipMaxW: 220,
modelListW: 360,
modelCostMinW: 360,
listRowH: 36,
listMaxH: 216,
listMinW: 240,
listOffset: 8,
dotPitch: 6,
dotFieldH: 64,
counterThreshold: 200,
counterWarnThreshold: 1800,
truncateChars: 18,
tokensPerKb: 100,
demoBusyMs: 3000,
}
const THEME_VARS = [
'[--cm-page:#EEEEF3] dark:[--cm-page:#0E0E0F]',
'[--cm-panel:#F4F4FA] dark:[--cm-panel:#1B1B1C]',
'[--cm-well:#EEEEF3] dark:[--cm-well:#0E0E0F]',
'[--cm-raised:#DFDFE3] dark:[--cm-raised:#2D2D2E]',
'[--cm-raised-hover:#CACACD] dark:[--cm-raised-hover:#373738]',
'[--cm-border:#DFDFE3] dark:[--cm-border:#2D2D2E]',
'[--cm-border-strong:#CACACD] dark:[--cm-border-strong:#373738]',
'[--cm-hairline:#DFDFE3] dark:[--cm-hairline:#2D2D2E]',
'[--cm-rim:#CACACD] dark:[--cm-rim:#F4F4FA2E]',
'[--cm-rim-lit:#9B9B9E] dark:[--cm-rim-lit:#F4F4FA4D]',
'[--cm-text:#1B1B1C] dark:[--cm-text:#F4F4FA]',
'[--cm-body:#373738] dark:[--cm-body:#CACACD]',
'[--cm-muted:#575759] dark:[--cm-muted:#9B9B9E]',
'[--cm-faint:#7B7B7D]',
'[--cm-selection:#A0A0A233] dark:[--cm-selection:#5E5E6333]',
'[--cm-success:#047857] dark:[--cm-success:#34D399]',
'[--cm-ai:#0369A1] dark:[--cm-ai:#7DD3FC]',
'[--cm-attention:#B45309] dark:[--cm-attention:#FFB574]',
'[--cm-red:#C8262C] dark:[--cm-red:#E5484D]',
'[--cm-primary:#1B1B1C] dark:[--cm-primary:#F4F4FA]',
'[--cm-primary-hover:#373738] dark:[--cm-primary-hover:#FFFFFF]',
'[--cm-on-primary:#F4F4FA] dark:[--cm-on-primary:#0E0E0F]',
'[--cm-focus:#1B1B1C99] dark:[--cm-focus:#F4F4FA99]',
'[--cm-focus-gap:#F4F4FA] dark:[--cm-focus-gap:#1B1B1C]',
'[--cm-dot:#1B1B1C] dark:[--cm-dot:#F4F4FA]',
'[--cm-scrim:#1B1B1C14] dark:[--cm-scrim:#0E0E0F99]',
'[--cm-elev-card:0_2px_6px_rgba(0,0,0,0.06),0_28px_56px_-16px_rgba(0,0,0,0.25)] dark:[--cm-elev-card:0_2px_6px_rgba(0,0,0,0.5),0_36px_72px_-18px_rgba(0,0,0,0.9)]',
'[--cm-elev-1:0_1px_2px_rgba(0,0,0,0.08)] dark:[--cm-elev-1:0_1px_2px_rgba(0,0,0,0.5)]',
'[--cm-elev-menu:0_4px_12px_rgba(0,0,0,0.10),0_16px_32px_-8px_rgba(0,0,0,0.2)] dark:[--cm-elev-menu:0_4px_12px_rgba(0,0,0,0.5),0_16px_32px_-8px_rgba(0,0,0,0.8)]',
].join(' ')
const META = 'shrink-0 text-[12px] leading-4 tabular-nums text-(color:--cm-muted)'
const MONO = 'font-mono text-[12px] leading-[18px]'
const BTN =
'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] leading-4 font-semibold transition-[background-color,color,border-color,box-shadow,scale] duration-150 active:scale-[0.97] disabled:opacity-50'
const BTN_SECONDARY = `${BTN} border border-(color:--cm-border-strong) text-(color:--cm-text) hover:bg-(--cm-raised)`
const BTN_ICON =
'inline-grid size-8 shrink-0 place-items-center rounded-full transition-[background-color,color,scale] duration-150 active:scale-[0.97]'
const FOCUS =
'outline-none focus-visible:ring-2 focus-visible:ring-(color:--cm-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(color:--cm-focus-gap)'
// Touch reach via pseudo-element: 32px buttons reach 44px, 16px attachment removes 40px.
const HIT = "relative pointer-coarse:after:absolute pointer-coarse:after:content-['']"
const HIT_BUTTON = `${HIT} pointer-coarse:after:-inset-1.5`
const HIT_SMALL = `${HIT} pointer-coarse:after:-inset-3`
// Chip remove (16px box): 40px wide so neighbours never overlap, 48px tall.
const HIT_CHIP_REMOVE = `${HIT} pointer-coarse:after:-inset-x-3 pointer-coarse:after:-inset-y-4`
const EASE = [0.23, 1, 0.32, 1] as const
const SPRING = { type: 'spring' as const, duration: 0.35, bounce: 0 }
const SR_ONLY =
'absolute h-px w-px overflow-hidden [clip:rect(0,0,0,0)] whitespace-nowrap border-0 p-0'
const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'
function subscribeReduced(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {}
const mq = window.matchMedia(REDUCED_QUERY)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}
function getReduced() {
return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(REDUCED_QUERY).matches
}
function getReducedServer() {
return false
}
type Tone = 'neutral' | 'ai' | 'attention' | 'success' | 'red'
const TONE_VAR: Record<Tone, string> = {
neutral: 'var(--cm-dot)',
ai: 'var(--cm-ai)',
attention: 'var(--cm-attention)',
success: 'var(--cm-success)',
red: 'var(--cm-red)',
}
const TONE_STRENGTH: Record<Tone, number> = { neutral: 6, ai: 14, attention: 14, success: 10, red: 12 }
type IconType = ComponentType<{ size?: number; className?: string }>
type CommandItem = { id: string; label: string; hint: string; icon?: IconType }
type FileItem = { path: string; kb: number }
type ModelMark = ComponentType<{ size: number }>
type ModelItem = {
id: string
label: string
speed: string
cost: string
mark?: ModelMark
}
type SubmitMode = 'enter' | 'modEnter' | 'none'
type Attachment = {
id: string
name: string
kind: 'image' | 'file'
url?: string
dimensions?: string
kb?: number
file?: File
}
type SendPayload = {
text: string
command: CommandItem | null
mentions: FileItem[]
attachments: Attachment[]
}
type OpenList =
| { kind: 'commands'; start: number; query: string; source: 'type' | 'button' }
| { kind: 'mentions'; start: number; query: string; source: 'type' | 'button' }
| { kind: 'models' }
| null
const CONTEXT_ZERO = 'No files in context'
const QUEUED_LABEL = 'Queued'
const BUSY_LIVE = 'Agent is working. Press Escape to stop.'
const ERROR_COPY = 'Message could not be sent. Try again.'
const NO_COMMAND = 'No command matches'
const NO_FILE = 'No file matches'
const DROP_LABEL = 'Drop files to attach'
const DEFAULT_COMMANDS: CommandItem[] = [
{ id: 'fix', label: 'Fix', hint: 'Repair the failing test or build', icon: WrenchDuotone },
{ id: 'explain', label: 'Explain', hint: 'Walk through what this code does', icon: BookOpenDuotone },
{ id: 'test', label: 'Test', hint: 'Write tests for the selected file', icon: FlaskDuotone },
{
id: 'refactor',
label: 'Refactor',
hint: 'Restructure without changing behavior',
icon: RefreshDuotone,
},
{ id: 'plan', label: 'Plan', hint: 'Draft the steps before editing', icon: ListChecksDuotone },
]
const DEFAULT_FILES: FileItem[] = [
{ path: 'app/lib/component-registry.tsx', kb: 12 },
{ path: 'src/components/composer/index.tsx', kb: 41 },
{ path: 'app/globals.css', kb: 8 },
{ path: 'scripts/build.mjs', kb: 3 },
{ path: 'README.md', kb: 6 },
]
const NONE: never[] = []
const DEFAULT_MODELS: ModelItem[] = [
{ id: 'gpt-sol', label: 'GPT-6.1 Sol', speed: 'Deepest', cost: 'high cost', mark: LogoGpt },
{ id: 'claude-opus', label: 'Claude Opus 5.5', speed: 'Balanced', cost: 'mid cost', mark: LogoAnthropic },
{ id: 'gemini-argon', label: 'Gemini 4 Argon', speed: 'Fast', cost: 'low cost', mark: LogoGoogle },
{ id: 'mistral-large', label: 'Mistral Large 4', speed: 'Fast', cost: 'mid cost', mark: LogoMistral },
{ id: 'deepseek-flash', label: 'DeepSeek V4 Flash', speed: 'Instant', cost: 'lowest cost', mark: LogoDeepSeek },
]
const DEMO_TEXT = 'Draft the steps for the new composer'
const DEMO_ATTACHMENT: Attachment = {
id: 'demo-hero-frame',
name: 'hero-frame.png',
kind: 'image',
dimensions: '1440 x 900',
}
// React's synthetic event does not surface isComposing, so read the native one.
function isComposingEvent(e: React.KeyboardEvent): boolean {
return (e.nativeEvent as unknown as { isComposing?: boolean }).isComposing === true
}
const COARSE_POINTER = '(pointer: coarse)'
function subscribeCoarse(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {}
const mq = window.matchMedia(COARSE_POINTER)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}
function getCoarse() {
if (typeof window === 'undefined' || !window.matchMedia) return false
return window.matchMedia(COARSE_POINTER).matches
}
function getCoarseServer() {
return false
}
let uidSeed = 0
function uid() {
uidSeed += 1
return `cm-${uidSeed}`
}
function truncate(name: string, max: number) {
return name.length <= max ? name : `${name.slice(0, max - 1)}…`
}
function formatTokens(n: number) {
return n < 1000 ? `${n} tokens` : `${(n / 1000).toFixed(1)}k tokens`
}
function springOf(reduce: boolean) {
return reduce ? { duration: 0 } : SPRING
}
// Framer wants seconds; tuning is in milliseconds, and reduced motion is instant.
function secs(reduce: boolean, value: number) {
return reduce ? 0 : value / 1000
}
function detectTrigger(
text: string,
caret: number,
hasCommand: boolean,
): { kind: 'commands' | 'mentions'; start: number; query: string } | null {
if (!hasCommand && text.startsWith('/') && caret >= 1) {
const token = text.slice(1)
if (!/\s/.test(token)) return { kind: 'commands', start: 0, query: token }
}
const before = text.slice(0, caret)
const at = before.lastIndexOf('@')
if (at !== -1) {
const prev = at === 0 ? '' : before.charAt(at - 1)
if (at === 0 || /\s/.test(prev)) {
const token = before.slice(at + 1)
if (!/\s/.test(token)) return { kind: 'mentions', start: at, query: token }
}
}
return null
}
function filterCommands(items: CommandItem[], query: string) {
const q = query.trim().toLowerCase()
if (!q) return items
return items.filter(
(c) => c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q),
)
}
function filterFiles(items: FileItem[], query: string) {
const q = query.trim().toLowerCase()
if (!q) return items
return items.filter((f) => f.path.toLowerCase().includes(q))
}
/**
* @typedef {object} CommandItem
* @property {string} id Stable command identifier that must be unique within the commands list.
* @property {string} label Command name shown in the picker and selected chip.
* @property {string} hint Supporting text shown beside the command name in the picker.
* @property {IconType} [icon] Icon shown for the command; left out, the picker uses its generic command mark.
*/
/**
* @typedef {object} FileItem
* @property {string} path File path shown in mention results and inserted into the context list.
* @property {number} kb File size in kilobytes used to estimate the context token count.
*/
/**
* @typedef {object} ModelItem
* @property {string} id Stable model identifier that must be unique within the models list.
* @property {string} label Model name shown on the selector and in its menu.
* @property {string} speed Speed description shown in the model menu.
* @property {string} cost Cost description shown in the model menu when space allows.
* @property {ModelMark} [mark] Mark drawn beside the model; left out, a generic circular mark is used.
*/
/**
* @typedef {object} AgentComposerProps
* @property {string} [value] Controls the message text; left out, text is kept internally, while passing it turns the demo off.
* @property {(value: string) => void} [onChange] Receives each text edit; left out, uncontrolled text still updates locally but a controlled value cannot change.
* @property {(payload: SendPayload) => void} [onSend] Receives the trimmed text, selected command, mentions and attachments; left out, submission only clears the local composition, while passing it turns the demo off.
* @property {() => void} [onStop] Handles stopping a busy request and makes the stop control available outside the demo; left out, a host-controlled busy state has no stop control.
* @property {boolean} [busy] Controls whether the composer shows its working state; left out, that state is managed internally, while passing it turns the demo off.
* @property {CommandItem[]} [commands] Supplies slash commands; left out, the demo uses sample commands and host mode uses none, while passing it turns the demo off.
* @property {FileItem[]} [files] Supplies files for mentions and attachments; left out, the demo uses sample files and host mode uses none, while passing it turns the demo off.
* @property {ModelItem[]} [models] Supplies the model selector options; left out, the demo uses sample models and host mode uses none, while passing it turns the demo off.
* @property {string} [model] Controls the selected model by id; left out, the first available model is selected internally, while passing it turns the demo off.
* @property {(id: string) => void} [onModelChange] Receives a selected model id; left out, model selection is kept internally when model is uncontrolled.
* @property {string} [placeholder] Sets the empty text prompt; left out, it is assembled from the available command and file shortcuts.
* @property {SubmitMode} [submitMode='enter'] Chooses whether Enter, modified Enter or no keyboard shortcut sends the message.
* @property {number} [maxRows] Sets the textarea height limit in text rows; left out, the tuning height limit is used.
* @property {string} [className] Adds classes to the outermost root; left out, only the built-in classes are used.
* @property {Partial<Tuning>} [tuning] Overrides composer measurements and motion values; left out, every value in TUNING is used.
*/State
- every hook, handler, effect and disposal
- the animation loop, frame by frame
Tree
- the JSX, every className and inline style
Why · Remix · Check
- the mechanism, the tuning points, the checks
Unlock Agent Composer
The full source and the remix prompt ship with Premium.