Attachment tray 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. npx shadcn@latest add progress button npm i lucide-react Tokens this needs: --card, --card-foreground, --muted, --muted-foreground, --border, --status-warn, --chart-1, --scale-seq-1, --scale-seq-3 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // AttachmentTray.tsx ──────────────────────────────────────────────────────────────────────── 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 (
{a.name}
{status(a)}
{(a.state === "uploading" || a.state === "extracting") && ( )}