{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"rag-workspace","type":"registry:block","title":"Document Q&A Workspace","description":"Ask your own documents — upload and parse, cited answers with inspectable passages, an honest not-found state, and a visible context budget.","author":"Scrim UI (https://scrimui.dev)","categories":["pattern"],"docs":"https://scrimui.dev/patterns/rag-workspace","dependencies":[],"registryDependencies":["https://scrimui.dev/r/file-upload.json","https://scrimui.dev/r/context-files.json","https://scrimui.dev/r/source-list.json","https://scrimui.dev/r/citation-ui.json","https://scrimui.dev/r/context-usage.json","https://scrimui.dev/r/prompt-input.json","https://scrimui.dev/r/streaming-message.json"],"files":[{"path":"src/showcase/patterns/rag-workspace/rag-workspace.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { FileUpload, type FileUploadStatus } from \"@/components/ui/file-upload\";\nimport { ContextFiles } from \"@/components/ui/context-files\";\nimport { SourceList, type RetrievedSource } from \"@/components/ui/source-list\";\nimport { CitationList, type Citation } from \"@/components/ui/citation-ui\";\nimport { ContextUsage } from \"@/components/ui/context-usage\";\nimport { PromptInput } from \"@/components/ui/prompt-input\";\nimport { StreamingMessage } from \"@/components/ui/streaming-message\";\n\n/**\n * \"Ask your own documents\" — the RAG workspace.\n *\n * What this pattern exists to show:\n *\n * 1. **An answer is only as good as its sources.** Every grounded answer\n *    carries citations, and the retrieved passages (with scores and the\n *    floor) are inspectable — trust is shown, not asserted.\n * 2. **\"Not found\" is a first-class answer.** When nothing scores above the\n *    floor, the workspace says so and shows what was considered, instead of\n *    letting the model guess.\n * 3. **Context is a budget.** The usage bar turns the context window into\n *    something the user can spend deliberately — a big upload visibly moves\n *    it toward the limit.\n * 4. **Removing a document has consequences.** If an existing answer cited\n *    it, the workspace says which answers just lost their grounding.\n *\n * Boundary with the Pro RAG template: this is mock state and a scripted\n * flow. Ingestion, chunking, embeddings and streaming citation offsets are\n * the template's engineering, not this file's.\n */\n\n/* ------------------------------------------------------------------ */\n/* Script                                                              */\n/* ------------------------------------------------------------------ */\n\ntype Doc = {\n  id: string;\n  name: string;\n  size: string;\n  tokens: number;\n  status: \"parsing\" | \"ready\" | \"failed\";\n};\n\ntype Turn = {\n  id: number;\n  role: \"user\" | \"assistant\";\n  text: string;\n  kind?: \"cited\" | \"not-found\" | \"generic\";\n};\n\nconst WINDOW_TOKENS = 128_000;\nconst RESERVE_TOKENS = 4_000;\nconst SYSTEM_TOKENS = 1_800;\nconst SCORE_FLOOR = 0.5;\n\nconst INITIAL_DOCS: Doc[] = [\n  { id: \"d1\", name: \"employee-handbook.pdf\", size: \"2.1 MB\", tokens: 12_400, status: \"ready\" },\n];\n\nconst HANDBOOK_CITATIONS: Citation[] = [\n  {\n    id: 1,\n    title: \"employee-handbook.pdf · Vacation policy, p.12\",\n    url: \"#handbook-p12\",\n    snippet: \"Full-time employees accrue 15 days of paid vacation per calendar year.\",\n  },\n  {\n    id: 2,\n    title: \"employee-handbook.pdf · Rollover rules, p.13\",\n    url: \"#handbook-p13\",\n    snippet: \"Up to 5 unused vacation days roll over and must be used before March 31.\",\n  },\n  {\n    id: 3,\n    title: \"employee-handbook.pdf · Company calendar, p.4\",\n    url: \"#handbook-p4\",\n    snippet: \"The December shutdown week is paid and does not count against vacation balance.\",\n  },\n];\n\nconst CITED_SOURCES: RetrievedSource[] = [\n  { 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 },\n  { 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 },\n  { 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 },\n  { 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 },\n];\n\nconst NOT_FOUND_SOURCES: RetrievedSource[] = [\n  { id: \"s5\", title: \"employee-handbook.pdf · p.30\", passage: \"New hires receive a laptop and access credentials on their first day.\", score: 0.34 },\n  { id: \"s6\", title: \"employee-handbook.pdf · p.18\", passage: \"Business travel expenses are reimbursed within 14 days of report submission.\", score: 0.29 },\n  { id: \"s7\", title: \"employee-handbook.pdf · p.7\", passage: \"The office is open from 8am to 7pm on weekdays.\", score: 0.18 },\n];\n\nconst ANSWER_CITED =\n  \"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].\";\nconst ANSWER_NOT_FOUND =\n  \"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.\";\nconst ANSWER_GENERIC =\n  \"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.\";\n\n/* ------------------------------------------------------------------ */\n/* Pattern                                                             */\n/* ------------------------------------------------------------------ */\n\nexport function RagWorkspacePattern() {\n  const [docs, setDocs] = React.useState<Doc[]>(INITIAL_DOCS);\n  const [turns, setTurns] = React.useState<Turn[]>([]);\n  const [pending, setPending] = React.useState<{ text: string; kind: Turn[\"kind\"] } | null>(null);\n  const [removedNotice, setRemovedNotice] = React.useState<string | null>(null);\n  const [upload, setUpload] = React.useState<{ name: string; size: string; status: FileUploadStatus; progress: number } | null>(null);\n  const [docsOpen, setDocsOpen] = React.useState(false);\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const idRef = React.useRef(1);\n\n  React.useEffect(() => {\n    const el = scrollRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, [turns, pending]);\n\n  const readyDocs = docs.filter((d) => d.status === \"ready\");\n  const convoTokens = 900 + turns.length * 420;\n  const usedTokens = SYSTEM_TOKENS + convoTokens + readyDocs.reduce((s, d) => s + d.tokens, 0);\n  const nearLimit = usedTokens / WINDOW_TOKENS > 0.8;\n\n  function submit(value: string) {\n    setTurns((t) => [...t, { id: idRef.current++, role: \"user\", text: value }]);\n    const step = turns.filter((t) => t.role === \"assistant\").length;\n    window.setTimeout(() => {\n      if (readyDocs.length === 0) {\n        setPending({ text: ANSWER_NOT_FOUND, kind: \"not-found\" });\n      } else if (step === 0) {\n        setPending({ text: ANSWER_CITED, kind: \"cited\" });\n      } else if (step === 1) {\n        setPending({ text: ANSWER_NOT_FOUND, kind: \"not-found\" });\n      } else {\n        setPending({ text: ANSWER_GENERIC, kind: \"generic\" });\n      }\n    }, 450);\n  }\n\n  function onStreamComplete() {\n    if (!pending) return;\n    setTurns((t) => [...t, { id: idRef.current++, role: \"assistant\", text: pending.text, kind: pending.kind }]);\n    setPending(null);\n  }\n\n  /** Real file names, simulated parse — the pattern never reads file bytes. */\n  function onSelect(files: FileList | null) {\n    if (!files || files.length === 0) return;\n    const file = files[0];\n    const size = file.size > 1_000_000 ? `${(file.size / 1_000_000).toFixed(1)} MB` : `${Math.max(1, Math.round(file.size / 1000))} KB`;\n    setUpload({ name: file.name, size, status: \"uploading\", progress: 0 });\n\n    let p = 0;\n    const timer = window.setInterval(() => {\n      p = Math.min(100, p + 14);\n      setUpload((u) => (u ? { ...u, progress: p } : u));\n      if (p >= 100) {\n        window.clearInterval(timer);\n        const tokens = Math.min(96_000, Math.max(2_000, Math.round(file.size / 40)));\n        setDocs((d) => [...d, { id: `d${idRef.current++}`, name: file.name, size, tokens, status: \"ready\" }]);\n        setUpload(null);\n      }\n    }, 160);\n  }\n\n  function removeDoc(doc: Doc) {\n    setDocs((ds) => ds.filter((d) => d.id !== doc.id));\n    const cited = turns.some((t) => t.kind === \"cited\");\n    if (doc.id === \"d1\" && cited) {\n      setRemovedNotice(`\"${doc.name}\" was removed — the vacation answer above cited it and is no longer grounded.`);\n    } else {\n      setRemovedNotice(`\"${doc.name}\" was removed from context.`);\n    }\n  }\n\n  const docsPanel = (\n    <div className=\"flex h-full flex-col gap-3 overflow-y-auto p-3\">\n      <p className=\"text-[13px] font-semibold text-zinc-900 dark:text-zinc-100\">Documents</p>\n      <FileUpload\n        status={upload?.status ?? \"idle\"}\n        progress={upload?.progress}\n        fileName={upload?.name}\n        fileSize={upload?.size}\n        accept=\".pdf,.md,.txt,.csv\"\n        onSelect={onSelect}\n        onRemove={() => setUpload(null)}\n      />\n      <ContextFiles\n        title=\"In context\"\n        files={readyDocs.map((d) => ({ name: d.name, detail: `${d.size} · ≈ ${(d.tokens / 1000).toFixed(1)}k tokens` }))}\n        onRemove={(name) => {\n          const doc = docs.find((d) => d.name === name);\n          if (doc) removeDoc(doc);\n        }}\n      />\n      <ContextUsage\n        window={WINDOW_TOKENS}\n        reserve={RESERVE_TOKENS}\n        estimated\n        segments={[\n          { label: \"System\", tokens: SYSTEM_TOKENS },\n          ...readyDocs.map((d, i) => ({ label: d.name, tokens: d.tokens, evictionRank: i + 2 })),\n          { label: \"Conversation\", tokens: convoTokens, evictionRank: 1 },\n        ]}\n      />\n      {nearLimit && (\n        <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\">\n          Context is nearly full — the oldest documents are evicted first when it overflows.\n        </p>\n      )}\n    </div>\n  );\n\n  return (\n    <div className=\"relative flex h-[640px] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900\">\n      {/* Documents rail */}\n      <aside className=\"hidden w-64 shrink-0 border-r border-zinc-200 dark:border-zinc-800 md:block\">\n        {docsPanel}\n      </aside>\n\n      {/* Q&A */}\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <div className=\"flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800\">\n          <div className=\"min-w-0\">\n            <p className=\"text-sm font-semibold text-zinc-900 dark:text-zinc-100\">Document Q&amp;A</p>\n            <p className=\"truncate text-xs text-zinc-500 dark:text-zinc-400\">\n              Answers only from your documents, with receipts\n            </p>\n          </div>\n          <button\n            type=\"button\"\n            onClick={() => setDocsOpen(true)}\n            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\"\n          >\n            Documents ({readyDocs.length})\n          </button>\n        </div>\n\n        <div ref={scrollRef} className=\"flex-1 space-y-5 overflow-y-auto px-4 py-5\">\n          {turns.length === 0 && !pending && (\n            <p className=\"pt-16 text-center text-[13px] leading-6 text-zinc-400 dark:text-zinc-500\">\n              Ask about the vacation policy — the answer cites its passages.\n              <br />\n              Then ask something the handbook doesn&apos;t cover.\n            </p>\n          )}\n\n          {removedNotice && (\n            <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\">\n              <span>{removedNotice}</span>\n              <button\n                type=\"button\"\n                onClick={() => setRemovedNotice(null)}\n                aria-label=\"Dismiss\"\n                className=\"shrink-0 rounded px-1 hover:bg-amber-100 dark:hover:bg-amber-900/50\"\n              >\n                ✕\n              </button>\n            </div>\n          )}\n\n          {turns.map((t) =>\n            t.role === \"user\" ? (\n              <div key={t.id} className=\"flex justify-end\">\n                <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\">\n                  {t.text}\n                </div>\n              </div>\n            ) : (\n              <div key={t.id}>\n                <StreamingMessage text={t.text} />\n                {t.kind === \"cited\" && (\n                  <div className=\"mt-2\">\n                    <CitationList citations={HANDBOOK_CITATIONS} />\n                  </div>\n                )}\n                {(t.kind === \"cited\" || t.kind === \"not-found\") && (\n                  <details className=\"mt-2 rounded-xl border border-zinc-200 dark:border-zinc-800\">\n                    <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\">\n                      {t.kind === \"cited\" ? \"Retrieved passages (3 of 4 used)\" : \"Nothing passed the relevance floor\"}\n                    </summary>\n                    <div className=\"border-t border-zinc-100 p-2 dark:border-zinc-800\">\n                      <SourceList sources={t.kind === \"cited\" ? CITED_SOURCES : NOT_FOUND_SOURCES} floor={SCORE_FLOOR} />\n                    </div>\n                  </details>\n                )}\n              </div>\n            ),\n          )}\n\n          {pending && (\n            <StreamingMessage text={pending.text} isStreaming speed={2} onComplete={onStreamComplete} />\n          )}\n        </div>\n\n        <div className=\"border-t border-zinc-200 px-4 py-3 dark:border-zinc-800\">\n          <PromptInput\n            placeholder={readyDocs.length === 0 ? \"Upload a document first…\" : \"Ask your documents…\"}\n            onSubmit={submit}\n          />\n        </div>\n      </div>\n\n      {/* Documents as an overlay on narrow screens */}\n      {docsOpen && (\n        <div className=\"absolute inset-0 z-10 bg-white dark:bg-zinc-900 md:hidden\">\n          <div className=\"flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800\">\n            <p className=\"text-sm font-semibold text-zinc-900 dark:text-zinc-100\">Documents</p>\n            <button\n              type=\"button\"\n              onClick={() => setDocsOpen(false)}\n              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\"\n            >\n              Back to chat\n            </button>\n          </div>\n          {docsPanel}\n        </div>\n      )}\n    </div>\n  );\n}\n","type":"registry:block","target":"components/blocks/rag-workspace.tsx"}]}