Skip to content
KONIGI

AI Assistants / Output shape / Artifact panel

1 of 4

Artifact panel

The answer is a document or a program, and a chat transcript is the wrong container for either.

Updated September 12, 2026

Problem

The viewer asked for a four-hundred-line file, a full report, or a working page. It arrives inside a chat turn. The conversation above scrolls away, the thing can’t be read without scrolling past it, and the next revision produces a second four-hundred-line copy directly underneath the first.

Solution

Promote output that behaves like a document into its own panel beside the transcript. The conversation stays about the work; the panel holds the work. That separation fixes four problems at once: the transcript stays navigable, the output gets its own scroll, revisions replace rather than accumulate, and the object can carry actions a chat turn can’t.

Versioning is what makes the panel worth building rather than a styling choice. Iterating on a document through conversation produces a sequence of drafts, and in a plain transcript those drafts are six near-identical walls of text the viewer has to diff by eye. In a panel they’re versions with a pager, and going back to the third attempt is one click. HAX guideline 9 asks for efficient correction, and for generated documents the correction loop is precisely this sequence of revisions.

The promotion heuristic is the hard part. Output should move to the panel when it’s long, structurally self-contained, and something the viewer will act on rather than read once. Getting it wrong in either direction is irritating in a specific way: a two-line snippet exiled to a panel makes the viewer click to read three words, and a full report left inline destroys the conversation. The heuristic wants to be legible, and a manual override in both directions covers the cases it misses.

Once the panel exists it accumulates the actions the output actually needs, which vary by type: copy and download for a document, run and preview for code, publish for a page. Editing belongs there too. A viewer who fixes one word by hand expects the model to see the edited version on the next turn, and a panel whose contents silently diverge from what the model holds is the worst failure available here.

Use when

The output is a self-contained thing the viewer will keep, revise, or use elsewhere.

Don’t use when

The answer is an answer. Explanations, comparisons and short code fragments belong in the conversation, and moving them out adds a click and removes them from the context the reader is holding.

Trade-offs

The panel takes horizontal space from the conversation permanently, and on a laptop that’s a real loss for exchanges where the artifact is incidental. Splitting the output from the discussion means the answer’s explanation and the answer are in different places, which is worse for teaching and better for doing. Versions preserved in the panel diverge from the transcript’s account of what happened. On a phone the whole arrangement collapses. Side-by-side becomes a sheet that covers the conversation, and the pattern is a modal again.

Checklist

  • What promotes output into the panel, and can the viewer override it in both directions?
  • Are previous versions reachable, and is the current one labelled?
  • If the viewer edits in the panel, does the model see the edit next turn?
  • What actions does the panel carry, and do they match the output’s type?
  • Does the transcript keep a readable reference to what was produced?
  • What happens on a phone?
  • Can the artifact be exported or shared on its own?
  • Is content still streaming into the panel visible without switching to it?
  • What happens when one conversation produces several different artifacts?
  • Does closing the panel destroy anything?

Compare

Claude opens a side panel for documents and code and keeps a version history on it, so the conversation stays a conversation while the output accumulates revisions in one place. ChatGPT offers a canvas that turns the output into a directly editable surface with inline controls, moving the panel from a viewer toward an editor. Gemini routes long output into Google Docs instead. The container problem goes away because the artifact lands in a product built to hold one. v0 inverts the arrangement entirely and makes the preview the main surface with the conversation as a narrow rail. When every turn produces a new version of one thing, that’s the right call.

Streaming response is what the panel is protecting the transcript from. Code block actions is the same problem at the scale of a single fence. Response collapse is the cheaper fix when a panel is more than the output warrants. Assistant sidebar is the same geometry with the roles reversed. Message turn is what stays behind in the conversation once the artifact leaves it.

Artifact panel anatomy A conversation on the left and a generated document in its own panel on the right, carrying a version pager, its own actions, and its own scroll, with the transcript keeping a short reference to what was produced. The conversation stays about the work; the panel holds the work make the intro shorter Pricing page draft · v3 Pricing page draft v3 of 5 copy · download edited here, and the model sees it next turn 1 2 3 4 1 A REFERENCE, NOT A COPY The transcript keeps a short pointer so the conversation stays navigable. 2 VERSIONS Six near-identical drafts in a transcript have to be diffed by eye. Here they page. 3 ACTIONS THE TYPE NEEDS Copy and download for a document, run and preview for code, publish for a page. 4 EDITS HAVE TO PROPAGATE A panel whose contents diverge from what the model holds is the worst failure here. Promotion is the hard part. A two-line snippet exiled to a panel costs a click to read three words.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

The conversation stays about the work and the panel holds the work. A generated document gets its own version pager, its own actions and its own scroll, while the transcript keeps a short reference to what was produced.

shadcn
npx shadcn@latest add resizable button
npm
lucide-react
Tokens
--foreground--background--card--card-foreground--accent--accent-foreground--muted--muted-foreground--border--input--ring--chart-1

make the intro shorter

Cut it to two sentences and moved the plan comparison up. v3 is in the panel.

Pricing page draft

v3 of 5
Pay for what you use. One plan, metered by seats, with the first three free. Starter: three seats, unlimited projects, community support. Free. Team: £12 a seat a month, shared workspaces, priority support, SSO. Enterprise: custom seat pricing, audit log, dedicated support, invoicing. Every plan includes unlimited viewers, and you can move between plans at any time without losing work.

ArtifactPanel.tsxThe reference chip, the panel with its pager, type-specific actions and an editable body that reports every edit, and the resizable layout.

import { useState, type ReactNode } from "react";
import { Copy, Download, Play, Eye, Globe, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";

export type Artifact = {
  title: string;
  /** Document, code or page. The actions follow the type. */
  kind: "document" | "code" | "page";
  /** Every version, oldest first. Six near-identical drafts in a transcript
   *  have to be diffed by eye; here they page. */
  versions: string[];
};

/** Copy and download for a document, run and preview for code, publish for
 *  a page. A panel with the wrong verbs is a panel nobody uses. */
const ACTIONS = {
  document: [{ label: "copy", Icon: Copy }, { label: "download", Icon: Download }],
  code:     [{ label: "run", Icon: Play },   { label: "preview", Icon: Eye }],
  page:     [{ label: "publish", Icon: Globe }],
} as const;

/** The transcript keeps a short pointer, so the conversation stays about the
 *  work and stays navigable. The work itself lives in the panel. */
export function ArtifactReference({ artifact, version, onOpen }: { artifact: Artifact; version: number; onOpen: () => void }) {
  return (
    <Button variant="outline" size="sm" className="w-full justify-start font-normal" onClick={onOpen}>
      <FileText className="size-4" /> {artifact.title} · v{version}
    </Button>
  );
}

export function ArtifactPanel({ artifact, onAction, onEdit, defaultVersion }: {
  artifact: Artifact;
  /** Which version opens first. Defaults to the newest. */
  defaultVersion?: number;
  onAction: (label: string, version: number) => void;
  /** Fires on every edit made in the panel. The model has to see this next
   *  turn; a panel whose contents diverge from what the model holds is the
   *  worst failure here. */
  onEdit: (version: number, text: string) => void;
}) {
  const n = artifact.versions.length;
  const [v, setV] = useState(defaultVersion ?? n);
  const [dirty, setDirty] = useState(false);

  return (
    <div className="flex h-full min-w-0 flex-col overflow-hidden rounded-lg border bg-muted">
      <div className="border-b px-3 py-2">
        <p className="text-sm text-card-foreground">{artifact.title}</p>
        <div className="mt-1 flex flex-wrap items-center gap-2">
        <div className="flex items-center gap-1 text-xs">
          <Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setV(v - 1)} disabled={v === 1} aria-label="previous version">‹</Button>
          <span className="tabular-nums text-chart-1">v{v} of {n}</span>
          <Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setV(v + 1)} disabled={v === n} aria-label="next version">›</Button>
        </div>
        <div className="ml-auto flex gap-1">
          {ACTIONS[artifact.kind].map(({ label, Icon }) => (
            <Button key={label} variant="ghost" size="sm" className="h-7 text-xs" onClick={() => onAction(label, v)}>
              <Icon className="size-3.5" /> {label}
            </Button>
          ))}
        </div>
        </div>
      </div>

      {/* The panel has its own scroll, and its body is the editable thing. */}
      <div
        contentEditable
        suppressContentEditableWarning
        onInput={(e) => { setDirty(true); onEdit(v, e.currentTarget.innerText); }}
        className="min-h-0 flex-1 overflow-y-auto whitespace-pre-wrap p-3 text-sm leading-relaxed text-card-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        {artifact.versions[v - 1]}
      </div>

      {dirty && (
        <p className="border-t px-3 py-1.5 text-xs text-muted-foreground">edited here, and the model sees it next turn</p>
      )}
    </div>
  );
}

/** Transcript on the left, work on the right, and the reader sets the split. */
export function ArtifactLayout({ transcript, panel }: { transcript: ReactNode; panel: ReactNode }) {
  return (
    <ResizablePanelGroup direction="horizontal">
      <ResizablePanel defaultSize={45} minSize={30}>{transcript}</ResizablePanel>
      <ResizableHandle />
      <ResizablePanel defaultSize={55} minSize={35}>{panel}</ResizablePanel>
    </ResizablePanelGroup>
  );
}

demo.tsxHow it is called: five drafts, v3 open, the transcript holding only the pointer. Drag the split.

import { useState } from "react";
import { ArtifactLayout, ArtifactPanel, ArtifactReference, type Artifact } from "./ArtifactPanel";

const PLANS =
  "Starter: three seats, unlimited projects, community support. Free.\nTeam: £12 a seat a month, shared workspaces, priority support, SSO.\nEnterprise: custom seat pricing, audit log, dedicated support, invoicing.";
const CLOSE = "Every plan includes unlimited viewers, and you can move between plans at any time without losing work.";

/** Five drafts of the same page. v3 is the one the transcript points at. */
const draft: Artifact = {
  title: "Pricing page draft",
  kind: "document",
  versions: [
    `Our pricing is designed to grow with you. Whether you are a solo founder or a global enterprise, we have a plan that fits your needs and your budget, with transparent pricing and no hidden fees.\n\n${PLANS}`,
    `Pricing that grows with you. One plan per team size, metered by seats, with the first three seats free so you can try it with real colleagues before you pay anything.\n\n${PLANS}\n\n${CLOSE}`,
    `Pay for what you use. One plan, metered by seats, with the first three free.\n\n${PLANS}\n\n${CLOSE}`,
    `Pay for what you use. Seats, not tiers.\n\n${PLANS}\n\n${CLOSE}`,
    `Seats, not tiers. The first three are free.\n\n${PLANS}\n\n${CLOSE}`,
  ],
};

export default function Demo() {
  const [open, setOpen] = useState(true);

  return (
    <div className="h-[420px] overflow-hidden rounded-lg border bg-card">
      <ArtifactLayout
        transcript={
          <div className="h-full p-4">
            <div className="flex justify-end">
              <p className="rounded-xl bg-muted px-3 py-1.5 text-sm text-foreground">make the intro shorter</p>
            </div>
            <p className="mt-4 text-sm leading-relaxed text-card-foreground">Cut it to two sentences and moved the plan comparison up. v3 is in the panel.</p>
            <div className="mt-4">
              <ArtifactReference artifact={draft} version={3} onOpen={() => setOpen(true)} />
            </div>
          </div>
        }
        panel={
          open ? (
            <div className="h-full p-4">
              <ArtifactPanel artifact={draft} defaultVersion={3} onAction={() => {}} onEdit={() => {}} />
            </div>
          ) : null
        }
      />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.