Elements

92 / 122 · 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

Installation

npx shadcn@latest add "@assistant-ui/elements-canvas-split"

Usage

import { CanvasSplit } from "@/components/elements/canvas-split";

<CanvasSplit
  messages={messages}
  title="migration-0.14.md"
  version={3}
  lines={lines}
  visibleLines={lines.length}
  saved
/>

Props

messages*CanvasMessage[]The thread, compressed into the side rail while the document holds the room.
title*stringDocument name in the canvas header.
version*numberWhich revision is on screen.
lines*string[]Document body, one paragraph per entry. A leading # marks a heading.
visibleLines*numberHow much has been written. A caret trails the last line until this reaches the end.
saved*booleanSwaps the header status between editing and saved.
onCopy() => voidCalled from the copy action. Omit it and the control renders disabled rather than promising a copy it cannot make.
onClose() => voidCalled when the canvas is dismissed.
classNamestringExtra classes merged onto the root.

Source

canvas-split.tsx
"use client";

import { CheckIcon, CopyIcon, FileTextIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, ghostButton, mono, paper } from "./surfaces";

export interface CanvasMessage {
  id: string;
  role: "user" | "assistant";
  text: string;
}

export function CanvasSplit({
  messages,
  title,
  version,
  lines,
  visibleLines,
  saved,
  onCopy,
  onClose,
  className,
}: {
  messages: readonly CanvasMessage[];
  title: string;
  version: number;
  lines: readonly string[];
  visibleLines: number;
  saved: boolean;
  onCopy?: () => void;
  onClose?: () => void;
  className?: string;
}) {
  return (
    <div
      className={cn(
        paper,
        "flex w-full max-w-3xl flex-col overflow-hidden rounded-[20px] md:h-80 md:flex-row",
        className,
      )}
    >
      <div className="border-foreground/[0.07] flex flex-col gap-3 border-b p-4 md:w-[15rem] md:shrink-0 md:overflow-y-auto md:border-r md:border-b-0">
        {messages.map((message) => (
          <div
            key={message.id}
            className={cn(
              "fade-in animate-in fill-mode-both text-[13px] leading-relaxed duration-300",
              message.role === "user"
                ? cn(field, "text-foreground/80 ml-auto rounded-2xl px-3 py-2")
                : "text-foreground/60",
            )}
          >
            {message.text}
          </div>
        ))}
      </div>

      <div className="flex min-w-0 flex-1 flex-col">
        <div className="border-foreground/[0.07] flex items-center gap-2 border-b px-3.5 py-2.5">
          <FileTextIcon className="text-foreground/35 size-3.5 shrink-0" />
          <span className="min-w-0 flex-1 truncate text-[13px] font-medium">
            {title}
          </span>
          <span className={cn(mono, "text-foreground/30 shrink-0")}>
            v{version}
          </span>
          <span
            className={cn(
              mono,
              "shrink-0 transition-colors duration-300",
              saved
                ? "text-emerald-600 dark:text-emerald-400"
                : "text-foreground/30",
            )}
          >
            {saved ? (
              <span className="flex items-center gap-1">
                <CheckIcon className="size-3" />
                saved
              </span>
            ) : (
              "editing"
            )}
          </span>
          <button
            type="button"
            aria-label={`Copy ${title}`}
            onClick={onCopy}
            disabled={!onCopy}
            className={cn(
              ghostButton,
              "size-7 shrink-0 disabled:pointer-events-none disabled:opacity-30",
            )}
          >
            <CopyIcon className="size-3.5" />
          </button>
          <button
            type="button"
            aria-label="Close the canvas"
            onClick={onClose}
            disabled={!onClose}
            className={cn(
              ghostButton,
              "size-7 shrink-0 disabled:pointer-events-none disabled:opacity-30",
            )}
          >
            <XIcon className="size-3.5" />
          </button>
        </div>

        <div className="flex min-h-[9rem] flex-1 flex-col gap-1.5 overflow-y-auto p-4">
          {lines.slice(0, visibleLines).map((line, i) => (
            <p
              key={`${i}-${line}`}
              className={cn(
                "fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-[13px] leading-relaxed duration-300",
                line.startsWith("#")
                  ? "text-foreground/95 font-medium"
                  : "text-foreground/65",
              )}
            >
              {line.replace(/^#\s*/, "")}
            </p>
          ))}
          {visibleLines < lines.length && (
            <span
              aria-hidden
              className="bg-foreground/70 h-[1.05em] w-[2px] animate-pulse rounded-full motion-reduce:animate-none"
            />
          )}
        </div>
      </div>
    </div>
  );
}