Skip to content
Scrim UI

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.tsx327 lines7 componentsReact + Tailwind + shadcn/ui

npx shadcn@latest add @scrimui/rag-workspace
Agent 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 @scrimui/rag-workspace
```

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-workspace

Installs 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

Pattern code

This file composes the components above. Copy each component from its page, then this pattern file wires them together.

rag-workspace.tsx
"use client";

import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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-sm font-semibold text-foreground">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-xs 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 (
    <Card className="flex-row gap-0 py-0 relative flex h-[640px] overflow-hidden rounded-xl border border-border bg-card">
      {/* Documents rail */}
      <aside className="hidden w-64 shrink-0 border-r border-border 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-border px-4 py-3">
          <div className="min-w-0">
            <p className="text-sm font-semibold text-foreground">Document Q&amp;A</p>
            <p className="truncate text-xs text-muted-foreground">
              Answers only from your documents, with receipts
            </p>
          </div>
          <Button variant="outline" size="sm"
            type="button"
            onClick={() => setDocsOpen(true)}
            className="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-muted 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-sm leading-6 text-muted-foreground">
              Ask about the vacation policy — the answer cites its passages.
              <br />
              Then ask something the handbook doesn&apos;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 variant="ghost" size="icon-sm"
                type="button"
                onClick={() => setRemovedNotice(null)}
                aria-label="Dismiss"
                className="min-h-6 min-w-6 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-xl bg-muted px-4 py-3 text-sm leading-6 text-foreground">
                  {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-border">
                    <summary className="cursor-pointer px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground">
                      {t.kind === "cited" ? "Retrieved passages (3 of 4 used)" : "Nothing passed the relevance floor"}
                    </summary>
                    <div className="border-t border-border p-2">
                      <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-border px-4 py-3">
          <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-card md:hidden">
          <div className="flex items-center justify-between border-b border-border px-4 py-3">
            <p className="text-sm font-semibold text-foreground">Documents</p>
            <Button variant="outline" size="sm"
              type="button"
              onClick={() => setDocsOpen(false)}
              className="rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-muted"
            >
              Back to chat
            </Button>
          </div>
          {docsPanel}
        </div>
      )}
    </Card>
  );
}

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

AI Chat

The canonical chat interface — sidebar, streaming messages, prompt input with model selector, and sources.

AI Research Assistant

A research flow that shows search tool calls, reasoning, sources and a cited final answer.

AI Coding Agent

A coding run with agent status, tool calls, diffs and a human-in-the-loop approval gate.

AI Voice Assistant

A voice-first conversation — live waveform states, a recording input, a spoken transcript and a typed fallback.

Model & Memory Preferences

A preferences screen that picks the model, reasoning level and tools, and manages persistent memory.

Artifact Workspace

Chat on the left, generated output on the right — artifacts open from the answer, stream, version, and fail without breaking the conversation.

Structured Extraction & Review

Upload a document, watch fields fill in, then review the flagged ones — per-field confidence, corrections that keep the original, export earned.

Image Generation Studio

A 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 Console

Watch 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 Copilot

Grounded reply drafts with citations, honest low-confidence answers, inline corrections, an approval gate on refunds, and a rating row on every draft.

Generative UI Dashboard

The model assembles a dashboard from a controlled widget registry — streamed props, an unsupported-request fallback, and widget clicks that re-enter the chat.