Skip to content

Thread

The Copilot conversation: welcome, suggestions, messages and scroll to bottom.

Status
Beta
Category
AI
Adoption
Not used yet
import { Thread } from "@oration/canon/components/ai/thread";
packages/canon/src/components/ai/thread.tsx

Copilot

Is Northwind Freight's invoice INV-20931 going out this week?
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.

Enter sends, Shift+Enter adds a new line.

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

Copilot is reachable everywhere and is the only chat panel. Pages hand it context (a record, a filter, a selection) instead of growing their own. The Knowledge base query and the Contact Center assist panel are the two exceptions.

The One Filled Button Rule

Send, in the prompt bar, is the thread's one filled button. Suggestions are outlined chips or list rows, message actions are ghost icons, and an approval card below a message is quiet unless it's the latest proposal.

The Quiet Indigo Rule

Nothing in the thread is indigo except Send and the focus ring. The assistant mark is a neutral Well Gray tile and the user's bubble is Well Gray, not a colored bubble.

Anatomy#

Who's in Friday's payment run?
212 invoices to 48 suppliers, $1,284,310.00 in total, going out Friday, October 2 at 2:00 PM CT.
Which W-9s are still missing?
Four suppliers have no W-9 on file: Orchard Street, Wen Zhou Consulting, Halcyon and Northwind Freight's new remit-to entity. Orchard Street has an invoice in Friday's payment run.
Send Orchard Street a reminder.
I drafted the reminder to their AP inbox and attached the blank W-9. Approve it below and it goes out now.
  1. Thread. The root column. It owns the stick-to-bottom state and isRunning, and sets data-running while a response streams. Give it a bounded height.
  2. Viewport. The scrolling region, with a stable scrollbar gutter and contained overscroll. Its inner column caps at max-w-3xl and pads 16px; panels override that with contentClassName.
  3. Messages. A role="log" list named Conversation, 24px between turns, marked busy while a response streams.
  4. Scroll to latest. A round 28px outline button that springs in 12px above the bottom edge once the reader scrolls more than 48px up.
  5. Composer slot. Whatever follows the viewport inside Thread, usually a PromptBar in a shrink-0 wrapper 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.

What can I help with?

I can see Northwind Freight. Ask about it, or type @ to bring in another supplier, invoice or ticket, and / for commands.

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.

Chips, as follow-ups under an answer
List, on 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.

Who called about Halcyon today?
Aisha Bello called Nora at 9:12 AM and again at 11:40 AM, both about INV-20877.
Why was it paid short?
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.

Loading conversation
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#

Loading
Loading conversation
Empty

What can I help with?

Ask about suppliers, invoices or Nora's calls.

Running
When does Orchard Street get paid?
OS-4471 was paid by ACH on Friday. The trace number is
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>    );}
States
StateTreatment
LoadingChatPanelSkeleton stands in for the panel: two user bubbles, an assistant turn and the composer, with a screen-reader Loading conversation.
EmptyThreadWelcome centers vertically in the viewport with an optional icon, a 16px title, a 13px description capped at 42ch and suggestions.
ConversationMessages stack 24px apart. The viewport is pinned to the bottom on mount.
RunningWith 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 upFollowing 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 focusA 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 ResizeObserver on the content and the viewport: while pinned, any growth sets scrollTop to 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.
  • ThreadViewport renders ThreadScrollToBottom for you. Pass scrollButton={false} to drop it, or render ThreadScrollToBottom yourself inside a positioned ancestor.
  • ThreadWelcome only renders suggestions when both suggestions and onSelect are passed. Extra content, such as recent chats, goes in children below them.
  • ThreadSuggestions calls onSelect(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 Thread and throws if rendered outside it. useThread() exposes isAtBottom, scrollToBottom() and isRunning to custom parts.

Do and don't#

Do. Suggest prompts that use what Copilot can see: Why is INV-20931 on hold? Draft a remittance reply to Halcyon.
Don't. Offer generic starters such as Ask me anything or What can you do? They teach nothing about the page.
Northwind Freight4 open invoices, $61,880.00
Do. Send people to Copilot with the record attached: a quiet Ask Copilot action on the supplier header.
Chat about Northwind Freight

Enter sends, Shift+Enter adds a new line.

Don't. Embed a second chat box in the page. Two chat surfaces split the history and the approvals.
Do. Give the thread a bounded parent (h-[30rem], or flex-1 min-h-0 in a panel) so the viewport scrolls and the composer stays put.
Don't. Drop it into a page section with no height. The viewport never scrolls, stick to bottom does nothing and the composer slides off screen.

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; while isRunning is true the log is aria-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.
  • ChatPanelSkeleton is aria-hidden apart from a role="status" Loading conversation.
Keyboard interactions
KeysAction
TabMoves through suggestions, message actions, the scroll-to-latest button and the composer.
EnterSends the focused suggestion.
SpaceSends the focused suggestion.

Design tokens#

Design tokens
TokenUsed for
--cardThe panel surface the thread sits on
--mutedSuggestion hover highlight, skeleton fill
--borderThe 1px inset ring on chip suggestions
--muted-foregroundWelcome description, suggestion icons and descriptions
--popoverScroll-to-latest button fill
shadow-popoverScroll-to-latest button lift
--ringSuggestion focus ring at 40%
--radius-lgSuggestion corners
spring.moderateScroll-to-latest enter
exit.moderateScroll-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..

Props of Thread
PropTypeDefaultDescription
childrenRequiredReact.ReactNodeNo defaultUsually a ThreadViewport and the composer.
isRunningbooleanfalseA response is streaming. Sets data-running and aria-busy on the log.
autoScrollbooleantrueFollow new content while the reader is at the bottom.
classNamestringNo defaultMerged onto the root, which is relative flex h-full min-h-0 flex-col.

ThreadViewport

The scrolling region.

Props of ThreadViewport
PropTypeDefaultDescription
childrenRequiredReact.ReactNodeNo defaultThreadMessages or ThreadWelcome.
scrollButtonbooleantrueRender the scroll-to-latest button.
classNamestringNo defaultMerged onto the outer wrapper.
contentClassNamestringNo defaultMerged onto the inner column (max-w-3xl px-4 py-4 gap-6). Panels pass max-w-none.

ThreadMessages

The role="log" message list.

Props of ThreadMessages
PropTypeDefaultDescription
childrenRequiredReact.ReactNodeNo defaultUserMessage and AssistantMessage turns.
classNamestringNo defaultMerged onto the log.

ThreadWelcome

The empty-thread greeting.

Props of ThreadWelcome
PropTypeDefaultDescription
titlestring"How can I help?"Rendered as an h2.
descriptionReact.ReactNodeNo defaultOne sentence on what Copilot can see.
iconReact.ReactNodeNo defaultShown above the title in Slate Meta.
suggestionsThreadSuggestion[]No defaultRendered only together with onSelect.
onSelect(prompt: string, suggestion: ThreadSuggestion) => voidNo defaultCalled when a suggestion is picked.
suggestionsLayout"chips" | "list""chips"Passed to ThreadSuggestions.
childrenReact.ReactNodeNo defaultBelow the suggestions, e.g. recent chats.
classNamestringNo defaultMerged onto the root.

ThreadSuggestions

Suggested prompts with a fluid hover highlight. Works outside ThreadWelcome, e.g. as follow-ups under an answer.

Props of ThreadSuggestions
PropTypeDefaultDescription
suggestionsRequired{ label: string; prompt: string; icon?: React.ReactNode; description?: string }[]No defaultThe ThreadSuggestion type. prompt is also the React key, so keep prompts unique. description shows in the list layout only.
onSelectRequired(prompt: string, suggestion: ThreadSuggestion) => voidNo defaultCalled 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.
classNamestringNo defaultMerged onto the group.

ThreadScrollToBottom

The scroll-to-latest button. ThreadViewport renders it already.

Props of ThreadScrollToBottom
PropTypeDefaultDescription
classNamestringNo defaultMerged onto the absolutely positioned wrapper (bottom-3 left-1/2).

ChatPanelSkeleton

A loading stand-in for a whole chat panel.

Props of ChatPanelSkeleton
PropTypeDefaultDescription
classNamestringNo defaultMerged onto the root.

useThread

Reads the thread context. Throws outside Thread.

Props of useThread
PropTypeDefaultDescription
returns{ scrollRef; contentRef; isAtBottom: boolean; scrollToBottom: (behavior?: ScrollBehavior) => void; isRunning: boolean }No defaultThe 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.