ComponentsAI

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.

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.

You: Who is waiting at CV sent?
Flo

Maya Chen has waited longest, six days, for Halcyon Bank.

States
import { FloThread } from '@/ui/flo-thread';<FloThread label="Chat with Flo" />

In the panel

The 400px panel: 20px gutters and 28px between turns. While Flo writes, the column stays pinned to the newest turn. Scroll up and it lets go; Latest appears 12px above the composer and takes you back down.

You: Who is waiting at CV sent?
Flo

Three people have waited more than five days. has waited longest, six days, for Halcyon Bank.

You: Move Maya to CV sent and email Sara her CV
Flo

Two changes for you to approve. The email goes from your own inbox once you send it.

2 changes for you
  • Maya ChenfromLong listtoCV sent
  • Sara LeeEmail Maya's CV · draft
You: Add her to the Halcyon shortlist too
FloAdding Maya Chen to the shortlist…
import { FloApproval } from '@/ui/flo-approval';
import { FloReply, FloUserMessage } from '@/ui/flo-message';
import { FloThread } from '@/ui/flo-thread';
import { Lozenge } from '@/ui/lozenge';
import { ProposedChangeRow } from '@/ui/proposed-change-row';
import { RecordChip } from '@/ui/record-chip';
import { ValueDiff } from '@/ui/value-diff';

const later = () => new Promise<void>((resolve) => setTimeout(resolve, 800));

export default function FloThreadPanel() {
  return (
    <div className="flex h-130 w-full max-w-[25rem] flex-col overflow-hidden rounded-xlarge shadow-raised">
      <FloThread busy>
        <FloUserMessage>Who is waiting at CV sent?</FloUserMessage>
        <FloReply steps={['Read your open jobs']} copyText="Maya Chen has waited longest." onFeedbackChange={() => {}}>
          <p>
            Three people have waited more than five days. <RecordChip variant="inline" type="candidate" name="Maya Chen" onOpen={() => {}} /> has
            waited longest, six days, for Halcyon Bank.
          </p>
        </FloReply>
        <FloUserMessage>Move Maya to CV sent and email Sara her CV</FloUserMessage>
        <FloReply steps={['Found Maya Chen', 'Drafted the email to Sara Lee']} copyText="Two changes for you to approve." onFeedbackChange={() => {}}>
          <p>Two changes for you to approve. The email goes from your own inbox once you send it.</p>
          <FloApproval onApprove={later} onDeny={later} onUndo={later} decidedAt="10:42">
            <ProposedChangeRow kind="stage_move" record={<RecordChip variant="inline" type="candidate" name="Maya Chen" />} summary="Maya Chen, Stage Long list to CV sent">
              <ValueDiff before="Long list" after={<Lozenge phase="client">CV sent</Lozenge>} />
            </ProposedChangeRow>
            <ProposedChangeRow kind="email" record={<RecordChip variant="inline" type="contact" name="Sara Lee" />} summary="Sara Lee, Email Maya's CV, draft">
              Email Maya&apos;s CV · draft
            </ProposedChangeRow>
          </FloApproval>
        </FloReply>
        <FloUserMessage>Add her to the Halcyon shortlist too</FloUserMessage>
        <FloReply status="working" working="Adding Maya Chen to the shortlist…" />
      </FloThread>
    </div>
  );
}

Send

Type and press Enter: the message travels from the composer into the thread, and Flo's reply rises in under it, shimmering while it works. Replay starts the chat again.

What should Flo do?

import { useRef, useState } from 'react';
import { FloComposer } from '@/ui/flo-composer';
import { FloReply, FloUserMessage } from '@/ui/flo-message';
import { FloThread } from '@/ui/flo-thread';
import { sendWithMorph } from '@/ui/motion';

type Turn = { id: number; text: string; done: boolean };

export default function FloThreadSend() {
  const [turns, setTurns] = useState<Turn[]>([]);
  const frame = useRef<HTMLDivElement>(null);
  const send = (text: string) => {
    const box = frame.current?.querySelector<HTMLElement>('[data-slot="flo-composer-input"]') ?? null;
    const id = Date.now();
    sendWithMorph(box, () => setTurns((before) => [...before, { id, text, done: false }]));
    setTimeout(() => setTurns((before) => before.map((turn) => (turn.id === id ? { ...turn, done: true } : turn))), 2000);
  };
  return (
    <div ref={frame} className="flex h-110 w-full max-w-[25rem] flex-col overflow-hidden rounded-xlarge shadow-raised">
      <FloThread
        busy={turns.some((turn) => !turn.done)}
        starters={[{ label: 'Who is waiting at CV sent?', detail: 'across your open jobs' }]}
        onStarter={send}
        composer={<FloComposer onSend={send} working={turns.some((turn) => !turn.done)} />}
      >
        {turns.flatMap((turn) => [
          <FloUserMessage key={`ask-${turn.id}`}>{turn.text}</FloUserMessage>,
          <FloReply key={`reply-${turn.id}`} enter status={turn.done ? 'done' : 'working'} working="Reading your open jobs…">
            {turn.done ? <p>Maya Chen has waited longest at CV sent, six days, for Halcyon Bank.</p> : null}
          </FloReply>,
        ])}
      </FloThread>
    </div>
  );
}

Empty

A new chat asks "What should Flo do?" at the foot of the column, near the composer, with a line on what Flo sees and two or three suggested asks. Up and Down move between the asks; Enter sends one.

What should Flo do?

Flo sees this job and its candidates. It proposes; nothing changes until you approve.

import { FloThread } from '@/ui/flo-thread';

export default function FloThreadEmpty() {
  return (
    <div className="flex h-110 w-full max-w-[25rem] flex-col overflow-hidden rounded-xlarge shadow-raised">
      <FloThread
        emptyDescription="Flo sees this job and its candidates. It proposes; nothing changes until you approve."
        starters={[
          { label: 'Who is waiting at CV sent?', detail: 'across your open jobs' },
          { label: 'Shortlist five for this job', detail: 'Senior Data Engineer, Halcyon Bank' },
        ]}
        onStarter={() => {}}
      />
    </div>
  );
}

On the Flo page

The Flo page keeps one 720px column, the same width as its composer, with 40px between turns and 16/26 reading type.

You: Who is waiting at CV sent?
Flo

Three people have waited more than five days at CV sent. Maya Chen has waited longest, six days, for Halcyon Bank.

import { FloReply, FloUserMessage } from '@/ui/flo-message';
import { FloThread } from '@/ui/flo-thread';

export default function FloThreadPage() {
  return (
    <div className="flex h-110 w-full flex-col overflow-hidden rounded-xlarge shadow-raised">
      <FloThread size="page">
        <FloUserMessage size="page">Who is waiting at CV sent?</FloUserMessage>
        <FloReply size="page" latest steps={['Read your open jobs']} copyText="Three people have waited more than five days at CV sent." onFeedbackChange={() => {}}>
          <p>Three people have waited more than five days at CV sent. Maya Chen has waited longest, six days, for Halcyon Bank.</p>
        </FloReply>
      </FloThread>
    </div>
  );
}

Reconnecting

When the line to Flo drops, a status line over the composer says Flo keeps working and the reply catches up once the line is back. The working line stays.

You: Shortlist five for Senior Data Engineer
FloJudging fit…0:42

Reconnecting to Flo. Flo keeps working, and the reply catches up when the line is back.

import { FloReply, FloUserMessage } from '@/ui/flo-message';
import { FloThread } from '@/ui/flo-thread';

export default function FloThreadReconnecting() {
  return (
    <div className="flex h-90 w-full max-w-[25rem] flex-col overflow-hidden rounded-xlarge shadow-raised">
      <FloThread busy connection="reconnecting">
        <FloUserMessage>Shortlist five for Senior Data Engineer</FloUserMessage>
        <FloReply status="working" working="Judging fit…" elapsed="0:42" />
      </FloThread>
    </div>
  );
}

Offline

Offline, the status line says what the member sends waits and goes once they're back. A message that failed stays in the thread with Retry, so nothing is lost. For a model error, a time-out, a refusal or a tool that failed partway, see Flo message; for an approval that expired or an Undo that came too late, see Flo approval.

You: Move Maya to CV sent

You're offline. What you send waits here and goes once you're back online.

import { FloUserMessage } from '@/ui/flo-message';
import { FloThread } from '@/ui/flo-thread';

export default function FloThreadOffline() {
  return (
    <div className="flex h-90 w-full max-w-[25rem] flex-col overflow-hidden rounded-xlarge shadow-raised">
      <FloThread connection="offline">
        <FloUserMessage status="failed" onRetry={() => {}}>
          Move Maya to CV sent
        </FloUserMessage>
      </FloThread>
    </div>
  );
}

On this page