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({ />
0 + ? + : undefined + } historyAdapter={createHistoryAdapter(adminApp, dashboardId)} toolComponents={} useOffWhiteLightMode @@ -494,6 +551,64 @@ const DASHBOARD_COMPOSER_PLACEHOLDER = { ], } as const; +function ChipBar({ + chips, + onRemove, +}: { + chips: DashboardChip[], + onRemove: (id: string) => void, +}) { + return ( +
+ {chips.map((c) => { + if (c.kind === "widget") { + return ( + + ); + } + if (c.kind === "action-add-component") { + return ( + + ); + } + // error + return ( + + ); + })} +
+ ); +} + function ChatPanelHeader({ displayName, isEditingName, diff --git a/apps/dashboard/src/components/assistant-ui/thread.tsx b/apps/dashboard/src/components/assistant-ui/thread.tsx index af27c79e4..8de0f7204 100644 --- a/apps/dashboard/src/components/assistant-ui/thread.tsx +++ b/apps/dashboard/src/components/assistant-ui/thread.tsx @@ -62,7 +62,8 @@ export const Thread: FC<{ runningStatusMessages?: string[], composerAttachments?: boolean, attachmentAdapter?: AttachmentAdapter, -}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false, attachmentAdapter }) => { + composerTopContent?: React.ReactNode, +}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false, attachmentAdapter, composerTopContent }) => { return ( @@ -110,7 +111,7 @@ export const Thread: FC<{ : "from-background via-background", )}> - +
@@ -537,11 +538,12 @@ const ComposerStaticInput: FC<{ placeholder?: string }> = ({ placeholder }) => { ); }; -const Composer: FC<{ placeholder?: ComposerPlaceholder }> = ({ placeholder }) => { +const Composer: FC<{ placeholder?: ComposerPlaceholder, topContent?: React.ReactNode }> = ({ placeholder, topContent }) => { const attachmentsEnabled = useComposerAttachmentsEnabled(); return ( {attachmentsEnabled && } + {topContent} {typeof placeholder === "object" ? ( @@ -284,6 +333,13 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo // Controls visibility flag — only true in the full dashboard viewer (not cmd+K preview) window.__showControls = ${showControls}; window.__chatOpen = ${initialChatOpen}; + // Inline + + + `; } @@ -605,6 +754,12 @@ export type WidgetSelection = { classes: string, textPreview: string, rect: { width: number, height: number }, + /** CSS-style selector chain from #root down to the clicked widget. Capped at 10 + segments. Lets the AI ground a patch on a real chunk of structure. */ + selectorPath: string, + /** First ~300 chars of the widget's outerHTML — verbatim rendered markup the AI + can match against when locating the JSX node in source. */ + outerHTMLSnippet: string, }, }; @@ -616,6 +771,7 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ onReady, onRuntimeError, onWidgetSelected, + onAddComponentClicked, isChatOpen, }: { artifact: DashboardArtifact, @@ -628,6 +784,9 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ onRuntimeError?: (err: DashboardRuntimeError) => void, /** Fires when the user clicks "Add to chat" on a widget overlay in the iframe. */ onWidgetSelected?: (selection: WidgetSelection) => void, + /** Fires when the user clicks the in-iframe "Add a component" button at the bottom + of the dashboard. Parent pushes an action chip into the composer chip bar. */ + onAddComponentClicked?: () => void, isChatOpen?: boolean, }) { const iframeRef = useRef(null); @@ -643,6 +802,8 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ onRuntimeErrorRef.current = onRuntimeError; const onWidgetSelectedRef = useRef(onWidgetSelected); onWidgetSelectedRef.current = onWidgetSelected; + const onAddComponentClickedRef = useRef(onAddComponentClicked); + onAddComponentClickedRef.current = onAddComponentClicked; const user = useUser({ or: "redirect" }); const { resolvedTheme } = useTheme(); @@ -768,11 +929,18 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ width: typeof event.data.metadata?.rect?.width === "number" ? event.data.metadata.rect.width : 0, height: typeof event.data.metadata?.rect?.height === "number" ? event.data.metadata.rect.height : 0, }, + selectorPath: typeof event.data.metadata?.selectorPath === "string" ? event.data.metadata.selectorPath : "", + outerHTMLSnippet: typeof event.data.metadata?.outerHTMLSnippet === "string" ? event.data.metadata.outerHTMLSnippet : "", }, }); return; } + if (type === "dashboard-add-component-clicked") { + onAddComponentClickedRef.current?.(); + return; + } + if (type === "stack-ai-dashboard-ready") { onReadyRef.current?.(); return; diff --git a/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx b/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx index aa9ad1e33..6b222f2e9 100644 --- a/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx +++ b/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx @@ -32,6 +32,8 @@ type AssistantChatProps = { runningStatusMessages?: string[], /** Enable image attachment UI (subject to shared MAX_IMAGES_PER_MESSAGE/MAX_IMAGE_BYTES_PER_FILE). */ composerAttachments?: boolean, + /** Content rendered inside the composer box, above the textarea. Used for widget chips. */ + composerTopContent?: React.ReactNode, /** * Called once the composer runtime is mounted. Parent stores the handle in a ref * and can then call `setText(...)` imperatively. Fires inside the runtime provider @@ -64,6 +66,7 @@ export default function AssistantChat({ hideMessageActions = false, runningStatusMessages, composerAttachments = false, + composerTopContent, onComposerReady, }: AssistantChatProps) { const attachmentAdapter = useMemo( @@ -92,6 +95,7 @@ export default function AssistantChat({ runningStatusMessages={runningStatusMessages} composerAttachments={composerAttachments} attachmentAdapter={attachmentAdapter} + composerTopContent={composerTopContent} /> {toolComponents} diff --git a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts index d05baf31e..464a46f7c 100644 --- a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts +++ b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts @@ -25,6 +25,116 @@ const isToolCall = (content: { type: string }): content is ToolCallContent => { return content.type === "tool-call"; }; +export type DashboardWidgetContext = { + kind: "widget", + id: string, + name: string, + selectorPath: string, + outerHTMLSnippet: string, +}; + +export type DashboardActionContext = { + kind: "action-add-component", + id: string, +}; + +export type DashboardErrorContext = { + kind: "error", + id: string, + message: string, + stack?: string, + componentStack?: string, +}; + +export type DashboardChip = + | DashboardWidgetContext + | DashboardActionContext + | DashboardErrorContext; + +export type DashboardPatchEdit = { + oldText: string, + newText: string, + occurrenceIndex?: number, +}; + +export type DashboardPatchFailure = { + index: number, + reason: "not-found" | "ambiguous", + oldTextPreview: string, +}; + +export type DashboardPatchResult = { + updatedSource: string, + applied: number, + failures: DashboardPatchFailure[], +}; + +export type DashboardPatchSnapshot = { + edits: DashboardPatchEdit[], + resultSource: string, +}; + +export function applyDashboardPatches(source: string, edits: DashboardPatchEdit[]): DashboardPatchResult { + let running = source; + let applied = 0; + const failures: DashboardPatchFailure[] = []; + + edits.forEach((edit, index) => { + const preview = edit.oldText.slice(0, 80).replace(/\s+/g, " "); + + const matches: number[] = []; + let from = 0; + while (from <= running.length) { + const at = running.indexOf(edit.oldText, from); + if (at === -1) break; + matches.push(at); + from = at + Math.max(edit.oldText.length, 1); + } + + if (matches.length === 0) { + failures.push({ index, reason: "not-found", oldTextPreview: preview }); + return; + } + + let chosenIndex: number; + if (edit.occurrenceIndex != null) { + if (edit.occurrenceIndex < 0 || edit.occurrenceIndex >= matches.length) { + failures.push({ index, reason: "not-found", oldTextPreview: preview }); + return; + } + chosenIndex = matches[edit.occurrenceIndex]; + } else if (matches.length > 1) { + failures.push({ index, reason: "ambiguous", oldTextPreview: preview }); + return; + } else { + chosenIndex = matches[0]; + } + + running = running.slice(0, chosenIndex) + edit.newText + running.slice(chosenIndex + edit.oldText.length); + applied += 1; + }); + + return { updatedSource: running, applied, failures }; +} + +function parsePatchEdits(args: unknown): DashboardPatchEdit[] | null { + if (typeof args !== "object" || args === null) return null; + const editsRaw = (args as { edits?: unknown }).edits; + if (!Array.isArray(editsRaw)) return null; + const edits: DashboardPatchEdit[] = []; + for (const e of editsRaw) { + if (typeof e !== "object" || e === null) return null; + const { oldText, newText, occurrenceIndex } = e as { oldText?: unknown, newText?: unknown, occurrenceIndex?: unknown }; + if (typeof oldText !== "string" || typeof newText !== "string") return null; + edits.push({ + oldText, + newText, + occurrenceIndex: typeof occurrenceIndex === "number" ? occurrenceIndex : undefined, + }); + } + return edits; +} + /** Maps thread messages to the backend wire format; merges `attachments` into `content`. */ function formatThreadMessagesForBackend( messages: readonly { role: string, content: readonly { type: string }[], attachments?: readonly { content?: readonly unknown[] }[] }[], @@ -250,8 +360,8 @@ export async function* streamDashboardCode( // Only give the agent the sql-query tool when we know which project to scope it to. // Without projectId, the tool would fall back to the internal project — wrong target. const tools = options?.projectId - ? ["update-dashboard", "sql-query"] - : ["update-dashboard"]; + ? ["update-dashboard", "patch-dashboard", "sql-query"] + : ["update-dashboard", "patch-dashboard"]; const chunkStream = await sendAiStreamRequest( backendBaseUrl, @@ -296,8 +406,8 @@ export async function generateDashboardCode( options?.enabledAppIds, ); const tools = options?.projectId - ? ["update-dashboard", "sql-query"] - : ["update-dashboard"]; + ? ["update-dashboard", "patch-dashboard", "sql-query"] + : ["update-dashboard", "patch-dashboard"]; const rawContent = await sendAiRequest( backendBaseUrl, currentUser, @@ -391,14 +501,46 @@ export function createDashboardChatAdapter( projectId?: string, onRunStart?: () => void, onRunEnd?: () => void, + onPatchApplied?: (updatedSource: string, failures: DashboardPatchFailure[], snapshots: DashboardPatchSnapshot[]) => void, + getPendingChips?: () => DashboardChip[], + consumePendingChips?: () => void, ): ChatModelAdapter { return { async *run({ messages, abortSignal }: ChatModelRunOptions): AsyncGenerator { onRunStart?.(); try { const formattedMessages = formatThreadMessagesForBackend(messages); + const chips = getPendingChips?.() ?? []; + if (chips.length > 0) { + const chipBlock = chips.map((c) => { + if (c.kind === "widget") { + return `[Widget: ${c.name}]\nPath: ${c.selectorPath}\nHTML: ${c.outerHTMLSnippet}`; + } + if (c.kind === "action-add-component") { + return `[Action: Add a new component to the dashboard]`; + } + // Bound stack/componentStack so a 5KB trace can't blow up the prompt. + const stackSlice = c.stack ? `\nStack:\n${c.stack.slice(0, 1200)}` : ""; + const componentStackSlice = c.componentStack + ? `\nComponent stack:${c.componentStack.slice(0, 400)}` + : ""; + return `[Error: The dashboard crashed at runtime — please diagnose and fix.]\nMessage: ${c.message}${stackSlice}${componentStackSlice}`; + }).join("\n\n"); + + for (let i = formattedMessages.length - 1; i >= 0; i--) { + if (formattedMessages[i].role !== "user") continue; + const orig = formattedMessages[i].content; + const chipPart = { type: "text" as const, text: chipBlock }; + formattedMessages[i] = { + ...formattedMessages[i], + content: Array.isArray(orig) ? [chipPart, ...orig] : [chipPart], + }; + break; + } + } let latestContent: ChatContent = []; + let chipsConsumed = chips.length === 0; for await (const content of streamDashboardCode( backendBaseUrl, currentUser, @@ -410,15 +552,45 @@ export function createDashboardChatAdapter( projectId, }, )) { + if (!chipsConsumed) { + consumePendingChips?.(); + chipsConsumed = true; + } latestContent = content; yield { content }; } - const finalToolCall = latestContent.find( - (item): item is ToolCallContent => isToolCall(item) && item.toolName === "updateDashboard", - ); - if (finalToolCall) { - onToolCall(finalToolCall); + let runningSource = currentTsxSource; + let lastFullReplacement: ToolCallContent | null = null; + const aggregatedFailures: DashboardPatchFailure[] = []; + const snapshots: DashboardPatchSnapshot[] = []; + let anyPatchApplied = false; + + for (const item of latestContent) { + if (!isToolCall(item)) continue; + if (item.toolName === "updateDashboard") { + if (typeof item.args?.content === "string") { + runningSource = item.args.content; + lastFullReplacement = item; + } + } else if (item.toolName === "patchDashboard") { + const edits = parsePatchEdits(item.args); + if (!edits) continue; + const result = applyDashboardPatches(runningSource, edits); + runningSource = result.updatedSource; + anyPatchApplied = true; + snapshots.push({ edits, resultSource: runningSource }); + for (const f of result.failures) { + aggregatedFailures.push(f); + } + } + } + + if (lastFullReplacement) { + onToolCall(lastFullReplacement); + } + if (anyPatchApplied || aggregatedFailures.length > 0) { + onPatchApplied?.(runningSource, aggregatedFailures, snapshots); } } catch (error) { if (abortSignal.aborted) { diff --git a/apps/dashboard/src/components/vibe-coding/dashboard-tool-components.tsx b/apps/dashboard/src/components/vibe-coding/dashboard-tool-components.tsx index 488359cf2..01a05f1d2 100644 --- a/apps/dashboard/src/components/vibe-coding/dashboard-tool-components.tsx +++ b/apps/dashboard/src/components/vibe-coding/dashboard-tool-components.tsx @@ -17,6 +17,7 @@ import { CheckCircleIcon, EyeIcon, MagnifyingGlassIcon, + PencilSimpleIcon, WarningIcon, } from "@phosphor-icons/react"; import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; @@ -36,6 +37,47 @@ function useCurrentCode() { return useSyncExternalStore(subscribe, () => currentCodeRef); } +/* ──────────────────────────────────────────────────────────────────────────── + * Patch snapshot registry. Each successful patchDashboard call stores the + * post-patch source under a stable key (the JSON-serialized edits array). The + * patch tool's chat row reads this to offer a "restore this version" action. + * + * Limitation: snapshots live in-memory and only cover patches applied during + * the current session. Patches loaded from saved chat history won't have a + * snapshot until the user re-runs them — the dialog still shows the edit text + * either way. Cross-reload restore would require persisting the result source + * with the tool call, which the inert-tool architecture doesn't currently do. + * ────────────────────────────────────────────────────────────────────────── */ +const patchSnapshots = new Map(); +const patchSnapshotListeners: Set<() => void> = new Set(); + +export function registerPatchSnapshot(editsKey: string, resultSource: string) { + patchSnapshots.set(editsKey, resultSource); + for (const l of patchSnapshotListeners) l(); +} + +function subscribePatchSnapshots(listener: () => void) { + patchSnapshotListeners.add(listener); + return () => { + patchSnapshotListeners.delete(listener); + }; +} + +function usePatchSnapshot(editsKey: string): string | undefined { + return useSyncExternalStore( + subscribePatchSnapshots, + () => patchSnapshots.get(editsKey), + ); +} + +export function patchSnapshotKey(edits: unknown): string { + try { + return JSON.stringify(edits); + } catch { + return ""; + } +} + function ToolRender({ args, isRunning }: { args: { content: string }, isRunning: boolean }) { const currentCode = useCurrentCode(); const isActive = args.content === currentCode; @@ -114,6 +156,121 @@ const ToolUI = makeAssistantToolUI< render: (props) => , }); +type PatchEditArg = { oldText?: string, newText?: string, occurrenceIndex?: number }; +type PatchToolArgs = { edits?: PatchEditArg[] }; + +function PatchToolRender({ args, isRunning }: { args: PatchToolArgs, isRunning: boolean }) { + const [open, setOpen] = useState(false); + const edits = Array.isArray(args.edits) ? args.edits : []; + const count = edits.length; + const currentCode = useCurrentCode(); + const snapshot = usePatchSnapshot(patchSnapshotKey(edits)); + // Snapshot may be missing for patches loaded from saved chat history (the in-memory + // map only covers the current session). When absent, hide the restore button rather + // than offering something we can't fulfill. + const canRestore = snapshot !== undefined && !isRunning; + const isActive = snapshot !== undefined && snapshot === currentCode; + const label = isRunning + ? "Editing dashboard..." + : count === 0 + ? "Edited dashboard" + : `Edited dashboard · ${count} ${count === 1 ? "change" : "changes"}`; + + return ( + <> +
+ + {canRestore && !isActive && ( + + + + )} +
+ + + + + Dashboard edits + + {isRunning ? "Streaming edits from the model…" : `${count} ${count === 1 ? "edit" : "edits"} applied to the source.`} + + + +
+ {edits.length === 0 && ( +
Waiting for edits…
+ )} + {edits.map((edit, i) => ( +
+
+ Edit {i + 1}{typeof edit.occurrenceIndex === "number" ? ` · occurrence ${edit.occurrenceIndex}` : ""} +
+
+
+                      {edit.oldText ?? ""}
+                    
+
+                      {edit.newText ?? ""}
+                    
+
+
+ ))} +
+
+
+
+ + ); +} + +const PatchToolUI = makeAssistantToolUI< + PatchToolArgs, + "success" +>({ + toolName: "patchDashboard", + render: (props) => , +}); + /* ──────────────────────────────────────────────────────────────────────────── * queryAnalytics tool UI — inspection steps the agent takes before/between * writes. Visual weight is DELIBERATELY lighter than the updateDashboard card: @@ -363,6 +520,7 @@ export const DashboardToolUI = ({ setCurrentCode, currentCode }: DashboardToolUI return ( <> + );