AI input
Agent composer
The prompt box for an AI agent: the send button, mic and status tab change in place, so the person always knows if the agent is waiting, working or listening.
Live preview
Loading preview
A composer with a branch, a folder and a context ring at 42%. Typing a prompt and pressing Enter shows Sending, then Stop while a fake answer streams, then back to Ready; the context rises to 84%. Replay runs the send flow again.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add agent-composerUsage
The demo above, as code.
'use client';
import * as React from 'react';
import type { ModelOption } from '../model-picker/model-picker';
import type { ComposerStatus } from '../prompt-composer/prompt-composer';
import { AgentComposer } from './agent-composer';
const MODELS: ModelOption[] = [
{
id: 'pro',
name: 'Lumen 2 Pro',
description: 'Best for hard, long tasks',
speed: 1,
intelligence: 3,
reasoning: true,
},
{
id: 'orbit',
name: 'Orbit 5',
description: 'Fast and smart for most work',
speed: 2,
intelligence: 2,
reasoning: true,
},
{
id: 'atlas',
name: 'Atlas Fast',
description: 'Quick answers to simple questions',
speed: 3,
intelligence: 1,
},
];
const MARKS: Record<string, string> = {
pro: 'M6.667 2 7.6 5.067 10.667 6 7.6 6.933 6.667 10 5.733 6.933 2.667 6l3.066-.933zM11.667 9.333l.5 1.5 1.5.5-1.5.5-.5 1.5-.5-1.5-1.5-.5 1.5-.5z',
orbit: 'M8 1.667 13.5 4.833v6.334L8 14.333 2.5 11.167V4.833z',
atlas: 'M8.667 1.333 2.667 9.333H8l-.667 5.334 6-8H8z',
};
const PROMPT = 'Add a coupon field to the checkout page and show the discount';
const FILES = ['checkout.tsx', 'coupon-rules.pdf'];
/** A plain file chip for the demo; apps put their own attachment chips in the slot. */
function FileChip({ name, onRemove }: { name: string; onRemove: () => void }) {
return (
<span className="inline-flex h-8 max-w-60 items-center gap-1 rounded-lg border border-secondary bg-primary_alt ps-2.5 pe-1 text-xs font-medium text-secondary">
<span className="truncate">{name}</span>
<button
type="button"
aria-label={`Remove ${name}`}
className="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-fg-quaternary outline-none hover:bg-tertiary hover:text-fg-secondary focus-visible:shadow-focus-ring"
onClick={onRemove}
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className="size-3.5"
>
<path d="M12 4 4 12M4 4l8 8" />
</svg>
</button>
</span>
);
}
export default function AgentComposerDemo({ replay = 0 }: { replay?: number }) {
const [text, setText] = React.useState('');
const [status, setStatus] = React.useState<ComposerStatus>('ready');
const [files, setFiles] = React.useState<string[]>(FILES);
const [model, setModel] = React.useState('pro');
const [effort, setEffort] = React.useState('High');
const [branch, setBranch] = React.useState('main');
const [used, setUsed] = React.useState(84_000);
const [listening, setListening] = React.useState(false);
const timers = React.useRef<number[]>([]);
const clear = React.useCallback(() => {
for (const timer of timers.current) window.clearTimeout(timer);
timers.current = [];
}, []);
React.useEffect(() => clear, [clear]);
const later = (ms: number, run: () => void) => timers.current.push(window.setTimeout(run, ms));
// A fake run: wait, stream while the agent switches branch and reads more context, then ready.
const submit = () => {
clear();
setText('');
setFiles([]);
setStatus('submitted');
later(900, () => {
setStatus('streaming');
setBranch('feat/coupons');
setUsed(168_000);
});
later(3400, () => setStatus('ready'));
};
const submitRef = React.useRef(submit);
submitRef.current = submit;
React.useEffect(() => {
if (replay === 0) return;
for (const timer of timers.current) window.clearTimeout(timer);
timers.current = [];
setStatus('ready');
setBranch('main');
setUsed(84_000);
setFiles(FILES);
setText('');
timers.current.push(window.setTimeout(() => setText(PROMPT), 300));
timers.current.push(window.setTimeout(() => submitRef.current(), 1100));
}, [replay]);
return (
<div className="flex min-h-[420px] w-full max-w-[640px] flex-col justify-end">
<AgentComposer
value={text}
status={status}
branch={branch}
folder="web-app"
context={{ used, limit: 200_000 }}
models={MODELS}
model={model}
effort={effort}
listening={listening}
onValueChange={setText}
onSubmit={submit}
onStop={() => {
clear();
setStatus('ready');
}}
onModelChange={setModel}
onEffortChange={setEffort}
onFilesSelected={(picked) => setFiles((list) => [...list, ...picked.map((f) => f.name)])}
onVoice={() => setListening((on) => !on)}
renderIcon={(option, kind) =>
kind === 'model' ? (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={MARKS[option.id]} />
</svg>
) : undefined
}
attachments={files.map((name) => (
<FileChip
key={name}
name={name}
onRemove={() => setFiles((list) => list.filter((f) => f !== name))}
/>
))}
/>
</div>
);
}
API
Parts: status tab (branch, folder, context ring and percent), card, attachments row, prompt (textarea), toolbar, add files button, permission picker (model-picker), model picker (model-picker), mic button with halo, send button (send, wait, stop icons), drop layer.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | undefined | Controlled prompt text. |
| defaultValue | string | "" | Prompt text at first when not controlled. |
| status | "ready" | "submitted" | "streaming" | "error" | "ready" | Request status; takes the chat hook status as is. |
| placeholder | string | "What do you want to build?" | Placeholder and accessible name of the prompt. |
| branch | string | undefined | Branch shown in the status tab. Leave branch, folder and context out to hide the tab. |
| folder | string | undefined | Project folder shown in the status tab. |
| context | { used: number; limit: number } | undefined | How full the context is; drives the ring and the percent. |
| permissions | ModelOption[] | Auto, Ask first, Plan only, Full access | Permission levels (same shape as model-picker options). |
| permission | string | undefined | Chosen permission id (controlled). |
| defaultPermission | string | first permission | Permission id at first when not controlled. |
| models | ModelOption[] | - | Models for the model picker. |
| model | string | - | Chosen model id. |
| effort | string | undefined | Thinking effort for models that reason. |
| accept | string | undefined | File types for the file picker, paste and drop. |
| listening | boolean | false | The mic is listening; the halo breathes. |
| Event | Description |
|---|---|
| valueChange | Prompt text changed. Payload: the text. |
| submit | Enter or the send button sent the prompt. Payload: the text. |
| stop | Stop was pressed while the answer streams. |
| permissionChange | Payload: the new permission id. |
| modelChange | Payload: the new model id. |
| effortChange | Payload: the new effort level. |
| filesSelected | Files were picked, pasted or dropped. Payload: the files. Setting this listener turns on the add files button, paste and drop. |
| voice | The mic was pressed. Setting this listener shows the mic. |
| Slot | Description |
|---|---|
| attachments | Attachment chips shown above the prompt. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| AG1 | Message sent | send icons | opacity, blur | arrow 1, 0 → spinner (arrow 0, blur-swap) | duration-md (260)ease-out | Swaps at once |
| AG18 | Message sent | send icons | scale | arrow 1 → spinner (arrow scale-swap) | duration-xl (380)ease-out | Swaps at once |
| AG2 | Answer starts | send icons | opacity, scale, blur | spinner → stop (same values as AG1 and AG18) | duration-md (260)ease-out | Swaps at once |
| AG3 | Answer ends or Stop pressed | send icons | opacity, scale, blur | stop → arrow (same values as AG1 and AG18) | duration-md (260)ease-out | Swaps at once |
| AG4 | Send pressed | send button | scale | 1 → scale-press | duration-sm (200)ease-out | Stays still |
| AG5 | Prompt filled or emptied | send button | opacity | 0.35 → 1 | duration-sm (200)ease-out | Shows at once |
| AG6 | Card gets focus | card ring | border color | border-secondary → border-primary | duration-sm (200)ease-out | Swaps at once |
| AG7 | Branch or folder changes | tab labels | text morph | main → feat/coupons | duration-3xl (460)ease-out | Swaps at once |
| AG8 | Context grows | context ring | stroke drawn | 42% → 84% | duration-5xl (640)ease-out | Swaps at once |
| AG9 | Context grows | context percent | number roll | 42% → 84% | duration-5xl (640)ease-out | Swaps at once |
| AG10 | Context passes 80% or 95% | ring, percent | color | fg-tertiary → fg-warning-primary, then fg-error-primary | duration-lg (320)ease-out | Swaps at once |
| AG11 | First file added | attachments row | height (grid rows) | 0fr → 1fr | duration-xl (380)ease-out | Resizes at once |
| AG12 | First file added | attachments row | opacity | 0 → 1 | duration-md (260)ease-out | Shows at once |
| AG13 | Last file removed | attachments row | opacity | 1 → 0 | duration-xs (160)ease-out | Hides at once |
| AG19 | Last file removed | attachments row | height (grid rows) | 1fr → 0fr | duration-xl (380)ease-out | Resizes at once |
| AG14 | Mic starts listening | mic halo | opacity | 0 → 1 | duration-sm (200)ease-out | Shows at once |
| AG15 | While listening | mic halo | scale, opacity (loop) | 0.82, 0.55 → 1, 1 | duration-loop (1600)ease-loop | No loop |
| AG16 | Files dragged over the card | drop layer, card ring | opacity, border color | 0, border-secondary → 1, border-brand | duration-sm (200)ease-out | Shows at once |
| AG17 | Text added or removed | prompt | height | old height → grows with the text, up to 220px | 0ease-linear | Resizes at once |
Accessibility
- The prompt is a textarea named by the placeholder text.
- The send button name follows its state: "Send", "Sending" or "Stop"; icons inside it are aria-hidden.
- The mic is a toggle button with aria-pressed and the name "Speak" or "Stop listening".
- The add files button is named "Add files"; paste and drop also add files.
- The status tab parts are aria-hidden when the tab is hidden; the context percent is read as text, not only as the ring.
- Permission and model pickers follow model-picker keyboard rules.