Thread
The Copilot conversation: welcome, suggestions, messages and scroll to bottom.
Copilot
import { AssistantMessage, MessageActions, UserMessage } from "@oration/canon/components/ai/message";import { PromptBar } from "@oration/canon/components/ai/prompt-bar";import { Thread, ThreadMessages, ThreadViewport, ThreadWelcome } from "@oration/canon/components/ai/thread";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { MailIcon, PhoneIncomingIcon, SquarePenIcon } from "lucide-react";import * as React from "react";export function Hero() { const answers: Record<string, string> = { "Which suppliers called about late payments this week?": "Nora took 38 payment-status calls since Monday. Three suppliers called more than once: Northwind Freight (4 calls about INV-20931), Orchard Street (3 calls about OS-4471) and Halcyon (2 calls about a short payment on INV-20877).", "Draft a remittance reply to Halcyon": "Here's a draft for Aisha Bello at Halcyon: INV-20877 was paid by ACH on Friday, September 25, for $18,240.00. The $760.00 difference is the early-payment discount on your terms. The remittance advice is attached.", }; const [messages, setMessages] = React.useState([ { id: "m1", role: "user", text: "Is Northwind Freight's invoice INV-20931 going out this week?", }, { id: "m2", role: "assistant", text: "Yes. INV-20931 for $20,625.00 is approved and scheduled for Tuesday's ACH run, September 29. Nora has told Northwind twice on calls since Friday, and the remittance goes to ap@northwindfreight.example the same day.", }, ]); const [running, setRunning] = React.useState(false); const timer = React.useRef<number | undefined>(undefined); React.useEffect(() => () => window.clearTimeout(timer.current), []); const send = (text: string) => { const id = Date.now().toString(36); setMessages((list) => [...list, { id: `u-${id}`, role: "user", text }]); setRunning(true); timer.current = window.setTimeout(() => { setMessages((list) => [ ...list, { id: `a-${id}`, role: "assistant", text: answers[text] ?? `I checked Cedarline's supplier records and Nora's calls for "${text}". Nothing needs your attention yet; I'll flag it if a supplier calls about it.`, }, ]); setRunning(false); }, 1400); }; const lastId = messages.at(-1)?.id; return ( <div className="flex h-[30rem] w-full max-w-xl flex-col rounded-xl bg-card text-left shadow-border"> <div className="flex h-11 shrink-0 items-center justify-between border-b border-border pr-2 pl-4"> <h3 className="text-sm font-semibold text-foreground"> Copilot </h3> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label="New chat" onClick={() => { window.clearTimeout(timer.current); setRunning(false); setMessages([]); }} /> } > <SquarePenIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>New chat</TooltipContent> </Tooltip> </div> <Thread isRunning={running} className="min-h-0 flex-1"> <ThreadViewport contentClassName="max-w-none"> {messages.length ? ( <ThreadMessages> {messages.map((message) => message.role === "user" ? ( <UserMessage key={message.id}> {message.text} </UserMessage> ) : ( <AssistantMessage key={message.id} actionsVisibility={ message.id === lastId ? "always" : "hover" } actions={ <MessageActions content={message.text} /> } > {message.text} </AssistantMessage> ), )} {running ? ( <AssistantMessage status="streaming"> <span className="text-muted-foreground text-shimmer"> Checking supplier records and Nora's calls </span> </AssistantMessage> ) : null} </ThreadMessages> ) : ( <ThreadWelcome title="What can I help with?" description="Ask about suppliers, invoices, payment runs or Nora's calls. Type @ to add a record." suggestions={[ { label: "Which suppliers called about late payments this week?", prompt: "Which suppliers called about late payments this week?", icon: ( <PhoneIncomingIcon aria-hidden="true" /> ), }, { label: "Draft a remittance reply to Halcyon", prompt: "Draft a remittance reply to Halcyon", icon: <MailIcon aria-hidden="true" />, }, ]} suggestionsLayout="list" onSelect={(prompt) => send(prompt)} /> )} </ThreadViewport> <div className="shrink-0 px-3 pb-3"> <PromptBar label="Message Copilot" placeholder="Ask about suppliers or payments" dictation={false} isRunning={running} onStop={() => { window.clearTimeout(timer.current); setRunning(false); toast.add({ title: "Response stopped" }); }} onSubmit={(text) => send(text)} /> </div> </Thread> </div> );}Usage#
Thread is the frame of a conversation with Copilot: a column that holds a scrolling viewport of messages, a welcome with suggested prompts when it's empty, a scroll-to-latest button and the prompt bar below. It keeps the newest message in view while a response streams and lets go as soon as the reader scrolls up. Copilot is the one chat surface in Oration, so Thread belongs in the Copilot panel, the web-call chat tab and the two approved exceptions (the Knowledge base query and the Contact Center assist panel), not in a new panel on every page. The common mistake is giving it no bounded height: it needs a fixed-height or flex-1 min-h-0 parent to scroll.
When to use
- For the Copilot panel: the conversation Maya Okafor has with Copilot across the CRM, with the composer pinned below.
- For a test chat with an agent, such as the chat tab of a web call with Nora.
- For the approved scoped assistants: the Knowledge base AI query and the Contact Center assist panel.
- When a response streams in and the view should follow it until the reader scrolls away.
- For the empty state of a conversation: a greeting, one line on what Copilot can see and three or four suggested prompts.
When not to use
- To add a chat box to a record page or settings page. Open Copilot with the record attached instead. Use AI assistance
- For a live call transcript, where turns come from two speakers and nobody types. Use Live transcript
- For a one-shot summary on a list or detail page. Stream it in place with its sources. Use Streaming text
- For a scrolling list that only needs to stick to its bottom, without messages or suggestions. Use Message scroller
- For a ticket's conversation with a customer, which is a record timeline, not a chat with Copilot. Use Timeline
One chat surface
The One Filled Button Rule
The Quiet Indigo Rule
Anatomy#
- Thread. The root column. It owns the stick-to-bottom state and
isRunning, and setsdata-runningwhile a response streams. Give it a bounded height. - Viewport. The scrolling region, with a stable scrollbar gutter and contained overscroll. Its inner column caps at
max-w-3xland pads 16px; panels override that withcontentClassName. - Messages. A
role="log"list named Conversation, 24px between turns, marked busy while a response streams. - Scroll to latest. A round 28px outline button that springs in 12px above the bottom edge once the reader scrolls more than 48px up.
- Composer slot. Whatever follows the viewport inside Thread, usually a
PromptBarin ashrink-0wrapper with 12px padding.
Examples#
Welcome and suggestions
An empty thread greets with what Copilot can see and three prompts built from the page. Picking one sends it.
import { AssistantMessage, UserMessage } from "@oration/canon/components/ai/message";import { Thread, ThreadMessages, ThreadViewport, ThreadWelcome } from "@oration/canon/components/ai/thread";import { Button } from "@oration/canon/components/button";import { FileClockIcon, FileWarningIcon, PhoneIncomingIcon, RotateCcwIcon, SparklesIcon,} from "lucide-react";import * as React from "react";export function Welcome() { const [prompt, setPrompt] = React.useState<string | null>(null); return ( <div className="flex w-full max-w-xl flex-col gap-2"> <div className="flex h-96 flex-col rounded-xl bg-card text-left shadow-border"> <Thread className="min-h-0 flex-1"> <ThreadViewport contentClassName="max-w-none"> {prompt ? ( <ThreadMessages> <UserMessage>{prompt}</UserMessage> <AssistantMessage status="streaming"> <span className="text-muted-foreground text-shimmer"> Reading Northwind Freight's invoices </span> </AssistantMessage> </ThreadMessages> ) : ( <ThreadWelcome icon={ <span className="flex size-8 items-center justify-center rounded-lg bg-muted text-muted-foreground"> <SparklesIcon aria-hidden="true" className="size-4" /> </span> } title="What can I help with?" description={ <> I can see{" "} <span className="font-medium text-foreground"> Northwind Freight </span> . Ask about it, or type @ to bring in another supplier, invoice or ticket, and / for commands. </> } suggestions={[ { label: "Summarize open invoices", prompt: "Summarize Northwind Freight's open invoices", description: "Amounts, due dates and holds", icon: ( <FileClockIcon aria-hidden="true" /> ), }, { label: "Why is INV-20931 on hold?", prompt: "Why is INV-20931 on hold?", description: "Checks approvals and the vendor record", icon: ( <FileWarningIcon aria-hidden="true" /> ), }, { label: "What did Nora tell them last?", prompt: "What did Nora tell Northwind Freight on the last call?", description: "From the call on Friday, Sep 25", icon: ( <PhoneIncomingIcon aria-hidden="true" /> ), }, ]} suggestionsLayout="list" onSelect={(next) => setPrompt(next)} /> )} </ThreadViewport> </Thread> </div> {prompt ? ( <Button type="button" variant="ghost" size="sm" className="self-start" onClick={() => setPrompt(null)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Show the welcome again </Button> ) : null} </div> );}Suggestion layouts
Chips wrap and suit follow-ups under an answer. The list layout adds an icon and a description per prompt, for the welcome.
import { ThreadSuggestions } from "@oration/canon/components/ai/thread";import { toast } from "@oration/canon/components/toast";import { FileWarningIcon, PhoneIncomingIcon } from "lucide-react";export function SuggestionLayouts() { const pick = (prompt: string) => toast.add({ title: "Prompt sent", description: prompt }); return ( <div className="grid w-full max-w-3xl gap-8 text-left sm:grid-cols-2"> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground"> Chips, as follow-ups under an answer </span> <ThreadSuggestions suggestions={[ { label: "Draft the reply", prompt: "Draft the reply to Halcyon", }, { label: "Show the payment run", prompt: "Show Friday's payment run", }, { label: "Compare to last month", prompt: "Compare short payments to August", }, ]} onSelect={pick} /> </div> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground"> List, on the welcome </span> <ThreadSuggestions layout="list" suggestions={[ { label: "Which W-9s are missing?", prompt: "Which suppliers are missing a W-9?", description: "Suppliers in Friday's run first", icon: <FileWarningIcon aria-hidden="true" />, }, { label: "Summarize this week's calls", prompt: "Summarize Nora's supplier calls this week", description: "Top reasons and repeat callers", icon: <PhoneIncomingIcon aria-hidden="true" />, }, ]} onSelect={pick} /> </div> </div> );}Stick to bottom while streaming
The viewport follows the answer as it grows. Scroll up mid-stream and it lets go, and the scroll-to-latest button appears.
import { AssistantMessage, UserMessage } from "@oration/canon/components/ai/message";import { Thread, ThreadMessages, ThreadViewport } from "@oration/canon/components/ai/thread";import { Button } from "@oration/canon/components/button";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function StickToBottom() { const reply = "Halcyon's short payment on INV-20877 is the early-payment discount. Their terms are 2/10 net 30, Cedarline paid on day 8, and the payment run took 2% off: $760.00 of the $19,000.00 invoice. Aisha Bello called twice because the remittance advice listed the net amount without the discount line. I can resend the remittance with the discount itemized, add a note to the supplier record so Nora explains it on the next call, and open a ticket for Jordan Lee to fix the remittance template so other suppliers on discount terms see the same line."; const words = reply.split(" "); const [count, setCount] = React.useState(0); const [running, setRunning] = React.useState(true); React.useEffect(() => { if (!running) return; if (count >= words.length) { setRunning(false); return; } const id = window.setTimeout(() => setCount((n) => n + 1), 60); return () => window.clearTimeout(id); }, [running, count, words.length]); return ( <div className="flex w-full max-w-xl flex-col gap-2"> <div className="flex h-72 flex-col rounded-xl bg-card text-left shadow-border"> <Thread isRunning={running} className="min-h-0 flex-1"> <ThreadViewport contentClassName="max-w-none"> <ThreadMessages> <UserMessage> Who called about Halcyon today? </UserMessage> <AssistantMessage> Aisha Bello called Nora at 9:12 AM and again at 11:40 AM, both about INV-20877. </AssistantMessage> <UserMessage>Why was it paid short?</UserMessage> <AssistantMessage status={running ? "streaming" : "done"} > {words.slice(0, count).join(" ")} </AssistantMessage> </ThreadMessages> </ThreadViewport> </Thread> </div> <Button type="button" variant="outline" size="sm" className="self-start" disabled={running} onClick={() => { setCount(0); setRunning(true); }} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Stream the answer again </Button> </div> );}Loading
ChatPanelSkeleton matches the panel's final layout, two turns and the composer, while a conversation loads.
import { ChatPanelSkeleton } from "@oration/canon/components/ai/thread";export function Loading() { return ( <div className="h-80 w-full max-w-md rounded-xl bg-card shadow-border"> <ChatPanelSkeleton /> </div> );}States#
import { AssistantMessage, UserMessage } from "@oration/canon/components/ai/message";import { ChatPanelSkeleton, Thread, ThreadMessages, ThreadViewport, ThreadWelcome,} from "@oration/canon/components/ai/thread";export function StatesPreview() { return ( <div className="grid w-full gap-4 text-left md:grid-cols-3"> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Loading</span> <div className="h-64 rounded-xl bg-card shadow-border"> <ChatPanelSkeleton /> </div> </div> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Empty</span> <div className="flex h-64 flex-col rounded-xl bg-card shadow-border"> <Thread className="min-h-0 flex-1"> <ThreadViewport contentClassName="max-w-none"> <ThreadWelcome title="What can I help with?" description="Ask about suppliers, invoices or Nora's calls." /> </ThreadViewport> </Thread> </div> </div> <div className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground">Running</span> <div className="flex h-64 flex-col rounded-xl bg-card shadow-border"> <Thread isRunning className="min-h-0 flex-1"> <ThreadViewport contentClassName="max-w-none"> <ThreadMessages> <UserMessage> When does Orchard Street get paid? </UserMessage> <AssistantMessage status="streaming"> OS-4471 was paid by ACH on Friday. The trace number is </AssistantMessage> </ThreadMessages> </ThreadViewport> </Thread> </div> </div> </div> );}| State | Treatment |
|---|---|
| Loading | ChatPanelSkeleton stands in for the panel: two user bubbles, an assistant turn and the composer, with a screen-reader Loading conversation. |
| Empty | ThreadWelcome centers vertically in the viewport with an optional icon, a 16px title, a 13px description capped at 42ch and suggestions. |
| Conversation | Messages stack 24px apart. The viewport is pinned to the bottom on mount. |
| Running | With isRunning, the root gets data-running and the message log gets aria-busy, so screen readers wait for the finished answer. The viewport keeps following the growing text. |
| Scrolled up | Following stops the moment the reader scrolls up, and the scroll-to-latest button appears. Scrolling back within 48px of the bottom, or pressing the button, resumes following. |
| Suggestion hover and focus | A Well Gray highlight glides between suggestions with the pointer (fluid hover). Keyboard focus draws a 3px Focus Indigo ring at 40%. |
Behavior#
- Stick to bottom is a
ResizeObserveron the content and the viewport: while pinned, any growth setsscrollTopto the bottom. An upward scroll of more than 1px unpins it; coming back within 48px re-pins it. autoScroll={false}turns following off, for a thread opened at an older message. The scroll-to-latest button still works.- The scroll-to-latest button scrolls smoothly, or jumps under reduced motion. It enters on
spring.moderate(160ms, no bounce) from 8px below at 0.96 scale and leaves in 120ms. ThreadViewportrendersThreadScrollToBottomfor you. PassscrollButton={false}to drop it, or renderThreadScrollToBottomyourself inside a positioned ancestor.ThreadWelcomeonly renders suggestions when bothsuggestionsandonSelectare passed. Extra content, such as recent chats, goes inchildrenbelow them.ThreadSuggestionscallsonSelect(prompt, suggestion). Copilot sends the prompt straight away; use the same prompt as the label unless the label needs to be shorter.- Every part reads context from
Threadand throws if rendered outside it.useThread()exposesisAtBottom,scrollToBottom()andisRunningto custom parts.
Do and don't#
h-[30rem], or flex-1 min-h-0 in a panel) so the viewport scrolls and the composer stays put.Content#
- Welcome title: a short question, What can I help with? The default is How can I help?
- Welcome description: one sentence on what Copilot can see right now and how to add more, such as I can see Northwind Freight. Type @ to add another record, and / for commands.
- Suggestions: three or four, phrased as the person would type them, specific to the page: Summarize Northwind Freight's open invoices, not Summarize.
- In the list layout, a suggestion's description says what it will produce: A table of invoices past terms.
- Follow-up chips after an answer stay under six words: Draft the reply, Show the payment run.
Accessibility#
- The message list is a
role="log"named Conversation. New finished messages are announced politely; whileisRunningis true the log isaria-busy, so fragments of a streaming answer aren't read out. - Suggestions are real buttons in a group named Suggested prompts, reachable with Tab.
- The scroll-to-latest button is named Scroll to latest message. It is icon-only and has no tooltip today (see known gaps).
- The viewport isn't focusable on its own; keyboard users reach messages through their action buttons, and the composer keeps focus while they type.
- Under reduced motion the scroll-to-latest jump is instant and the button's translate and scale are dropped by the app's motion config.
ChatPanelSkeletonisaria-hiddenapart from arole="status"Loading conversation.
| Keys | Action |
|---|---|
| Tab | Moves through suggestions, message actions, the scroll-to-latest button and the composer. |
| Enter | Sends the focused suggestion. |
| Space | Sends the focused suggestion. |
Design tokens#
| Token | Used for |
|---|---|
--card | The panel surface the thread sits on |
--muted | Suggestion hover highlight, skeleton fill |
--border | The 1px inset ring on chip suggestions |
--muted-foreground | Welcome description, suggestion icons and descriptions |
--popover | Scroll-to-latest button fill |
shadow-popover | Scroll-to-latest button lift |
--ring | Suggestion focus ring at 40% |
--radius-lg | Suggestion corners |
spring.moderate | Scroll-to-latest enter |
exit.moderate | Scroll-to-latest exit, 120ms |
API reference#
Thread
The root. Provides the stick-to-bottom state to every part. Also exported: useThread() and useStickToBottom({ enabled, threshold }).
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | React.ReactNode | No default | Usually a ThreadViewport and the composer. |
isRunning | boolean | false | A response is streaming. Sets data-running and aria-busy on the log. |
autoScroll | boolean | true | Follow new content while the reader is at the bottom. |
className | string | No default | Merged onto the root, which is relative flex h-full min-h-0 flex-col. |
ThreadViewport
The scrolling region.
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | React.ReactNode | No default | ThreadMessages or ThreadWelcome. |
scrollButton | boolean | true | Render the scroll-to-latest button. |
className | string | No default | Merged onto the outer wrapper. |
contentClassName | string | No default | Merged onto the inner column (max-w-3xl px-4 py-4 gap-6). Panels pass max-w-none. |
ThreadMessages
The role="log" message list.
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | React.ReactNode | No default | UserMessage and AssistantMessage turns. |
className | string | No default | Merged onto the log. |
ThreadWelcome
The empty-thread greeting.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | "How can I help?" | Rendered as an h2. |
description | React.ReactNode | No default | One sentence on what Copilot can see. |
icon | React.ReactNode | No default | Shown above the title in Slate Meta. |
suggestions | ThreadSuggestion[] | No default | Rendered only together with onSelect. |
onSelect | (prompt: string, suggestion: ThreadSuggestion) => void | No default | Called when a suggestion is picked. |
suggestionsLayout | "chips" | "list" | "chips" | Passed to ThreadSuggestions. |
children | React.ReactNode | No default | Below the suggestions, e.g. recent chats. |
className | string | No default | Merged onto the root. |
ThreadSuggestions
Suggested prompts with a fluid hover highlight. Works outside ThreadWelcome, e.g. as follow-ups under an answer.
| Prop | Type | Default | Description |
|---|---|---|---|
suggestionsRequired | { label: string; prompt: string; icon?: React.ReactNode; description?: string }[] | No default | The ThreadSuggestion type. prompt is also the React key, so keep prompts unique. description shows in the list layout only. |
onSelectRequired | (prompt: string, suggestion: ThreadSuggestion) => void | No default | Called with the prompt and the whole suggestion. |
layout | "chips" | "list" | "chips" | Chips are 32px outlined pills that wrap; list rows are at least 36px and stack. |
className | string | No default | Merged onto the group. |
ThreadScrollToBottom
The scroll-to-latest button. ThreadViewport renders it already.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged onto the absolutely positioned wrapper (bottom-3 left-1/2). |
ChatPanelSkeleton
A loading stand-in for a whole chat panel.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged onto the root. |
useThread
Reads the thread context. Throws outside Thread.
| Prop | Type | Default | Description |
|---|---|---|---|
returns | { scrollRef; contentRef; isAtBottom: boolean; scrollToBottom: (behavior?: ScrollBehavior) => void; isRunning: boolean } | No default | The refs belong to the viewport; don't reattach them. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
ChatPanelSkeleton isn't used anywhere in the app. The Copilot panel opens without a loading state.
The scroll-to-latest button is icon-only with an aria-label but no tooltip, unlike every other icon button in the suite.
ThreadWelcome always renders its title as an h2, with no way to change the level for panels whose own heading is an h2.
There are three stick-to-bottom implementations: this one (48px threshold), Live transcript's (24px) and Message scroller. They behave slightly differently when you scroll back down.
ThreadViewport centers its content at max-w-3xl by default, which suits a full page; every product call site overrides it with max-w-none.