Skip to content
View as Markdown

AIChat

AIChat owns the frame for an assistant UI and places its three regions in a fixed order, the way Layout places Layout.Header and Layout.Column: AIChat.Header (optional) over AIChat.Conversation (required) over AIChat.Composer (optional). Each region carries its own props; the root carries only the chat's status. You compose the transcript inside AIChat.Conversation from the attached parts — AIChat.Message, AIChat.Response, AIChat.Reasoning, AIChat.Tool, AIChat.Sources, and more — against your own useAIChat() state.

It renders no border or background of its own. Wrap it in Card, Sheet, or a Layout.Column for the surrounding surface.

Not a mirror of the catalogue pattern. AIChat was migrated from the UI Catalogue's AI chat pattern, which is a design reference built on its own primitives — not a preview of this component. The API differs, and several behaviours were changed deliberately to fit AppShell (see Prop pass-through and the notes throughout). Read that page for the intent; read this one for the contract.

Import

tsx
import { AIChat, useAIChat, createAIGatewayClient } from "@tailor-platform/app-shell";

Basic usage

tsx
function Assistant() {
  const client = useMemo(() => createAIGatewayClient({ gatewayUri, authClient }), [authClient]);
  const { messages, status, sendMessage, stop } = useAIChat({ client, model: "gpt-5" });

  return (
    // `overflow-hidden` keeps the header rule inside the card's rounded corners.
    <Card.Root className="astw:flex astw:h-full astw:flex-col astw:overflow-hidden">
      <AIChat status={status}>
        <AIChat.Header title="Assistant" />
        <AIChat.Conversation>
          {messages.length === 0 ? (
            <AIChat.EmptyState title="Ask the assistant" />
          ) : (
            messages.map((message) => (
              <AIChat.Message key={message.id} from={message.role}>
                <AIChat.Response>{message.content}</AIChat.Response>
              </AIChat.Message>
            ))
          )}
        </AIChat.Conversation>
        <AIChat.Composer onSubmit={sendMessage} onStop={stop} />
      </AIChat>
    </Card.Root>
  );
}

message.role ("user" | "assistant") from useAIChat lines up directly with AIChat.Message's from prop — no mapping step. AIChat streams for real when the AI Gateway sends real SSE chunks; there is no client-side typewriter effect.

The regions can appear in any source order — the root always renders Header, then Conversation, then Composer. Omit AIChat.Composer for a read-only transcript.

They must be direct children. The root matches them by component identity, so a region wrapped in one of your own components — const AppComposer = () => <AIChat.Composer … /> — is not recognised, and is dropped with a console warning like any other unexpected child. To preset props, export the props instead of the element: <AIChat.Composer {...appComposerProps} />.

Props

AIChat

PropTypeDefaultDescription
childrenReactNoderequiredAIChat.Header, AIChat.Conversation, and AIChat.Composer, in any order.
status"ready" | "submitted" | "streaming" | "error""ready"The chat's state. Plugs directly into useAIChat()'s status; AIChat.Composer reads it.
classNamestring-Merged onto the root. It already fills a block parent (h-full) or a flex-column parent (flex-1).

Other props spread onto the root <div>.

AIChat.Header

PropTypeDefaultDescription
titleReactNode-Title text.
iconReactNodesparkleLeading graphic. Pass null for none, or a control (e.g. a collapse button) for a docked panel.
actionsReactNode-Right-aligned slot — compose it from AIChat.Action.
classNamestring-

AIChat.Conversation

PropTypeDefaultDescription
childrenReactNoderequiredThe transcript. Composed from the attached parts, or AIChat.EmptyState while empty.
autoScrollbooleantrueFollow content growth while the reader is pinned to the bottom.
classNamestring-

Other props spread onto the region's outer <div>.

AIChat.Composer

PropTypeDefaultDescription
onSubmit(message: string) => voidrequiredCalled with the trimmed prompt.
onStop() => void-Called from the Stop button while the chat is busy. Omit for a plain busy state.
valuestring-Controlled draft. Cleared via onValueChange("") after a successful submit.
defaultValuestring""Uncontrolled draft's initial value.
onValueChange(value: string) => void-Called on every draft change, including the post-submit clear.
placeholderstring-
disabledbooleanfalseDisables the composer — not the transcript above it.
submitOnEnterbooleantrueEnter submits (IME-safe); Shift+Enter always inserts a newline.
actionsReactNode-Open slot on the action row — a visibility toggle, a model picker, a select.
classNamestring-

Prop pass-through

Most props stay inside their region. These AIChat.Composer props land on another AppShell component, so they carry that component's contract — and any future change to it — rather than one AIChat defines:

PropReachesNote
valueTextarea valueThe composer body is a real Textarea.
placeholderTextarea placeholder
disabledTextarea and the attach ButtonOne prop, two components.
onStopButton onClickBecomes the Stop button's handler.

onValueChange is wrapped rather than forwarded — it is called from the Textarea's onChange, and again with "" after a submit. defaultValue seeds the composer's own state and is never forwarded. Nothing on AIChat, AIChat.Header, or AIChat.Conversation reaches another AppShell component.

Three attached parts wrap an AppShell component. They expose only the props the part's job needs rather than inheriting the wrapped component's surface, so its visual treatment is fixed and not yours to change:

PartWrapsAcceptsFixed
AIChat.SuggestionButtonsuggestion, onSelect, className, disabled, childrenvariant="secondary", size="sm"
AIChat.ActionButton inside a Tooltiplabel, onClick, className, disabled, childrenvariant="ghost", size="icon"
AIChat.ChainOfThoughtSearchResultBadgeclassName, children, variant

variant stays open on ChainOfThoughtSearchResult because conveying status is what a Badge is for. AIChat.Action's label becomes both the accessible name and the tooltip text.

One thing to know about className on these: it is merged with cn(), which is not configured for the astw: prefix, so it cannot reliably override a base utility from the wrapped component. If an override appears to do nothing, that is why.

Filling the page

AIChat fills whatever height its parent gives it — h-full for a block parent, flex-1 min-h-0 for a flex column — and never grows the page: the transcript scrolls internally while the header and composer stay pinned. Give the surface around it a definite height.

Inside a <Layout fill> column (the column is flex flex-col, so the card takes the leftover space):

tsx
<Layout fill>
  <Layout.Header title="Assistant" />
  <Layout.Column>
    <Card.Root className="astw:flex astw:min-h-0 astw:flex-1 astw:flex-col astw:overflow-hidden">
      <AIChat status={status}>
        <AIChat.Header title="Assistant" />
        <AIChat.Conversation>{/* … */}</AIChat.Conversation>
        <AIChat.Composer onSubmit={sendMessage} />
      </AIChat>
    </Card.Root>
  </Layout.Column>
</Layout>

min-h-0 is what lets the card shrink below its content's natural height so the transcript scrolls instead of pushing the composer off-screen. In a docked panel or Sheet whose height is already fixed, astw:h-full on the card is enough.

A 48px strip above the transcript: leading graphic, title, and an open action slot on the right, closed by a rule that runs the full width of the surface. Omit it for a bare transcript-and-composer surface, and give the card overflow-hidden so the rule stays inside its rounded corners.

tsx
<AIChat.Header
  title="Assistant"
  actions={
    <>
      <AIChat.Action label="Conversation history" onClick={openHistory}>
        <History className="astw:size-3.5" aria-hidden />
      </AIChat.Action>
      <AIChat.Action label="Clear conversation" onClick={clear}>
        <Eraser className="astw:size-3.5" aria-hidden />
      </AIChat.Action>
    </>
  }
/>

For a docked right panel, put the collapse control in icon so it takes the leading position:

tsx
<AIChat.Header
  title="Assistant"
  icon={
    <AIChat.Action label="Collapse panel" onClick={onClose}>
      <ChevronsRight className="astw:size-3.5" aria-hidden />
    </AIChat.Action>
  }
/>

Attached parts

Message

tsx
<AIChat.Message from="assistant">
  <AIChat.Response>{message.content}</AIChat.Response>
</AIChat.Message>

User turns render as a right-aligned primary bubble; assistant turns take the full width with no bubble, so reasoning, tool calls, and citations stack inside the same column above the response text.

Response

Renders the markdown subset a streamed LLM response actually emits — bold, inline code, links, headings, bullet/numbered lists — without a markdown dependency. Only http(s):, mailto:, and same-site relative links render as clickable; any other target renders as plain text, since the text passing through here is untrusted model output. For full CommonMark, swap <AIChat.Response> for react-markdown or the AI SDK's streamdown — call sites keep the same shape.

tsx
<AIChat.Response>{message.content}</AIChat.Response>

EmptyState / Suggestions

AIChat.EmptyState stacks icon, then the title/description block, then children — so starter suggestions sit alongside the text rather than replacing it:

tsx
<AIChat.EmptyState
  icon={<Sparkles className="astw:size-6 astw:text-primary" aria-hidden />}
  title="Ask the assistant"
  description="Grounded in your help articles."
>
  <AIChat.Suggestions>
    <AIChat.Suggestion suggestion="How do I create a purchase order?" onSelect={sendMessage} />
  </AIChat.Suggestions>
</AIChat.EmptyState>

AIChat.Suggestion submits its suggestion text as if the reader had typed and sent it via onSelect.

Actions

Icon-button row under a finished assistant turn — copy, retry, feedback. Render it only once the turn has finished so streaming turns stay uncluttered.

tsx
<AIChat.Actions>
  <AIChat.Action label="Copy" onClick={() => navigator.clipboard.writeText(message.content)}>
    <Copy className="astw:size-3.5" aria-hidden />
  </AIChat.Action>
</AIChat.Actions>

Reasoning

Collapsible "thinking" block for reasoning models. Auto-opens while isStreaming is true and auto-closes when it turns false, unless the reader has manually toggled it.

tsx
<AIChat.Reasoning isStreaming={reasoning.streaming} duration={reasoning.duration}>
  <AIChat.ReasoningTrigger />
  <AIChat.ReasoningContent>{reasoning.text}</AIChat.ReasoningContent>
</AIChat.Reasoning>

ChainOfThought

Structured step timeline for agentic turns — the discrete plan being executed, each step with a status and optional result chips. Use Reasoning for free-form thinking text and this for multi-step tool plans.

tsx
<AIChat.ChainOfThought defaultOpen>
  <AIChat.ChainOfThoughtHeader />
  <AIChat.ChainOfThoughtContent>
    <AIChat.ChainOfThoughtStep label="Searching the knowledge base" status="complete">
      <AIChat.ChainOfThoughtSearchResults>
        <AIChat.ChainOfThoughtSearchResult>12 hits</AIChat.ChainOfThoughtSearchResult>
      </AIChat.ChainOfThoughtSearchResults>
    </AIChat.ChainOfThoughtStep>
    <AIChat.ChainOfThoughtStep label="Drafting the answer" status="active" />
  </AIChat.ChainOfThoughtContent>
</AIChat.ChainOfThought>

status is "complete" | "active" | "pending".

Tool

Collapsible tool-call card. Maps 1:1 to the AI SDK's tool-part lifecycle, so a streaming loop can update a call in place as parts arrive.

tsx
<AIChat.Tool>
  <AIChat.ToolHeader toolName="search_kb" state={call.state} />
  <AIChat.ToolContent>
    <AIChat.ToolInput input={call.input} />
    <AIChat.ToolOutput output={call.output} errorText={call.errorText} />
  </AIChat.ToolContent>
</AIChat.Tool>

state is "input-streaming" | "input-available" | "output-available" | "output-error".

Sources

Compact "Used N sources" citation trigger under a grounded answer.

tsx
<AIChat.Sources>
  <AIChat.SourcesTrigger count={sources.length} />
  <AIChat.SourcesContent>
    {sources.map((source) => (
      <AIChat.Source key={source.id} title={source.title} onClick={() => openDocument(source.id)} />
    ))}
  </AIChat.SourcesContent>
</AIChat.Sources>

History

Grouped list of past conversations for reopening an earlier chat. Layout-agnostic — render it in a popover behind a header action, or as a sidebar section of a full chat page. Group titles and date bucketing are yours; AIChat.History has no opinion on date ranges.

tsx
<AIChat.History
  groups={[{ title: "Today", items: [{ id: "1", title: "Creating a purchase order" }] }]}
  activeId={activeId}
  onSelect={loadConversation}
  onDelete={deleteConversation}
/>