Skip to content
KONIGI

AI Assistants / Input and invocation / Attachment tray

1 of 7

Attachment tray

The viewer adds a file to the question and needs to see what the model will actually read.

Updated September 12, 2026

Problem

Someone attaches a 300-page PDF and asks for a summary. The file uploads, a chip appears, the answer arrives, and it’s confidently about the first forty pages. Nothing on screen ever distinguished the file being present from the file being read.

Solution

The tray is the place where the difference between attached and understood gets stated. It sits above the composer, holds one chip per item, and each chip carries four things: what it’s, what type it’s, how much of it there’s, and what state it’s in.

State runs further than upload progress. The useful sequence is uploading, extracting, ready, and the extraction step is where most of the honesty lives. A scanned PDF with no text layer uploads perfectly and contains nothing a text model can read. A spreadsheet may arrive as flattened CSV with the formulas gone. An image goes to a vision model and gets described rather than read. None of that’s visible from a filename and an icon, and all of it changes what the answer can be.

Truncation is the second honesty problem and the more damaging one. Context windows are finite, and a document larger than the budget gets cut, chunked, or retrieved from rather than read whole. Silent truncation produces an answer that’s confidently partial, which is worse than a refusal because it carries no signal. Say how much was used. Even a rough form works: forty of three hundred pages, or the first 100,000 tokens.

HAX guideline 16 covers the shape of this: convey the consequences of user actions before they happen. Attaching a file has three consequences a viewer can’t infer. It commits content to a provider, it may persist beyond this conversation, and it consumes a context budget shared with the conversation itself. The tray is the only place all three can be said at the moment they become true.

Two smaller decisions round it out. Attachments have to be removable before send, because the wrong file gets picked constantly. And their lifetime across turns has to be stated: a file attached on turn one is still in context on turn nine, the viewer rarely knows that, and it’s the difference between a follow-up that works and one answered from a document they thought they’d left behind.

Use when

The assistant accepts files, images, or documents as part of a question.

Don’t use when

The context comes from the product rather than the person, which is scoped context and needs a different treatment. A tray implies something was added and can be removed, and applying it to the ambient contents of a workspace overstates the viewer’s control.

Trade-offs

Showing extraction detail makes the interface talk about plumbing at the exact moment the viewer wants to ask a question, and most people won’t read it. Showing nothing produces the silent-truncation failure, which costs far more and surfaces much later. Chips that stay visible across every turn keep the context legible and consume composer space permanently. Persisting files makes follow-ups work and creates a retention surface that has to be explained and controlled.

Checklist

  • Does a chip distinguish uploaded from extracted from ready?
  • What does a scanned PDF with no text layer look like here?
  • If the document exceeds the context budget, does the viewer learn how much was used?
  • Can an attachment be removed before send, and after?
  • Is it clear whether the file stays in context on later turns?
  • Where does the file go, how long does it live, and is that said at attach time?
  • What happens when extraction fails, and is that distinguishable from an empty document?
  • Does the chip name the file in a way that survives two files with similar names?
  • Is the drop target big enough to hit with a dragged file?
  • Can a screen reader user tell what’s attached and remove it?

Compare

ChatGPT puts the chip in the composer and keeps the file available for later turns, so the attachment behaves like conversation state rather than a one-shot payload. Claude separates a per-message attachment from project-level knowledge, which makes the lifetime question explicit in the interface instead of leaving it to be inferred. Gemini reaches into Drive rather than asking for an upload, so the attached thing is a live reference to a document that can change underneath the conversation. Notion replaces attachment with an @-mention of a page, so context is addressed by name in the prompt text itself and there is no tray at all.

Composer is the surface the tray sits on. Scoped context is the same problem when the material comes from the product rather than from a file the viewer chose. Context meter is where the budget the attachment consumes becomes visible. Source list is the answer-side counterpart, naming what was actually drawn from. Generation error is what a failed extraction turns into if the tray stays silent.

Attachment tray anatomy Three chips above a composer in three different states: one extracted and ready with the share of the context budget it used, one still extracting, and one that uploaded perfectly and contains no text layer to read. Attached is not the same as read lease.pdf 40 of 312 pages used figures.xlsx extracting… scan-2019.pdf no text layer, nothing to read documents conversation context budget 1 2 3 4 1 SAY HOW MUCH WAS USED Silent truncation produces an answer that is confidently partial and carries no signal. 2 EXTRACTION IS A STATE Uploaded, extracting, ready. The middle one is where the honesty lives. 3 A PERFECT EMPTY FILE A scan with no text layer uploads fine and contains nothing a text model can read. 4 A SHARED BUDGET The document and the conversation draw on the same window. One crowds the other. A file attached on turn one is usually still in context on turn nine, and nobody says so.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Three chips in three states above a composer. Attached is not the same as read: one file is extracted and reports the share of the context budget it took, one is still extracting, and one uploaded perfectly with no text layer inside it.

shadcn
npx shadcn@latest add progress button
npm
lucide-react
Tokens
--card--card-foreground--muted--muted-foreground--border--status-warn--chart-1--scale-seq-1--scale-seq-3
  • lease.pdf

    40 of 312 pages used

  • figures.xlsx

    extracting…

  • scan-2019.pdf

    no text layer, nothing to read

documentsconversationcontext budget

AttachmentTray.tsxA closed set of states per chip, a partial read that says how much was used, and the budget the documents share with the conversation.

import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";

/**
 * Attached is not the same as read. The states in between are where the
 * honesty lives, and the set is closed so a chip can never sit in a state the
 * tray has no words for.
 */
export type Attachment = { id: string; name: string } & (
  | { state: "uploading"; progress: number }
  | { state: "extracting"; progress: number }
  /** Read, in whole or in part. `used` of `total` is what the model will see. */
  | { state: "ready"; used: number; total: number; unit: "pages" | "rows" | "tokens"; tokens: number }
  /** Uploaded perfectly and holds nothing a text model can read. */
  | { state: "empty"; reason: string }
  | { state: "failed"; reason: string }
);

type Props = {
  items: Attachment[];
  /** The window the documents and the conversation draw on, in tokens. */
  budget: number;
  /** What the conversation itself has used so far. */
  conversationTokens: number;
  /** The wrong file gets picked constantly. */
  onRemove: (id: string) => void;
};

/** The second line of a chip. Silent truncation is the failure, so a partial
 *  read says how much was used rather than just "ready". */
const status = (a: Attachment) => {
  switch (a.state) {
    case "uploading": return "uploading…";
    case "extracting": return "extracting…";
    case "ready": return a.used < a.total ? `${a.used} of ${a.total} ${a.unit} used` : `${a.total} ${a.unit} read`;
    case "empty": return a.reason;
    case "failed": return `extraction failed: ${a.reason}`;
  }
};

export function AttachmentTray({ items, budget, conversationTokens, onRemove }: Props) {
  const documentTokens = items.reduce((n, a) => n + (a.state === "ready" ? a.tokens : 0), 0);
  const share = (n: number) => `${Math.min(100, Math.round((n / budget) * 1000) / 10)}%`;

  return (
    <div>
      <ul className="flex flex-wrap gap-3" aria-label="Attachments">
        {items.map((a) => {
          const flagged = a.state === "empty" || a.state === "failed";
          return (
            <li
              key={a.id}
              className={cn("flex items-start gap-2 rounded-md border bg-muted px-3 py-2", flagged && "border-status-warn")}
            >
              <div className="min-w-0">
                <p className="truncate text-xs text-card-foreground" title={a.name}>{a.name}</p>
                <p className={cn("mt-0.5 text-[11px] tabular-nums", flagged ? "text-status-warn" : "text-muted-foreground")}>
                  {status(a)}
                </p>
                {(a.state === "uploading" || a.state === "extracting") && (
                  <Progress
                    value={a.progress}
                    aria-label={`${a.name} ${a.state}`}
                    className="mt-1 h-[3px] w-[140px] bg-muted-foreground/20 [&>div]:bg-chart-1"
                  />
                )}
              </div>
              <Button
                variant="ghost"
                size="sm"
                className="-mr-2 -mt-1 h-6 w-6 p-0 text-muted-foreground"
                onClick={() => onRemove(a.id)}
                aria-label={`Remove ${a.name}`}
              >
                <X className="size-3" />
              </Button>
            </li>
          );
        })}
      </ul>

      {/* One window, two claimants. The documents crowd the conversation and
          the viewer needs to see that before the answer comes back short. */}
      <div className="mt-4">
        <div
          className="flex h-3 w-full overflow-hidden rounded-[2px] bg-muted"
          role="img"
          aria-label={`documents ${share(documentTokens)} and conversation ${share(conversationTokens)} of the context budget`}
        >
          <span className="bg-scale-seq-3" style={{ width: share(documentTokens) }} />
          <span className="bg-scale-seq-1" style={{ width: share(conversationTokens) }} />
        </div>
        <div className="relative mt-1.5 h-4 text-[11px] text-muted-foreground">
          <span className="absolute left-0">documents</span>
          <span className="absolute" style={{ left: share(documentTokens) }}>conversation</span>
          <span className="absolute right-0">context budget</span>
        </div>
      </div>
    </div>
  );
}

demo.tsxHow it is called: three files in three states against a 200k window. Remove drops a chip and the bar recomputes.

import { useState } from "react";
import { AttachmentTray, type Attachment } from "./AttachmentTray";

/**
 * Three chips in three states. The lease was read in part and says so, the
 * spreadsheet is still extracting, and the 2019 scan uploaded fine with no
 * text layer inside it. The budget is a 200k window: the lease took 128k of
 * it and the conversation has used 32k.
 */
const ITEMS: Attachment[] = [
  { id: "lease", name: "lease.pdf", state: "ready", used: 40, total: 312, unit: "pages", tokens: 128_000 },
  { id: "figures", name: "figures.xlsx", state: "extracting", progress: 41 },
  { id: "scan", name: "scan-2019.pdf", state: "empty", reason: "no text layer, nothing to read" },
];

export default function Demo() {
  const [items, setItems] = useState(ITEMS);
  return (
    <div className="rounded-lg border bg-card p-4">
      <AttachmentTray
        items={items}
        budget={200_000}
        conversationTokens={32_000}
        onRemove={(id) => setItems(items.filter((a) => a.id !== id))}
      />
    </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.