Elements

Elements · Thread

Canvas

The thread steps aside and the document takes the room, still being written as you read.

Draft the migration note for 0.14
Opened it in the canvas. Editing now.
migration-0.14.mdv3editing
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-canvas-split"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A canvas split sets a document beside the thread instead of inside it: the conversation narrows to a rail on one side, the document takes the rest of the room, and it keeps growing while you read. assistant-ui has no dedicated canvas or artifact primitive, so with a runtime this is composed from the same tool-call rendering every structured answer uses; standalone you hand the document's text and state to the pieces directly.

Getting started

Render the document from a tool call

A tool whose result is the document, its arguments streaming in as the model writes it, is what the still-being-written half of this pattern is built on. Register a renderer for it through MessagePrimitive.Parts:

components/assistant-ui/elements/thread.aui.tsx
"use client";

import { MessagePrimitive, useToolArgsStatus, type ToolCallMessagePartProps } from "@assistant-ui/react";
import { CanvasSplitDocument, CanvasSplitHeader, CanvasSplitBody, CanvasSplitLine } from "./canvas-split";

type DocArgs = { title: string; content: string };

function DocumentTool({ args, status }: ToolCallMessagePartProps<DocArgs>) {
  const { propStatus } = useToolArgsStatus<DocArgs>();
  return (
    <CanvasSplitDocument>
      <CanvasSplitHeader
        title={args.title ?? "Untitled"}
        version={1}
        saved={status.type === "complete"}
      />
      <CanvasSplitBody writing={propStatus.content === "streaming"}>
        <CanvasSplitLine>{args.content}</CanvasSplitLine>
      </CanvasSplitBody>
    </CanvasSplitDocument>
  );
}

<MessagePrimitive.Parts components={{ tools: { by_name: { write_document: DocumentTool } } }} />

args is a partial parse while the model is still streaming its call, fields can be missing or incomplete, so useToolArgsStatus is what tells you content specifically is still arriving rather than just checking the call's overall status.

Give the thread its own lane

The conversation half is an ordinary, narrower thread: the same ThreadPrimitive.Viewport and .Messages, styled with CanvasSplitThread and CanvasSplitMessage instead of a full-width layout:

<CanvasSplit>
  <ThreadPrimitive.Viewport asChild>
    <CanvasSplitThread>
      <ThreadPrimitive.Messages>
        {({ message }) => (
          <CanvasSplitMessage speaker={message.role === "user" ? "user" : "assistant"}>
            <MessagePrimitive.Parts components={{ tools: { by_name: { write_document: DocumentTool } } }} />
          </CanvasSplitMessage>
        )}
      </ThreadPrimitive.Messages>
    </CanvasSplitThread>
  </ThreadPrimitive.Viewport>
  {/* CanvasSplitDocument renders itself, from inside DocumentTool above */}
</CanvasSplit>

Anatomy

<div data-slot="canvas-split">
  <div data-slot="canvas-split-thread">{/* narrow message rail */}</div>
  <div data-slot="canvas-split-document">
    <div data-slot="canvas-split-header">{/* title, version, saved or editing, copy, close */}</div>
    <div data-slot="canvas-split-body">{/* lines, blinking caret while writing */}</div>
  </div>
</div>

Below md, the thread and the document stack vertically instead of splitting side by side; at md and up the layout becomes a row. CanvasSplitHeader's copy and close buttons both disable themselves the same way ChatPanelComposer's send button does, whenever onCopy or onClose is left undefined. CanvasSplitBody's blinking caret is purely presentational: it renders whenever writing is true, appended after whatever children you pass, so it always trails the last line.

Examples

Copy and close

<CanvasSplitHeader
  title="Q3 report outline"
  version={3}
  saved={true}
  onCopy={() => navigator.clipboard.writeText(text)}
  onClose={() => setOpen(false)}
/>

Leaving onCopy or onClose out disables that button instead of hiding it, so the header's width stays stable whether or not either action is available.

A document with no tool call

The same is true with a runtime: CanvasSplitDocument and its children are plain components, so a document backed by application state instead of a tool call still composes with them exactly like the tool-call renderer above.

API reference

Primitive parts

PartRendersNotes
MessagePrimitive.Partschildrencomponents.tools.by_name[toolName] registers a renderer for that tool's calls.
ThreadPrimitive.Viewport / .Messagesdiv / childrenThe thread rail; the same primitives as any other thread, just narrower.

Tool call part

FieldTypeDescription
argsTArgs (partial while streaming)The model's arguments so far.
status.type"running" | "complete" | "incomplete"The call's own lifecycle, not per field.
useToolArgsStatus().propStatusPartial<Record<keyof TArgs, "streaming" | "complete">>Per-argument streaming state; call from inside the tool renderer.