Flo composer
The one box for talking to Flo: a rounded island with the message, the files, attach, the mic and a round Send that turns into Stop while Flo answers.
Playground
Change the controls to try each prop. The code under the preview updates as you go and lists only the props you changed; copy it into your page.
import { FloComposer } from '@/ui/flo-composer';<FloComposer placeholder="Ask Flo, or drop a CV" />Default
The panel composer: 15/24 text in a 16px-radius island with a soft shadow. The box grows with its text up to 160px, then scrolls. Enter sends and Shift+Enter breaks a line; Send stays off until there is something to send.
import { FloComposer } from '@/ui/flo-composer';
import { toast } from '@/ui/toast';
export default function FloComposerDefault() {
return (
<div className="w-full max-w-92">
<FloComposer onSend={(message) => toast.add({ title: 'Sent to Flo', description: message })} />
</div>
);
}Files
Pass onFiles and the clip, a drop and a paste all feed the same queue. Dragging files over the island shows "Drop files for Flo" on a dashed green edge. The chips sit inside the island above the text (Attachment chip, variant="composer"). Send waits while a file uploads. Past five files the clip turns off and extra files are refused in words. Try dropping a file.
import { useState } from 'react';
import { AttachmentChip, AttachmentList } from '@/ui/attachment-chip';
import { FloComposer } from '@/ui/flo-composer';
import { toast } from '@/ui/toast';
type Pending = { key: string; name: string; bytes: number; status: 'uploading' | 'ready' | 'failed'; error?: string };
const limit = 20 * 1024 * 1024;
export default function FloComposerFiles() {
const [files, setFiles] = useState<Pending[]>([
{ key: 'cv', name: 'Maya Chen CV.pdf', bytes: 188416, status: 'ready' },
]);
// The app uploads each file as it lands; here a timer stands in for the upload.
function add(chosen: File[]) {
const added = chosen.map((file): Pending => {
const tooBig = file.size > limit;
return {
key: crypto.randomUUID(),
name: file.name,
bytes: file.size,
status: tooBig ? 'failed' : 'uploading',
error: tooBig ? `Too big · ${Math.round(file.size / 1024 / 1024)} MB, limit 20 MB` : undefined,
};
});
setFiles((before) => [...before, ...added]);
setTimeout(() => setFiles((before) => before.map((file) => (file.status === 'uploading' ? { ...file, status: 'ready' } : file))), 1200);
}
return (
<div className="w-full max-w-92">
<FloComposer
defaultValue="What does she lack for Halcyon?"
onFiles={add}
accept=".pdf,.docx,.png,.jpg,.csv,.xlsx"
fileCount={files.length}
uploading={files.some((file) => file.status === 'uploading')}
attachments={
files.length ? (
<AttachmentList label="Files for Flo">
{files.map(({ key, ...file }) => (
<AttachmentChip key={key} variant="composer" {...file} onRemove={() => setFiles((before) => before.filter((other) => other.key !== key))} />
))}
</AttachmentList>
) : null
}
onSend={(message) => {
toast.add({ title: 'Sent to Flo', description: [message, ...files.filter((file) => file.status === 'ready').map((file) => file.name)].filter(Boolean).join(' · ') });
setFiles([]);
}}
/>
</div>
);
}Flo working
While Flo answers, an empty box turns Send into Stop (a white square on ink); Esc stops too. You can keep typing: Send comes back, and the message waits at the end of the thread as FloQueuedMessage, "Sends when Flo finishes".
import { useState } from 'react';
import { FloComposer, FloQueuedMessage } from '@/ui/flo-composer';
export default function FloComposerWorking() {
const [working, setWorking] = useState(true);
const [queued, setQueued] = useState(['Add her to the Halcyon shortlist too']);
return (
<div className="flex w-full max-w-92 flex-col gap-300">
{queued.map((message, at) => (
<FloQueuedMessage key={at}>{message}</FloQueuedMessage>
))}
<FloComposer
working={working}
onStop={() => setWorking(false)}
onSend={(message) => (working ? setQueued((before) => [...before, message]) : setWorking(true))}
/>
</div>
);
}Listening
With voice, the mic sits beside Send. While it listens the box is read only and shows the words as they are heard, and the status line shows the listening mark and how to finish. The first session shows the privacy notice under the box. See Voice input.
import { useState } from 'react';
import { FloComposer } from '@/ui/flo-composer';
import { VoiceNotice } from '@/ui/voice-input';
export default function FloComposerListening() {
const [startedAt, setStartedAt] = useState<number | null>(() => Date.now());
const [notice, setNotice] = useState(true);
return (
<div className="w-full max-w-92">
<FloComposer
value={startedAt ? 'Move Maya Chen to interview and remind me to call Sara on Friday' : ''}
voice={{ listening: startedAt !== null, startedAt: startedAt ?? undefined, onToggle: () => setStartedAt((at) => (at ? null : Date.now())) }}
notice={notice ? <VoiceNotice learnMoreHref="https://soniox.com" onDismiss={() => setNotice(false)} /> : null}
/>
</div>
);
}Over the limit
A message longer than Flo reads (8,000 characters; 80 here) waits in the box with a red line saying how long it is, and Send stays off.
import { FloComposer } from '@/ui/flo-composer';
export default function FloComposerOverLimit() {
return (
<div className="w-full max-w-92">
<FloComposer
maxLength={80}
defaultValue="Find every candidate we placed at Halcyon Bank in the last two years, then draft a note to each of their managers asking how they are settling in."
/>
</div>
);
}Viewing a file
With a file open beside the chat, a chip says "Viewing Maya Chen CV.pdf" and the placeholder asks about it.
import { FloComposer } from '@/ui/flo-composer';
export default function FloComposerViewing() {
return (
<div className="w-full max-w-92">
<FloComposer viewing="Maya Chen CV.pdf" onFiles={() => {}} />
</div>
);
}On the Flo page
size="page": 16/24 text, 32px buttons and a little more padding, in the page's 680px column.
import { FloComposer } from '@/ui/flo-composer';
export default function FloComposerPage() {
return (
<div className="w-full max-w-170">
<FloComposer size="page" placeholder="Ask a follow-up" onFiles={() => {}} voice={{ listening: false, onToggle: () => {} }} />
</div>
);
}Inline on a screen
Where Flo edits what is on screen, such as the CV review, the composer takes no files and has no Stop: while Flo works, Send shows a spinner.
import { useState } from 'react';
import { FloComposer } from '@/ui/flo-composer';
export default function FloComposerInline() {
const [busy, setBusy] = useState(false);
return (
<div className="w-full max-w-130">
<FloComposer
placeholder="Tell Flo what to change in this CV"
working={busy}
onSend={() => {
setBusy(true);
setTimeout(() => setBusy(false), 1500);
}}
/>
</div>
);
}Send
Press Send, Enter or Replay: the text leaves the box and moves into the thread as your message. Press Reduced motion to see the fallback.
import { useRef, useState } from 'react';
import { FloComposer } from '@/ui/flo-composer';
import { FloUserMessage } from '@/ui/flo-message';
import { sendWithMorph } from '@/ui/motion';
const question = 'Who on the Northwind shortlist has not heard back this week?';
export default function FloComposerSend() {
const [text, setText] = useState(question);
const [sent, setSent] = useState<string[]>([]);
const thread = useRef<HTMLDivElement>(null);
const composer = useRef<HTMLFormElement>(null);
const send = (message: string) =>
sendWithMorph(composer.current?.querySelector('textarea') ?? null, () => {
setSent((before) => [...before, message]);
setText('');
}, thread.current ?? document);
return (
<div className="flex h-72 w-full max-w-120 flex-col justify-end gap-200">
<div ref={thread} className="flex flex-col gap-150">
{sent.map((message, index) => (
<FloUserMessage key={index}>{message}</FloUserMessage>
))}
</div>
<FloComposer ref={composer} value={text} onValueChange={setText} onSend={send} />
<button
type="button"
hidden
data-replay
onClick={() => {
setSent([]);
setText(question);
requestAnimationFrame(() => requestAnimationFrame(() => send(question)));
}}
/>
</div>
);
}Flo thread
The Flo conversation: a column of turns that stays pinned to the newest while Flo writes, Latest once you scroll up, and What should Flo do? with suggested asks when it's empty.
Plan card
One approval for a big change: an import from the member's files, or a list of target companies. Every row shows before anything is written; the member leaves rows out, approves once, and can undo the lot for 14 days.