Document Q&A Workspace
Ask your own documents — upload and parse, cited answers with inspectable passages, an honest not-found state, and a visible context budget.
rag-workspace.tsx325 lines7 componentsReact + Tailwind, no dependencies
npx shadcn@latest add https://scrimui.dev/r/rag-workspace.jsonAgent promptClaude Code · Cursor · any agent
Add the Document Q&A Workspace pattern from the Scrim UI registry to this project — a complete screen, not a single component.
Document Q&A Workspace — Ask your own documents — upload and parse, cited answers with inspectable passages, an honest not-found state, and a visible context budget.
## 1. Install
```bash
npx shadcn@latest add https://scrimui.dev/r/rag-workspace.json
```
This writes the screen to `components/blocks/rag-workspace.tsx` and pulls in the components it is built from, each landing at `components/ui/`. Everything is plain React + Tailwind with no runtime dependencies. The imports in the block already point at those paths, so it compiles as installed.
## 2. What it is made of
- `file-upload` — File Upload
- `context-files` — Context Files
- `context-usage` — Context Usage
- `citation-ui` — Citation UI
- `source-list` — Source List
- `streaming-message` — Streaming Message
- `prompt-input` — Prompt Input
Each is a separate file you can edit or replace on its own; the block is the arrangement, not a monolith.
## 3. Rules this layout depends on
Keep these when adapting the screen — they are the reasons it works, and they are easy to break while restyling:
- Ground every answer in inspectable retrieval — citations on the claims, passages with scores one disclosure away.
- Treat 'not found' as a first-class answer: say what was considered, and never let the model guess past the floor.
- Make the context window a visible budget — uploads move the bar, and the near-limit state warns before eviction.
- Flag answers that lose their grounding when a cited document is removed mid-session.
- Keep upload, retrieval and context cost in one rail so 'what does the model know' is answerable at a glance.
## 4. Do not do these
- Answering from nothing — a below-floor retrieval that silently becomes a confident hallucination.
- Showing only the passages that passed; the user can't tell 'no result' from 'no documents'.
- Removing a document with no trace while its citations stay on screen, still looking grounded.
- A context meter that only appears at 100% — by then the eviction has already happened.
The demo content in the file — messages, file names, model names — is placeholder. Replace it with this project's real data and wire the handlers to real state rather than shipping the stubs.
Reference: https://scrimui.dev/patterns/rag-workspaceInstalls the screen and every component it is built from, and carries the layout rules from this page so an agent does not restyle them away.
Live Preview
Document Q&A
Answers only from your documents, with receipts
Ask about the vacation policy — the answer cites its passages.
Then ask something the handbook doesn't cover.
Built from these components
A drag-and-drop upload zone — per-file progress bars, type icons, and error states for files too big or of the wrong type.
The files currently in the model's context — sizes, how many tokens each one costs, and removal without leaving the chat.
How much of the context window is gone and to what — system prompt, files, history — plus what gets dropped first when the next message does not fit.
Inline citation markers in generated text — numbered footnotes that reveal the source in a hover preview card.
The passages retrieval actually returned, with similarity scores and a visible relevance floor — including the case where nothing cleared it.
Render an AI reply token by token like ChatGPT's typing effect — blinking cursor, stop button, and no layout jump as the text grows.
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.
Pattern code
This file composes the components above. Copy each component from its page, then this pattern file wires them together.
"use client";
import * as React from "react";
import { FileUpload, type FileUploadStatus } from "../../file-upload/file-upload";
import { ContextFiles } from "../../context-files/context-files";
import { SourceList, type RetrievedSource } from "../../source-list/source-list";
import { CitationList, type Citation } from "../../citation-ui/citation-ui";
import { ContextUsage } from "../../context-usage/context-usage";
import { PromptInput } from "../../prompt-input/prompt-input";
import { StreamingMessage } from "../../streaming-message/streaming-message";
/**
* "Ask your own documents" — the RAG workspace.
*
* What this pattern exists to show:
*
* 1. **An answer is only as good as its sources.** Every grounded answer
* carries citations, and the retrieved passages (with scores and the
* floor) are inspectable — trust is shown, not asserted.
* 2. **"Not found" is a first-class answer.** When nothing scores above the
* floor, the workspace says so and shows what was considered, instead of
* letting the model guess.
* 3. **Context is a budget.** The usage bar turns the context window into
* something the user can spend deliberately — a big upload visibly moves
* it toward the limit.
* 4. **Removing a document has consequences.** If an existing answer cited
* it, the workspace says which answers just lost their grounding.
*
* Boundary with the Pro RAG template: this is mock state and a scripted
* flow. Ingestion, chunking, embeddings and streaming citation offsets are
* the template's engineering, not this file's.
*/
/* ------------------------------------------------------------------ */
/* Script */
/* ------------------------------------------------------------------ */
type Doc = {
id: string;
name: string;
size: string;
tokens: number;
status: "parsing" | "ready" | "failed";
};
type Turn = {
id: number;
role: "user" | "assistant";
text: string;
kind?: "cited" | "not-found" | "generic";
};
const WINDOW_TOKENS = 128_000;
const RESERVE_TOKENS = 4_000;
const SYSTEM_TOKENS = 1_800;
const SCORE_FLOOR = 0.5;
const INITIAL_DOCS: Doc[] = [
{ id: "d1", name: "employee-handbook.pdf", size: "2.1 MB", tokens: 12_400, status: "ready" },
];
const HANDBOOK_CITATIONS: Citation[] = [
{
id: 1,
title: "employee-handbook.pdf · Vacation policy, p.12",
url: "#handbook-p12",
snippet: "Full-time employees accrue 15 days of paid vacation per calendar year.",
},
{
id: 2,
title: "employee-handbook.pdf · Rollover rules, p.13",
url: "#handbook-p13",
snippet: "Up to 5 unused vacation days roll over and must be used before March 31.",
},
{
id: 3,
title: "employee-handbook.pdf · Company calendar, p.4",
url: "#handbook-p4",
snippet: "The December shutdown week is paid and does not count against vacation balance.",
},
];
const CITED_SOURCES: RetrievedSource[] = [
{ id: "s1", title: "employee-handbook.pdf · p.12", passage: "Full-time employees accrue 15 days of paid vacation per calendar year, increasing to 20 days after three years of continuous employment.", score: 0.82 },
{ id: "s2", title: "employee-handbook.pdf · p.13", passage: "Up to 5 unused vacation days roll over into the following year and must be used before March 31, after which they expire.", score: 0.74 },
{ id: "s3", title: "employee-handbook.pdf · p.4", passage: "The company observes a shutdown week in late December. This time is paid and does not count against the vacation balance.", score: 0.61 },
{ id: "s4", title: "employee-handbook.pdf · p.21", passage: "Remote work is available up to three days per week with manager approval.", score: 0.31 },
];
const NOT_FOUND_SOURCES: RetrievedSource[] = [
{ id: "s5", title: "employee-handbook.pdf · p.30", passage: "New hires receive a laptop and access credentials on their first day.", score: 0.34 },
{ id: "s6", title: "employee-handbook.pdf · p.18", passage: "Business travel expenses are reimbursed within 14 days of report submission.", score: 0.29 },
{ id: "s7", title: "employee-handbook.pdf · p.7", passage: "The office is open from 8am to 7pm on weekdays.", score: 0.18 },
];
const ANSWER_CITED =
"Employees accrue 15 days of paid vacation per year, rising to 20 days after three years [1]. Up to 5 unused days roll over, but they expire if not used before March 31 [2]. Separately, the December shutdown week is paid time and never touches the vacation balance [3].";
const ANSWER_NOT_FOUND =
"I couldn't find that in the documents you've shared. The closest passages — onboarding and expenses — scored below the relevance floor, so I won't guess. Try uploading a document that covers it, or rephrase using terms the document itself would use.";
const ANSWER_GENERIC =
"I answer only from the documents currently in context. Ask about the handbook, upload another document on the left, or remove one to see what happens to its answers.";
/* ------------------------------------------------------------------ */
/* Pattern */
/* ------------------------------------------------------------------ */
export function RagWorkspacePattern() {
const [docs, setDocs] = React.useState<Doc[]>(INITIAL_DOCS);
const [turns, setTurns] = React.useState<Turn[]>([]);
const [pending, setPending] = React.useState<{ text: string; kind: Turn["kind"] } | null>(null);
const [removedNotice, setRemovedNotice] = React.useState<string | null>(null);
const [upload, setUpload] = React.useState<{ name: string; size: string; status: FileUploadStatus; progress: number } | null>(null);
const [docsOpen, setDocsOpen] = React.useState(false);
const scrollRef = React.useRef<HTMLDivElement>(null);
const idRef = React.useRef(1);
React.useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [turns, pending]);
const readyDocs = docs.filter((d) => d.status === "ready");
const convoTokens = 900 + turns.length * 420;
const usedTokens = SYSTEM_TOKENS + convoTokens + readyDocs.reduce((s, d) => s + d.tokens, 0);
const nearLimit = usedTokens / WINDOW_TOKENS > 0.8;
function submit(value: string) {
setTurns((t) => [...t, { id: idRef.current++, role: "user", text: value }]);
const step = turns.filter((t) => t.role === "assistant").length;
window.setTimeout(() => {
if (readyDocs.length === 0) {
setPending({ text: ANSWER_NOT_FOUND, kind: "not-found" });
} else if (step === 0) {
setPending({ text: ANSWER_CITED, kind: "cited" });
} else if (step === 1) {
setPending({ text: ANSWER_NOT_FOUND, kind: "not-found" });
} else {
setPending({ text: ANSWER_GENERIC, kind: "generic" });
}
}, 450);
}
function onStreamComplete() {
if (!pending) return;
setTurns((t) => [...t, { id: idRef.current++, role: "assistant", text: pending.text, kind: pending.kind }]);
setPending(null);
}
/** Real file names, simulated parse — the pattern never reads file bytes. */
function onSelect(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const size = file.size > 1_000_000 ? `${(file.size / 1_000_000).toFixed(1)} MB` : `${Math.max(1, Math.round(file.size / 1000))} KB`;
setUpload({ name: file.name, size, status: "uploading", progress: 0 });
let p = 0;
const timer = window.setInterval(() => {
p = Math.min(100, p + 14);
setUpload((u) => (u ? { ...u, progress: p } : u));
if (p >= 100) {
window.clearInterval(timer);
const tokens = Math.min(96_000, Math.max(2_000, Math.round(file.size / 40)));
setDocs((d) => [...d, { id: `d${idRef.current++}`, name: file.name, size, tokens, status: "ready" }]);
setUpload(null);
}
}, 160);
}
function removeDoc(doc: Doc) {
setDocs((ds) => ds.filter((d) => d.id !== doc.id));
const cited = turns.some((t) => t.kind === "cited");
if (doc.id === "d1" && cited) {
setRemovedNotice(`"${doc.name}" was removed — the vacation answer above cited it and is no longer grounded.`);
} else {
setRemovedNotice(`"${doc.name}" was removed from context.`);
}
}
const docsPanel = (
<div className="flex h-full flex-col gap-3 overflow-y-auto p-3">
<p className="text-[13px] font-semibold text-zinc-900 dark:text-zinc-100">Documents</p>
<FileUpload
status={upload?.status ?? "idle"}
progress={upload?.progress}
fileName={upload?.name}
fileSize={upload?.size}
accept=".pdf,.md,.txt,.csv"
onSelect={onSelect}
onRemove={() => setUpload(null)}
/>
<ContextFiles
title="In context"
files={readyDocs.map((d) => ({ name: d.name, detail: `${d.size} · ≈ ${(d.tokens / 1000).toFixed(1)}k tokens` }))}
onRemove={(name) => {
const doc = docs.find((d) => d.name === name);
if (doc) removeDoc(doc);
}}
/>
<ContextUsage
window={WINDOW_TOKENS}
reserve={RESERVE_TOKENS}
estimated
segments={[
{ label: "System", tokens: SYSTEM_TOKENS },
...readyDocs.map((d, i) => ({ label: d.name, tokens: d.tokens, evictionRank: i + 2 })),
{ label: "Conversation", tokens: convoTokens, evictionRank: 1 },
]}
/>
{nearLimit && (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-4 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300">
Context is nearly full — the oldest documents are evicted first when it overflows.
</p>
)}
</div>
);
return (
<div className="relative flex h-[640px] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
{/* Documents rail */}
<aside className="hidden w-64 shrink-0 border-r border-zinc-200 dark:border-zinc-800 md:block">
{docsPanel}
</aside>
{/* Q&A */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
<div className="min-w-0">
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Document Q&A</p>
<p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
Answers only from your documents, with receipts
</p>
</div>
<button
type="button"
onClick={() => setDocsOpen(true)}
className="shrink-0 rounded-lg border border-zinc-200 px-2.5 py-1 text-xs font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800 md:hidden"
>
Documents ({readyDocs.length})
</button>
</div>
<div ref={scrollRef} className="flex-1 space-y-5 overflow-y-auto px-4 py-5">
{turns.length === 0 && !pending && (
<p className="pt-16 text-center text-[13px] leading-6 text-zinc-400 dark:text-zinc-500">
Ask about the vacation policy — the answer cites its passages.
<br />
Then ask something the handbook doesn't cover.
</p>
)}
{removedNotice && (
<div role="status" className="flex items-start justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300">
<span>{removedNotice}</span>
<button
type="button"
onClick={() => setRemovedNotice(null)}
aria-label="Dismiss"
className="shrink-0 rounded px-1 hover:bg-amber-100 dark:hover:bg-amber-900/50"
>
✕
</button>
</div>
)}
{turns.map((t) =>
t.role === "user" ? (
<div key={t.id} className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap rounded-2xl rounded-tr-md bg-zinc-900 px-4 py-3 text-[15px] leading-6 text-white dark:bg-zinc-100 dark:text-zinc-900">
{t.text}
</div>
</div>
) : (
<div key={t.id}>
<StreamingMessage text={t.text} />
{t.kind === "cited" && (
<div className="mt-2">
<CitationList citations={HANDBOOK_CITATIONS} />
</div>
)}
{(t.kind === "cited" || t.kind === "not-found") && (
<details className="mt-2 rounded-xl border border-zinc-200 dark:border-zinc-800">
<summary className="cursor-pointer px-3 py-2 text-xs font-medium text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200">
{t.kind === "cited" ? "Retrieved passages (3 of 4 used)" : "Nothing passed the relevance floor"}
</summary>
<div className="border-t border-zinc-100 p-2 dark:border-zinc-800">
<SourceList sources={t.kind === "cited" ? CITED_SOURCES : NOT_FOUND_SOURCES} floor={SCORE_FLOOR} />
</div>
</details>
)}
</div>
),
)}
{pending && (
<StreamingMessage text={pending.text} isStreaming speed={2} onComplete={onStreamComplete} />
)}
</div>
<div className="border-t border-zinc-200 px-4 py-3 dark:border-zinc-800">
<PromptInput
placeholder={readyDocs.length === 0 ? "Upload a document first…" : "Ask your documents…"}
onSubmit={submit}
/>
</div>
</div>
{/* Documents as an overlay on narrow screens */}
{docsOpen && (
<div className="absolute inset-0 z-10 bg-white dark:bg-zinc-900 md:hidden">
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Documents</p>
<button
type="button"
onClick={() => setDocsOpen(false)}
className="rounded-lg border border-zinc-200 px-2.5 py-1 text-xs font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
Back to chat
</button>
</div>
{docsPanel}
</div>
)}
</div>
);
}
When to use it
- Ground every answer in inspectable retrieval — citations on the claims, passages with scores one disclosure away.
- Treat 'not found' as a first-class answer: say what was considered, and never let the model guess past the floor.
- Make the context window a visible budget — uploads move the bar, and the near-limit state warns before eviction.
- Flag answers that lose their grounding when a cited document is removed mid-session.
- Keep upload, retrieval and context cost in one rail so 'what does the model know' is answerable at a glance.
What breaks in production
- Answering from nothing — a below-floor retrieval that silently becomes a confident hallucination.
- Showing only the passages that passed; the user can't tell 'no result' from 'no documents'.
- Removing a document with no trace while its citations stay on screen, still looking grounded.
- A context meter that only appears at 100% — by then the eviction has already happened.
More Patterns
The canonical chat interface — sidebar, streaming messages, prompt input with model selector, and sources.
AI Research AssistantA research flow that shows search tool calls, reasoning, sources and a cited final answer.
AI Coding AgentA coding run with agent status, tool calls, diffs and a human-in-the-loop approval gate.
AI Voice AssistantA voice-first conversation — live waveform states, a recording input, a spoken transcript and a typed fallback.
Model & Memory PreferencesA preferences screen that picks the model, reasoning level and tools, and manages persistent memory.
Artifact WorkspaceChat on the left, generated output on the right — artifacts open from the answer, stream, version, and fail without breaking the conversation.
Structured Extraction & ReviewUpload a document, watch fields fill in, then review the flagged ones — per-field confidence, corrections that keep the original, export earned.
Image Generation StudioA one-screen generation studio — prompt composer and model picker beside a feed of queued, staged, blocked and ready image results with variants.
Multi-agent Ops ConsoleWatch a fleet of agents at once — parallel statuses, an inspectable handoff, a waiting approval, a failed child run, and per-run plus fleet cost.
Customer Support CopilotGrounded reply drafts with citations, honest low-confidence answers, inline corrections, an approval gate on refunds, and a rating row on every draft.
Generative UI DashboardThe model assembles a dashboard from a controlled widget registry — streamed props, an unsupported-request fallback, and widget clicks that re-enter the chat.