Lunia DS

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.

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-composer

Usage

The demo above, as code.

demo.tsx
'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.

PropTypeDefaultDescription
valuestringundefinedControlled prompt text.
defaultValuestring""Prompt text at first when not controlled.
status"ready" | "submitted" | "streaming" | "error""ready"Request status; takes the chat hook status as is.
placeholderstring"What do you want to build?"Placeholder and accessible name of the prompt.
branchstringundefinedBranch shown in the status tab. Leave branch, folder and context out to hide the tab.
folderstringundefinedProject folder shown in the status tab.
context{ used: number; limit: number }undefinedHow full the context is; drives the ring and the percent.
permissionsModelOption[]Auto, Ask first, Plan only, Full accessPermission levels (same shape as model-picker options).
permissionstringundefinedChosen permission id (controlled).
defaultPermissionstringfirst permissionPermission id at first when not controlled.
modelsModelOption[]-Models for the model picker.
modelstring-Chosen model id.
effortstringundefinedThinking effort for models that reason.
acceptstringundefinedFile types for the file picker, paste and drop.
listeningbooleanfalseThe mic is listening; the halo breathes.
EventDescription
valueChangePrompt text changed. Payload: the text.
submitEnter or the send button sent the prompt. Payload: the text.
stopStop was pressed while the answer streams.
permissionChangePayload: the new permission id.
modelChangePayload: the new model id.
effortChangePayload: the new effort level.
filesSelectedFiles were picked, pasted or dropped. Payload: the files. Setting this listener turns on the add files button, paste and drop.
voiceThe mic was pressed. Setting this listener shows the mic.
SlotDescription
attachmentsAttachment 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
AG1Message sentsend iconsopacity, blurarrow 1, 0 → spinner (arrow 0, blur-swap)duration-md (260)ease-outSwaps at once
AG18Message sentsend iconsscalearrow 1 → spinner (arrow scale-swap)duration-xl (380)ease-outSwaps at once
AG2Answer startssend iconsopacity, scale, blurspinner → stop (same values as AG1 and AG18)duration-md (260)ease-outSwaps at once
AG3Answer ends or Stop pressedsend iconsopacity, scale, blurstop → arrow (same values as AG1 and AG18)duration-md (260)ease-outSwaps at once
AG4Send pressedsend buttonscale1 → scale-pressduration-sm (200)ease-outStays still
AG5Prompt filled or emptiedsend buttonopacity0.35 → 1duration-sm (200)ease-outShows at once
AG6Card gets focuscard ringborder colorborder-secondary → border-primaryduration-sm (200)ease-outSwaps at once
AG7Branch or folder changestab labelstext morphmain → feat/couponsduration-3xl (460)ease-outSwaps at once
AG8Context growscontext ringstroke drawn42% → 84%duration-5xl (640)ease-outSwaps at once
AG9Context growscontext percentnumber roll42% → 84%duration-5xl (640)ease-outSwaps at once
AG10Context passes 80% or 95%ring, percentcolorfg-tertiary → fg-warning-primary, then fg-error-primaryduration-lg (320)ease-outSwaps at once
AG11First file addedattachments rowheight (grid rows)0fr → 1frduration-xl (380)ease-outResizes at once
AG12First file addedattachments rowopacity0 → 1duration-md (260)ease-outShows at once
AG13Last file removedattachments rowopacity1 → 0duration-xs (160)ease-outHides at once
AG19Last file removedattachments rowheight (grid rows)1fr → 0frduration-xl (380)ease-outResizes at once
AG14Mic starts listeningmic haloopacity0 → 1duration-sm (200)ease-outShows at once
AG15While listeningmic haloscale, opacity (loop)0.82, 0.55 → 1, 1duration-loop (1600)ease-loopNo loop
AG16Files dragged over the carddrop layer, card ringopacity, border color0, border-secondary → 1, border-brandduration-sm (200)ease-outShows at once
AG17Text added or removedpromptheightold height → grows with the text, up to 220px0ease-linearResizes 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.