{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"generative-dashboard","type":"registry:block","title":"Generative UI Dashboard","description":"The model assembles a dashboard from a controlled widget registry — streamed props, an unsupported-request fallback, and widget clicks that re-enter the chat.","author":"Scrim UI (https://scrimui.dev)","categories":["pattern"],"docs":"https://scrimui.dev/patterns/generative-dashboard","dependencies":[],"registryDependencies":["https://scrimui.dev/r/generative-ui.json","https://scrimui.dev/r/tool-call.json","https://scrimui.dev/r/artifact-preview.json","https://scrimui.dev/r/error-message.json","https://scrimui.dev/r/prompt-input.json"],"files":[{"path":"src/showcase/patterns/generative-dashboard/generative-dashboard.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { GenerativeUi } from \"@/components/ui/generative-ui\";\nimport { ToolCall } from \"@/components/ui/tool-call\";\nimport { ArtifactPreview } from \"@/components/ui/artifact-preview\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { PromptInput } from \"@/components/ui/prompt-input\";\n\n/**\n * A dashboard the model assembles from a controlled widget registry.\n *\n * What this pattern exists to show:\n *\n * 1. **The model picks from allowed components, never arbitrary UI.** Metric\n *    card, bar chart, data table, report — that is the whole registry, and\n *    every widget is attributed to the tool call that produced it.\n * 2. **Props stream into a shaped skeleton.** The layout is known before the\n *    data is, so nothing resizes when the numbers arrive.\n * 3. **An unsupported request degrades to prose, not a crash.** The model\n *    asked for a 3D scatter; the registry said no, and the raw result is\n *    still behind the Data toggle.\n * 4. **Widget interaction is conversation.** Clicking a bar sends the filter\n *    back into the chat as a message — the model answers it there.\n * 5. **One broken widget is one broken card.** The table failed on a bad\n *    field name; the other widgets never blinked, and Retry fixes just it.\n */\n\n/* ------------------------------------------------------------------ */\n/* Mock data                                                           */\n/* ------------------------------------------------------------------ */\n\nconst REGIONS = [\n  { label: \"West\", value: 412 },\n  { label: \"East\", value: 368 },\n  { label: \"North\", value: 295 },\n  { label: \"South\", value: 231 },\n];\n\nconst METRICS = [\n  { label: \"Q3 revenue\", value: \"$1.31M\", delta: \"+11% vs Q2\" },\n  { label: \"Deals closed\", value: \"184\", delta: \"+9%\" },\n  { label: \"Avg deal size\", value: \"$7.1k\", delta: \"+2%\" },\n];\n\nconst WEEKS = [\n  { week: \"Jul 6\", revenue: \"$148k\", deals: 21 },\n  { week: \"Jul 13\", revenue: \"$162k\", deals: 24 },\n  { week: \"Jul 20\", revenue: \"$141k\", deals: 19 },\n  { week: \"Jul 27\", revenue: \"$176k\", deals: 26 },\n];\n\nconst REPORT_MD = `# Q3 revenue summary\n\nWest leads at $412k (+8% vs Q2), East close behind at $368k.\nNorth recovered after a slow July; South is flat.\n\nRecommended: shift two East reps to the South pipeline review.`;\n\nconst SCATTER_JSON = `{\n  \"requested\": \"scatter_3d\",\n  \"registry\": [\"metric_card\", \"bar_chart\", \"data_table\", \"report\"],\n  \"result\": { \"points\": 184, \"axes\": [\"deal_size\", \"cycle_days\", \"region\"] }\n}`;\n\n/* ------------------------------------------------------------------ */\n/* Registry widgets — the only components the model may render          */\n/* ------------------------------------------------------------------ */\n\nfunction MetricCard({ label, value, delta }: { label: string; value: string; delta: string }) {\n  return (\n    <div className=\"px-4 py-3\">\n      <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">{label}</p>\n      <p className=\"mt-0.5 text-xl font-semibold tabular-nums text-zinc-900 dark:text-zinc-100\">{value}</p>\n      <p className=\"text-xs font-medium text-teal-600 dark:text-teal-400\">{delta}</p>\n    </div>\n  );\n}\n\nfunction BarChart({ onPick }: { onPick?: (region: string) => void }) {\n  const max = Math.max(...REGIONS.map((r) => r.value));\n  return (\n    <div className=\"space-y-2 px-4 py-3\">\n      <p className=\"text-xs font-medium text-zinc-500 dark:text-zinc-400\">Revenue by region — click a bar to filter</p>\n      {REGIONS.map((r) => (\n        <button\n          key={r.label}\n          type=\"button\"\n          onClick={() => onPick?.(r.label)}\n          className=\"group flex w-full items-center gap-2 text-left\"\n          aria-label={`Filter to ${r.label}, $${r.value}k`}\n        >\n          <span className=\"w-10 text-xs text-zinc-500 dark:text-zinc-400\">{r.label}</span>\n          <span className=\"h-4 rounded bg-zinc-300 transition-colors group-hover:bg-zinc-900 dark:bg-zinc-700 dark:group-hover:bg-zinc-100\" style={{ width: `${(r.value / max) * 70}%` }} />\n          <span className=\"text-xs tabular-nums text-zinc-600 dark:text-zinc-300\">${r.value}k</span>\n        </button>\n      ))}\n    </div>\n  );\n}\n\nfunction DataTable() {\n  return (\n    <table className=\"w-full text-sm\">\n      <thead>\n        <tr className=\"border-b border-zinc-200 text-left text-xs text-zinc-500 dark:border-zinc-800 dark:text-zinc-400\">\n          <th className=\"px-4 py-2 font-medium\">Week</th>\n          <th className=\"px-4 py-2 font-medium\">Revenue</th>\n          <th className=\"px-4 py-2 font-medium\">Deals</th>\n        </tr>\n      </thead>\n      <tbody>\n        {WEEKS.map((w) => (\n          <tr key={w.week} className=\"border-b border-zinc-100 last:border-0 dark:border-zinc-800/60\">\n            <td className=\"px-4 py-2 text-zinc-700 dark:text-zinc-300\">{w.week}</td>\n            <td className=\"px-4 py-2 tabular-nums text-zinc-900 dark:text-zinc-100\">{w.revenue}</td>\n            <td className=\"px-4 py-2 tabular-nums text-zinc-700 dark:text-zinc-300\">{w.deals}</td>\n          </tr>\n        ))}\n      </tbody>\n    </table>\n  );\n}\n\nfunction ReportBody() {\n  return (\n    <div className=\"space-y-2 px-4 py-3 text-sm leading-6 text-zinc-700 dark:text-zinc-300\">\n      <p>West leads at <strong className=\"text-zinc-900 dark:text-zinc-100\">$412k</strong> (+8% vs Q2), East close behind at $368k.</p>\n      <p>North recovered after a slow July; South is flat. Recommended: shift two East reps to the South pipeline review.</p>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\ntype ChatItem =\n  | { id: string; kind: \"user\" | \"assistant\"; text: string }\n  | { id: string; kind: \"tool\"; name: string; input: string; output?: string; status: \"running\" | \"success\"; duration?: string };\n\ntype Widget = {\n  id: string;\n  tool: string;\n  type: \"metric\" | \"chart\" | \"table\" | \"report\" | \"unsupported\";\n  state: \"streaming\" | \"ready\" | \"error\";\n  metricIndex?: number;\n};\n\n/* ------------------------------------------------------------------ */\n/* Pattern                                                             */\n/* ------------------------------------------------------------------ */\n\nexport function GenerativeDashboardPattern() {\n  const [chat, setChat] = React.useState<ChatItem[]>([]);\n  const [widgets, setWidgets] = React.useState<Widget[]>([]);\n  const [submits, setSubmits] = React.useState(0);\n  const [busy, setBusy] = React.useState(false);\n  const [filtered, setFiltered] = React.useState<string | null>(null);\n  const [retrying, setRetrying] = React.useState(false);\n\n  const timers = React.useRef<number[]>([]);\n  React.useEffect(() => {\n    const t = timers.current;\n    return () => t.forEach(clearTimeout);\n  }, []);\n  function later(ms: number, fn: () => void) {\n    timers.current.push(window.setTimeout(fn, ms));\n  }\n\n  function push(...items: ChatItem[]) {\n    setChat((c) => [...c, ...items]);\n  }\n\n  function setWidgetState(id: string, state: Widget[\"state\"]) {\n    setWidgets((ws) => ws.map((w) => (w.id === id ? { ...w, state } : w)));\n  }\n\n  function submit(text: string) {\n    if (busy) return;\n    const n = submits + 1;\n    setSubmits(n);\n    setBusy(true);\n    push({ id: `u${n}`, kind: \"user\", text });\n\n    if (n === 1) {\n      push({ id: \"t1\", kind: \"tool\", name: \"query_metrics\", input: '{ metric: \"revenue\", by: \"region\", quarter: \"Q3\" }', status: \"running\" });\n      later(1000, () => {\n        setChat((c) =>\n          c.map((item) =>\n            item.id === \"t1\" && item.kind === \"tool\"\n              ? { ...item, status: \"success\", output: '{ regions: 4, total: \"$1.31M\" }', duration: \"0.8s\" }\n              : item,\n          ),\n        );\n        push({ id: \"a1\", kind: \"assistant\", text: \"Q3 revenue is $1.31M, up 11% on Q2. West leads — I put the breakdown on the canvas, plus a written summary you can copy out.\" });\n        setWidgets([\n          { id: \"m0\", tool: \"query_metrics\", type: \"metric\", state: \"streaming\", metricIndex: 0 },\n          { id: \"m1\", tool: \"query_metrics\", type: \"metric\", state: \"streaming\", metricIndex: 1 },\n          { id: \"m2\", tool: \"query_metrics\", type: \"metric\", state: \"streaming\", metricIndex: 2 },\n          { id: \"c1\", tool: \"query_metrics\", type: \"chart\", state: \"streaming\" },\n          { id: \"r1\", tool: \"write_report\", type: \"report\", state: \"streaming\" },\n        ]);\n      });\n      later(1700, () => [\"m0\", \"m1\", \"m2\"].forEach((id) => setWidgetState(id, \"ready\")));\n      later(2200, () => {\n        setWidgetState(\"c1\", \"ready\");\n        setWidgetState(\"r1\", \"ready\");\n        setBusy(false);\n      });\n    } else if (n === 2) {\n      later(700, () => {\n        push({\n          id: \"a2\",\n          kind: \"assistant\",\n          text: \"I can't render that one: a 3D scatter isn't in this dashboard's registry, so I won't fake it. The raw result is below — the deal-size vs cycle-days view works as a table if you want it.\",\n        });\n        setWidgets((ws) => [...ws, { id: \"x1\", tool: \"render_widget\", type: \"unsupported\", state: \"ready\" }]);\n        setBusy(false);\n      });\n    } else if (n === 3) {\n      setWidgets((ws) => [...ws, { id: \"tb1\", tool: \"weekly_breakdown\", type: \"table\", state: \"streaming\" }]);\n      later(1100, () => {\n        setWidgetState(\"tb1\", \"error\");\n        push({ id: \"a3\", kind: \"assistant\", text: \"The weekly table failed to render — the tool returned the data under a field the widget didn't expect. Retry it; nothing else on the canvas is affected.\" });\n        setBusy(false);\n      });\n    } else {\n      later(600, () => {\n        push({ id: `a${n}`, kind: \"assistant\", text: \"Nothing new to add — the canvas already covers revenue by region, the weekly breakdown and the summary report. Ask for a different cut if you want another widget.\" });\n        setBusy(false);\n      });\n    }\n  }\n\n  function retryTable() {\n    setRetrying(true);\n    setWidgetState(\"tb1\", \"streaming\");\n    later(1400, () => {\n      setWidgetState(\"tb1\", \"ready\");\n      setRetrying(false);\n      push({ id: \"a3r\", kind: \"assistant\", text: \"Fixed — the weekly breakdown is on the canvas now. July 27 was the strongest week at $176k across 26 deals.\" });\n    });\n  }\n\n  function pickRegion(region: string) {\n    if (filtered === region || busy) return;\n    setFiltered(region);\n    push({ id: `uf-${region}`, kind: \"user\", text: `Filter: ${region}` });\n    later(700, () => {\n      push({\n        id: `af-${region}`,\n        kind: \"assistant\",\n        text: `Filtered to ${region}: $412k revenue, up 8% vs Q2, with the shortest sales cycle of any region at 23 days.`,\n      });\n    });\n  }\n\n  return (\n    <div className=\"flex h-[640px] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900\">\n      {/* Conversation drives the canvas */}\n      <div className=\"flex w-[320px] shrink-0 flex-col border-r border-zinc-200 dark:border-zinc-800 max-md:w-full\">\n        <div className=\"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\">Analytics copilot</p>\n          <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">Registry: metric card · bar chart · data table · report</p>\n        </div>\n\n        <div className=\"flex-1 space-y-3 overflow-y-auto px-3 py-3\">\n          {chat.length === 0 && (\n            <div className=\"rounded-xl border border-dashed border-zinc-300 px-3 py-5 text-center dark:border-zinc-700\">\n              <p className=\"text-sm font-medium text-zinc-700 dark:text-zinc-200\">Ask for a number, get a dashboard</p>\n              <p className=\"mt-1 text-xs text-zinc-500 dark:text-zinc-400\">\n                Try: “Revenue by region for Q3”, then “Show a 3D scatter of reps”, then “Break it down by week”.\n              </p>\n            </div>\n          )}\n          {chat.map((item) =>\n            item.kind === \"user\" ? (\n              <div key={item.id} className=\"flex justify-end\">\n                <p className=\"max-w-[90%] rounded-2xl rounded-br-md bg-zinc-900 px-3.5 py-2 text-sm leading-6 text-white dark:bg-zinc-100 dark:text-zinc-900\">\n                  {item.text}\n                </p>\n              </div>\n            ) : item.kind === \"tool\" ? (\n              <ToolCall key={item.id} name={item.name} input={item.input} output={item.output} status={item.status} duration={item.duration} />\n            ) : (\n              <p key={item.id} className=\"rounded-2xl rounded-tl-md border border-zinc-200 bg-white px-3.5 py-2 text-sm leading-6 text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100\">\n                {item.text}\n              </p>\n            ),\n          )}\n        </div>\n\n        <div className=\"border-t border-zinc-200 p-3 dark:border-zinc-800\">\n          <PromptInput onSubmit={submit} placeholder=\"Ask for a metric…\" loading={busy} />\n        </div>\n      </div>\n\n      {/* Canvas — only registry widgets may appear here */}\n      <div className=\"flex-1 overflow-y-auto bg-zinc-50 p-4 dark:bg-zinc-950 max-md:hidden\">\n        {widgets.length === 0 ? (\n          <div className=\"flex h-full items-center justify-center\">\n            <p className=\"text-sm text-zinc-400 dark:text-zinc-500\">The canvas is empty — widgets land here as the model renders them.</p>\n          </div>\n        ) : (\n          <div className=\"grid grid-cols-2 gap-3 lg:grid-cols-3\">\n            {widgets.map((w) =>\n              w.type === \"metric\" ? (\n                <GenerativeUi\n                  key={w.id}\n                  tool={w.tool}\n                  state={w.state === \"ready\" ? \"ready\" : \"streaming\"}\n                  skeleton={\n                    <div className=\"space-y-2 px-4 py-3\">\n                      <div className=\"h-3 w-20 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800\" />\n                      <div className=\"h-6 w-24 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800\" />\n                      <div className=\"h-3 w-14 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800\" />\n                    </div>\n                  }\n                >\n                  <MetricCard {...METRICS[w.metricIndex ?? 0]} />\n                </GenerativeUi>\n              ) : w.type === \"chart\" ? (\n                <div key={w.id} className=\"col-span-2\">\n                  <GenerativeUi\n                    tool={w.tool}\n                    state={w.state === \"ready\" ? \"ready\" : \"streaming\"}\n                    skeleton={\n                      <div className=\"space-y-2.5 px-4 py-3\">\n                        {[70, 62, 50, 38].map((pct) => (\n                          <div key={pct} className=\"h-4 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800\" style={{ width: `${pct}%` }} />\n                        ))}\n                      </div>\n                    }\n                  >\n                    <BarChart onPick={pickRegion} />\n                  </GenerativeUi>\n                </div>\n              ) : w.type === \"table\" ? (\n                <div key={w.id} className=\"col-span-2\">\n                  <GenerativeUi\n                    tool={w.tool}\n                    state={w.state === \"streaming\" ? \"streaming\" : \"ready\"}\n                    skeleton={\n                      <div className=\"space-y-2 px-4 py-3\">\n                        {[0, 1, 2, 3].map((i) => (\n                          <div key={i} className=\"h-4 w-full animate-pulse rounded bg-zinc-200 dark:bg-zinc-800\" />\n                        ))}\n                      </div>\n                    }\n                  >\n                    {w.state === \"error\" ? (\n                      <div className=\"p-3\">\n                        <ErrorMessage\n                          title=\"Widget failed to render\"\n                          message=\"rows is undefined — the tool returned weekly data under `weeks`, not `rows`.\"\n                          onRetry={retryTable}\n                          retrying={retrying}\n                        />\n                      </div>\n                    ) : (\n                      <DataTable />\n                    )}\n                  </GenerativeUi>\n                </div>\n              ) : w.type === \"report\" ? (\n                <div key={w.id} className=\"col-span-2 lg:col-span-3\">\n                  <ArtifactPreview\n                    title=\"Q3 revenue summary\"\n                    type=\"document\"\n                    status={w.state === \"ready\" ? \"ready\" : \"streaming\"}\n                    preview={<ReportBody />}\n                    code={REPORT_MD}\n                    language=\"Markdown\"\n                  />\n                </div>\n              ) : (\n                <GenerativeUi\n                  key={w.id}\n                  tool={w.tool}\n                  state=\"unsupported\"\n                  fallback={\n                    <span>\n                      The registry has no <code className=\"rounded bg-zinc-100 px-1 dark:bg-zinc-800\">scatter_3d</code> widget — the model\n                      asked for one anyway. Refused, not improvised: the raw result is under Data.\n                    </span>\n                  }\n                  data={SCATTER_JSON}\n                />\n              ),\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n","type":"registry:block","target":"components/blocks/generative-dashboard.tsx"}]}