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 (
{/* One window, two claimants. The documents crowd the conversation and the viewer needs to see that before the answer comes back short. */}
documents conversation context budget
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── 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 (
setItems(items.filter((a) => a.id !== id))} />
); }