Skip to content
KONIGI Patterns

AI Assistants / Starting a request / Attachment tray

1 of 7 ›

Attachment tray

People need to know which attached files the assistant can read.

Updated September 12, 2026

Problem

A file can upload successfully without all of its contents being readable by the assistant. People need to know what was processed before relying on an answer.

Solution

Show attached files near the message field. Each item should display its name, type, and status, with a way to remove it before sending.

Distinguish uploading from processing and ready to use. Explain problems such as unreadable scanned text or spreadsheet formulas that were not imported. If only part of a file was used, say which part.

Make clear whether attachments remain available for follow-up questions. Explain where files are sent and how long they are kept, with more detail available when needed.

Use when

People can attach files, images, or documents to a request.

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

File details take space near the message field, but hiding processing limits can make a partial answer look complete. Keeping attachments available helps with follow-up questions and requires clear controls for removing them.

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 holds the message and attachments. Scoped context covers material selected from within the app. Context meter shows how much room attachments use.

Source list identifies material used in an answer. Generation error handles failures that prevent an answer.

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 screenshots yet for this pattern. Browse Products to see other examples.