From d8b7499cae93269a2e2df000ab4606410a6183c9 Mon Sep 17 00:00:00 2001
From: aadesh18 <110230993+aadesh18@users.noreply.github.com>
Date: Thu, 23 Apr 2026 17:34:12 -0700
Subject: [PATCH] Custom Dashboard Improvements (#1359)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR introduces a context chip bar for the AI dashboard chat panel —
users can click widgets in the sandbox iframe, trigger "Add a
component", or auto-capture runtime errors as chips that are prepended
to the next AI request. It also adds a patchDashboard tool for surgical
find-and-replace edits alongside the existing updateDashboard, streaming
dashboard generation, and an improved system prompt with hook-ordering
guidance for generated components.
---
apps/backend/src/lib/ai/prompts.ts | 177 ++++++++++++++--
.../src/lib/ai/tools/create-dashboard.ts | 21 +-
apps/backend/src/lib/ai/tools/index.ts | 11 +-
.../dashboards/[dashboardId]/page-client.tsx | 183 +++++++++++++----
.../src/components/assistant-ui/thread.tsx | 8 +-
.../dashboard-sandbox-host.tsx | 192 ++++++++++++++++--
.../components/vibe-coding/assistant-chat.tsx | 4 +
.../components/vibe-coding/chat-adapters.ts | 190 ++++++++++++++++-
.../vibe-coding/dashboard-tool-components.tsx | 158 ++++++++++++++
9 files changed, 862 insertions(+), 82 deletions(-)
diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts
index cafef7f15..a0537af5c 100644
--- a/apps/backend/src/lib/ai/prompts.ts
+++ b/apps/backend/src/lib/ai/prompts.ts
@@ -517,33 +517,64 @@ follow these rules without exception:
5. Before finishing the code, mentally re-order your hooks and confirm the count and order are
identical on every possible render path.
-CANONICAL BAD EXAMPLE (crashes with React error #310):
+CANONICAL SHAPE OF EVERY Dashboard COMPONENT:
function Dashboard() {
- const [users, setUsers] = React.useState(null);
- if (!users) {
- return ; // ← early return BEFORE the next hook
- }
- const [filter, setFilter] = React.useState(""); // ← this hook is skipped on first render
- React.useEffect(() => { ... }, []); // ← and this one
- return
...
;
- }
-
-CANONICAL GOOD EXAMPLE:
- function Dashboard() {
- // All hooks first. Unconditional. Same count every render.
+ // 1) ALL hooks first. Unconditional. Same count every render.
+ // Includes React.useState / useEffect / useCallback / useMemo / useRef
+ // AND every DashboardUI.use* (useDataSource, etc).
const [users, setUsers] = React.useState(null);
const [filter, setFilter] = React.useState("");
const [error, setError] = React.useState(null);
React.useEffect(() => { ... }, []);
- // Conditional rendering AFTER all hooks:
+ // 2) Conditional rendering happens ONLY AFTER every hook has run.
if (error) return ;
if (!users) return ;
return
...
;
}
-If you catch yourself writing \`if (...) return ...\` anywhere above a \`React.useXxx\` call,
-STOP and move the return below every hook.
+Mental check before you emit: scan your Dashboard function top-to-bottom. If ANY line
+starting with \`use\` (React.useX, DashboardUI.useX, or any custom useX) sits BELOW a
+\`return\`, an \`if\`, a ternary, or a loop, the code is wrong — move the hook up.
+
+CUSTOM HOOKS COUNT TOO — \`DashboardUI.useDataSource\` IS A HOOK
+─────────────────────────────────────────────────────────────────
+Anything starting with \`use\` is a hook, regardless of namespace. \`DashboardUI.useDataSource\`
+and any other \`use*\` from \`DashboardUI\` follow the SAME rules as \`React.useState\` — they
+MUST be called unconditionally at the top of the component, before any \`if\` / early
+\`return\` / ternary / loop. The \`DashboardUI\` namespace does NOT exempt them.
+
+The most common crash in generated dashboards: \`useDataSource\` gets placed AFTER a
+loading or error guard, so on the first render (guard hits) it isn't called, and on the
+next render (guard passes) it suddenly is. Hook count changes between renders → React
+error #310. NEVER put \`useDataSource\` below an early return.
+
+ // ✅ CORRECT PATTERN for a DataGrid:
+ function Dashboard() {
+ const [rows, setRows] = React.useState(null);
+ const [error, setError] = React.useState(null);
+ const [gridState, setGridState] = React.useState(DashboardUI.createDefaultDataGridState());
+ React.useEffect(() => {
+ stackServerApp.listUsers({ includeAnonymous: true, limit: 500 })
+ .then(setRows)
+ .catch((e) => setError(String(e)));
+ }, []);
+ const gridData = DashboardUI.useDataSource({ // ← always called
+ data: rows ?? [], // ← tolerate null pre-load
+ columns: [/* ... */],
+ getRowId: (u) => u.id,
+ sorting: gridState.sorting,
+ quickSearch: gridState.quickSearch,
+ pagination: gridState.pagination,
+ paginationMode: "client",
+ });
+ if (error) return
{error}
;
+ if (rows == null) return ;
+ return ;
+ }
+
+The rule: ALL hooks first (including \`useDataSource\`), THEN the conditional returns.
+Pass \`data: rows ?? []\` so \`useDataSource\` is safe to call while \`rows\` is still null.
────────────────────────────────────────
EDITING BEHAVIOR (when existing code is provided)
@@ -552,7 +583,108 @@ EDITING BEHAVIOR (when existing code is provided)
- Always preserve parts of the dashboard the user didn't ask to change.
- If the user asks to add something, add it without removing existing content.
- If the user asks to change styling, colors, or layout, make those changes while preserving functionality.
-- Always call the updateDashboard tool with the COMPLETE updated source code — no partial code or diffs.
+
+CHOOSING THE RIGHT TOOL — patchDashboard vs. updateDashboard
+You have TWO write tools. Pick the right one. Wrong choice wastes tokens and time —
+or worse, breaks the layout.
+
+The decision is NOT just about size. It's about whether the change is LOCAL to one
+element's own attributes, or whether it ripples through surrounding layout / sibling
+positioning / shared state.
+
+- patchDashboard — for changes that are LOCAL to one element and don't affect siblings.
+ Use it for:
+ * Rename a label or heading
+ * Change a color, className, or style on one element
+ * Swap an icon
+ * Tweak one prop value (e.g. limit: 100 → 500)
+ * Adjust one chart's config (axis label, color, format)
+ * Fix a single bug in one function or hook body
+ * Add or remove ONE self-contained leaf component (a badge, an icon)
+
+ How it works: \`{ edits: [{ oldText, newText, occurrenceIndex? }, ...] }\` — each edit
+ is a literal find-and-replace on the CURRENT source.
+ * \`oldText\` MUST appear verbatim in the current source — copy it character-for-character
+ including whitespace and line breaks. No paraphrasing, no normalization.
+ * Include enough surrounding context in \`oldText\` to make the match UNIQUE. If the same
+ snippet appears more than once, either expand \`oldText\` to disambiguate OR set
+ \`occurrenceIndex\` (0-indexed) to pick the Nth match.
+ * \`newText\` is the replacement. Use \`""\` to delete.
+ * Batch related edits in ONE call (up to 20). Edits apply in order; later edits see the
+ result of earlier ones.
+ * If a single \`oldText\` block can hold all your changes, prefer one large edit over
+ many small ones — fewer matching failures.
+
+- updateDashboard — for changes that affect LAYOUT, ORDERING, or REGIONS, even if the
+ user pointed at a single component. Re-emit the full source.
+ Use it for:
+ * REORDERING components — moving a card up/down, swapping two charts, changing the
+ sequence of rows. The grid's child order matters; sibling indices shift.
+ * RESIZING — making a card wider/taller, changing grid-cols-2 to grid-cols-3,
+ adjusting a chart's height, changing col-span/row-span. Affects siblings'
+ positioning in the same grid.
+ * MOVING a component to a different parent or section.
+ * Adding a NEW row or column to a grid (sibling positions change).
+ * Layout overhauls (flex → grid, single column → two-column).
+ * Replacing the data model or switching the entire theme.
+ * Initial creation of a brand-new dashboard.
+
+ The rule of thumb: ask "would this change break or shift any neighboring component?"
+ If yes → updateDashboard. If the change is purely cosmetic on the targeted element
+ itself → patchDashboard.
+
+ Why: patchDashboard is a literal text replacement. It can't reason about JSX siblings,
+ grid templates, or array order. A "move this card up" patch that just swaps two JSX
+ blocks usually breaks because the surrounding structure (commas, fragment boundaries,
+ conditional renders) doesn't survive a naive swap.
+
+ Do NOT use updateDashboard for purely cosmetic edits. Rewriting 3000 tokens to change
+ one className is wrong; emit a patchDashboard with one edit instead.
+
+WIDGET CONTEXT FROM THE USER
+When the user prefixes their message with a block like:
+ [Widget: User Signups]
+ Path: div.grid > div:nth-of-type(2) > h3
+ HTML:
User Signups
+they have clicked a specific element in the running dashboard. The HTML is a verbatim
+slice of the rendered DOM and almost always contains text that also appears in the JSX
+source — use it as your \`oldText\` anchor when patching. The Path describes the
+element's position in the render tree, useful for disambiguation when the HTML alone
+repeats.
+
+A widget pointer narrows the TARGET, but it does NOT decide the tool. Apply the same
+rule above: if the user wants to restyle/rename the targeted widget → patchDashboard.
+If the user wants to reorder, resize, move, or restructure the layout around the widget
+→ updateDashboard. "Make this card bigger" affects the grid → updateDashboard, even
+though the user only pointed at one card.
+
+ACTION INTENTS FROM THE USER
+The user may include a block like:
+ [Action: Add a new component to the dashboard]
+This is a structural intent — the user wants something NEW added. Adding a component
+shifts sibling positions in the layout grid and is exactly the case that updateDashboard
+handles (per the rule above: structural change → full rewrite). Do NOT try to express
+"add a card" as a patchDashboard with a JSX fragment insertion — JSX commas, parent
+containers, and grid template-cols may all need adjustment together. Re-emit the full
+source via updateDashboard with the new component placed sensibly in the existing
+layout, preserving everything else.
+
+The user's typed text describes WHAT to add ("a metric card for active users",
+"a chart of weekly signups"). Combine the action intent with the typed text to decide
+what to build.
+
+RUNTIME ERROR REPORTS FROM THE USER
+The user may include a block like:
+ [Error: The dashboard crashed at runtime — please diagnose and fix.]
+ Message:
+ Stack:
+ Component stack:
+The dashboard threw at runtime. Localize the bug from the stack and component stack,
+identify the smallest possible fix in the source, and apply it. If the fix is a
+one-line change (a typo, a wrong prop name, a missing null check), use patchDashboard.
+If the fix requires restructuring (a hook ordering bug, a malformed JSX tree), use
+updateDashboard. Either way, preserve the rest of the dashboard. Don't strip out
+features the user didn't ask to remove just because they're near the crash site.
────────────────────────────────────────
CORE DATA FETCHING RULES (STACK)
@@ -974,11 +1106,14 @@ PRE-EMIT CHECKLIST (RUN THIS IN YOUR HEAD BEFORE CALLING updateDashboard)
Before you call updateDashboard, silently walk through these four checks. If any fails, fix it
FIRST and re-run the list.
- [1] HOOK ORDER — Are all \`React.useState\` / \`React.useEffect\` / \`React.useCallback\` calls at
+ [1] HOOK ORDER — Is EVERY call starting with \`use\` (React.useState / useEffect /
+ useCallback / useMemo / useRef AND every DashboardUI.use* including useDataSource) at
the top of the Dashboard component, before every \`if\` / early \`return\` / conditional?
- If no, move them up. This prevents React error #310. Also check that any variable
- referenced inside a hook initializer (e.g. \`useState(() => foo(columns))\`) is declared
- ABOVE that hook — a TDZ error looks like a hook-order crash but isn't one.
+ If no, move them up — this is the #1 cause of React error #310 in generated dashboards.
+ For \`useDataSource\` specifically: pass \`data: rows ?? []\` so the hook is safe to call
+ while data is still loading, then guard on \`rows == null\` AFTER the hook. Also check
+ that any variable referenced inside a hook initializer (e.g. \`useState(() => foo(columns))\`)
+ is declared ABOVE that hook — a TDZ error looks like a hook-order crash but isn't one.
[2] DATA HONESTY — Does every field the code references actually exist in the SDK types or
ClickHouse schema shown in context? No made-up field names, no hardcoded sample arrays,
diff --git a/apps/backend/src/lib/ai/tools/create-dashboard.ts b/apps/backend/src/lib/ai/tools/create-dashboard.ts
index 380a8eb27..275337924 100644
--- a/apps/backend/src/lib/ai/tools/create-dashboard.ts
+++ b/apps/backend/src/lib/ai/tools/create-dashboard.ts
@@ -10,9 +10,28 @@ import { z } from "zod";
*/
export function updateDashboardTool(auth: SmartRequestAuth | null) {
return tool({
- description: "Update the dashboard with new source code. The source code must define a React functional component named 'Dashboard' (no props). It runs inside a sandboxed iframe with React, Recharts, DashboardUI, and stackServerApp available as globals. No imports, exports, or require statements.",
+ description: "Replace the entire dashboard source. Use ONLY for initial creation or large structural rewrites that touch most of the file. For any change smaller than ~30% of the file, use patchDashboard instead. The source must define a React functional component named 'Dashboard' (no props). Runs in a sandboxed iframe with React, Recharts, DashboardUI, and stackServerApp as globals. No imports, exports, or require statements.",
inputSchema: z.object({
content: z.string().describe("The complete updated JSX source code for the Dashboard component"),
}),
});
}
+
+/**
+ * Tool for surgical edits to the existing dashboard source.
+ *
+ * Like updateDashboardTool, this is inert server-side - the call streams back to the
+ * client, which applies the patches against currentTsxSource and updates state.
+ */
+export function patchDashboardTool(auth: SmartRequestAuth | null) {
+ return tool({
+ description: "Apply one or more surgical text edits to the existing dashboard source. Prefer this over updateDashboard for any change smaller than ~30% of the file (rename, restyle, add/remove a single component, fix one bug). Each edit is a literal find-and-replace on the current source. Returns nothing - the client applies the patch.",
+ inputSchema: z.object({
+ edits: z.array(z.object({
+ oldText: z.string().min(1).describe("Exact substring to find in the current source. Must match verbatim including whitespace. Include enough surrounding context to make the match unique, OR set occurrenceIndex when oldText repeats."),
+ newText: z.string().describe("Replacement text. Empty string deletes the match."),
+ occurrenceIndex: z.number().int().min(0).optional().describe("0-indexed match to replace when oldText appears multiple times. Omit when oldText is unique in the source."),
+ })).min(1).max(20).describe("Edits applied in order against the running source. Later edits see the result of earlier ones."),
+ }),
+ });
+}
diff --git a/apps/backend/src/lib/ai/tools/index.ts b/apps/backend/src/lib/ai/tools/index.ts
index 8430b0ec0..116b3ba73 100644
--- a/apps/backend/src/lib/ai/tools/index.ts
+++ b/apps/backend/src/lib/ai/tools/index.ts
@@ -1,6 +1,6 @@
import { SmartRequestAuth } from "@/route-handlers/smart-request";
import { ToolSet } from "ai";
-import { updateDashboardTool } from "./create-dashboard";
+import { patchDashboardTool, updateDashboardTool } from "./create-dashboard";
import { createEmailDraftTool } from "./create-email-draft";
import { createEmailTemplateTool } from "./create-email-template";
import { createEmailThemeTool } from "./create-email-theme";
@@ -13,7 +13,8 @@ export type ToolName =
| "create-email-theme"
| "create-email-template"
| "create-email-draft"
- | "update-dashboard";
+ | "update-dashboard"
+ | "patch-dashboard";
export type ToolContext = {
auth: SmartRequestAuth | null,
@@ -62,6 +63,11 @@ export async function getTools(
break;
}
+ case "patch-dashboard": {
+ tools["patchDashboard"] = patchDashboardTool(context.auth);
+ break;
+ }
+
default: {
// TypeScript will ensure this is unreachable if we handle all cases
const _exhaustive: never = toolName;
@@ -89,6 +95,7 @@ export function validateToolNames(toolNames: unknown): toolNames is ToolName[] {
"create-email-template",
"create-email-draft",
"update-dashboard",
+ "patch-dashboard",
];
return toolNames.every((name) => validToolNames.includes(name as ToolName));
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx
index 17ad8f25b..a6f5a36ef 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx
@@ -13,14 +13,17 @@ import {
DashboardToolUI,
type AssistantComposerApi,
} from "@/components/vibe-coding";
-import { ToolCallContent } from "@/components/vibe-coding/chat-adapters";
+import { ToolCallContent, type DashboardChip, type DashboardPatchFailure, type DashboardPatchSnapshot } from "@/components/vibe-coding/chat-adapters";
+import { patchSnapshotKey, registerPatchSnapshot } from "@/components/vibe-coding/dashboard-tool-components";
import { useUpdateConfig } from "@/lib/config-update";
import { cn } from "@/lib/utils";
import {
ChatCircleIcon,
+ CursorClickIcon,
FloppyDiskIcon,
PencilSimpleIcon,
TrashIcon,
+ WarningIcon,
XIcon,
} from "@phosphor-icons/react";
import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
@@ -28,6 +31,7 @@ import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import type { AppId } from "@/lib/apps-frontend";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
+import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { getPublicEnvVar } from "@/lib/env";
import { useUser } from "@stackframe/stack";
import { usePathname } from "next/navigation";
@@ -144,8 +148,9 @@ function DashboardDetailContent({
// Coalesce duplicate error reports — React re-renders a crashed component several times,
// and uncaught-error listeners can fire twice for the same exception. We only surface the
- // first unique error per 2-second window so the composer isn't stomped on repeatedly.
+ // first unique error per 2-second window so the chip bar isn't spammed.
const lastErrorRef = useRef<{ signature: string, at: number } | null>(null);
+
const handleDashboardRuntimeError = useCallback(
(err: DashboardRuntimeError) => {
const signature = `${err.message}::${(err.stack ?? "").slice(0, 200)}`;
@@ -155,55 +160,74 @@ function DashboardDetailContent({
}
lastErrorRef.current = { signature, at: now };
- // Build a compact fix-request prompt. We keep the stack to ~1200 chars so the
- // agent gets enough context to localize the bug without drowning in frame noise.
- const stackSlice = (err.stack ?? "").trim().slice(0, 1200);
- const componentStackSlice = (err.componentStack ?? "").trim().slice(0, 400);
- const prefill = [
- "The dashboard just crashed at runtime. Please diagnose and fix it.",
- "",
- "Error:",
- err.message,
- stackSlice ? `\nStack:\n${stackSlice}` : "",
- componentStackSlice ? `\nComponent stack:${componentStackSlice}` : "",
- ]
- .filter(Boolean)
- .join("\n");
-
- // Open the chat panel if it's closed so the user sees the pre-filled composer.
- // The iframe panel doesn't unmount when chat toggles, so no reload cost.
setIsChatOpen(true);
- composerApiRef.current?.setText(prefill);
+
+ const errorChip: DashboardChip = {
+ kind: "error",
+ id: generateUuid(),
+ message: err.message,
+ stack: err.stack,
+ componentStack: err.componentStack,
+ };
+ setPendingChips((prev) => [...prev, errorChip]);
+
+ const api = composerApiRef.current;
+ if (api && api.getText().trim().length === 0) {
+ api.setText("could you please fix this error");
+ }
toast({
variant: "destructive",
title: "Dashboard crashed",
- description: "Error added to chat — hit send to fix it.",
+ description: "Error added as a chip — hit send to fix it.",
});
},
[toast],
);
+ const [pendingChips, setPendingChips] = useState([]);
+ const pendingChipsRef = useRef([]);
+ useEffect(() => {
+ pendingChipsRef.current = pendingChips;
+ }, [pendingChips]);
+
+ const getPendingChips = useCallback(() => pendingChipsRef.current, []);
+ const consumePendingChips = useCallback(() => {
+ pendingChipsRef.current = [];
+ setPendingChips([]);
+ }, []);
+ const removePendingChip = useCallback((id: string) => {
+ setPendingChips((prev) => {
+ const next = prev.filter((c) => c.id !== id);
+ pendingChipsRef.current = next;
+ return next;
+ });
+ }, []);
+
const handleWidgetSelected = useCallback(
(selection: WidgetSelection) => {
- const api = composerApiRef.current;
- if (!api) return;
-
setIsChatOpen(true);
+ const { heading, selectorPath, outerHTMLSnippet } = selection.metadata;
+ const name = (heading && heading.trim().length > 0 && heading.trim().length <= 60)
+ ? heading.trim()
+ : "Widget";
- const { heading, tagName, rect, textPreview } = selection.metadata;
- const name = heading ?? `${tagName} (${rect.width}×${rect.height})`;
- const domContext = [
- `[Widget: ${name}]`,
- textPreview ? `Content: ${textPreview.slice(0, 200)}` : "",
- ].filter(Boolean).join("\n");
-
- const currentText = api.getText();
- api.setText(domContext + "\n" + currentText);
+ setPendingChips((prev) => [
+ ...prev,
+ { kind: "widget", id: generateUuid(), name, selectorPath, outerHTMLSnippet },
+ ]);
},
[],
);
+ const handleAddComponent = useCallback(() => {
+ setIsChatOpen(true);
+ setPendingChips((prev) => {
+ if (prev.some((c) => c.kind === "action-add-component")) return prev;
+ return [...prev, { kind: "action-add-component", id: generateUuid() }];
+ });
+ }, []);
+
useEffect(() => {
if (!hasUnsavedChanges) return;
setNeedConfirm(true);
@@ -252,6 +276,33 @@ function DashboardDetailContent({
}
}, []);
+ const handlePatchApplied = useCallback((updatedSource: string, failures: DashboardPatchFailure[], snapshots: DashboardPatchSnapshot[]) => {
+ setPendingCode(updatedSource);
+ setCurrentTsxSource(updatedSource);
+ clearTimeout(codePhaseTimerRef.current);
+ setCodePhase("typing");
+ codePhaseTimerRef.current = setTimeout(() => {
+ setCodePhase("loading");
+ codePhaseTimerRef.current = setTimeout(() => {
+ setCodePhase("done");
+ }, 1000);
+ }, 3000);
+ for (const snap of snapshots) {
+ registerPatchSnapshot(patchSnapshotKey(snap.edits), snap.resultSource);
+ }
+ if (failures.length > 0) {
+ const summary = failures.slice(0, 3).map((f) =>
+ `#${f.index + 1} ${f.reason} ("${f.oldTextPreview}${f.oldTextPreview.length >= 80 ? "…" : ""}")`,
+ ).join("; ");
+ const remainder = failures.length > 3 ? ` (+${failures.length - 3} more)` : "";
+ toast({
+ variant: "destructive",
+ title: `${failures.length} ${failures.length === 1 ? "edit" : "edits"} didn't apply`,
+ description: `${summary}${remainder}. Ask the AI to retry with more context.`,
+ });
+ }
+ }, [toast]);
+
const handleRunStart = useCallback(() => {
setIsGenerating(true);
setPendingCode(null);
@@ -366,6 +417,7 @@ function DashboardDetailContent({
onReady={handleIframeReady}
onRuntimeError={handleDashboardRuntimeError}
onWidgetSelected={handleWidgetSelected}
+ onAddComponentClicked={handleAddComponent}
isChatOpen={isChatOpen}
/>
@@ -448,7 +500,12 @@ function DashboardDetailContent({
/>