Custom Dashboard Improvements (#1359)
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled

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.
This commit is contained in:
aadesh18 2026-04-23 17:34:12 -07:00 committed by GitHub
parent dbc7988169
commit d8b7499cae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 862 additions and 82 deletions

View File

@ -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 <Loading />; // ← early return BEFORE the next hook
}
const [filter, setFilter] = React.useState(""); // ← this hook is skipped on first render
React.useEffect(() => { ... }, []); // ← and this one
return <div>...</div>;
}
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 <ErrorState message={error} />;
if (!users) return <Loading />;
return <div>...</div>;
}
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 <div className="p-6 text-red-500">{error}</div>;
if (rows == null) return <DashboardUI.DesignSkeleton />;
return <DashboardUI.DataGrid rows={gridData.rows} state={gridState} onChange={setGridState} />;
}
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: <h3 class="text-lg font-semibold">User Signups</h3>
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: <error message>
Stack: <stack trace>
Component stack: <react 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,

View File

@ -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."),
}),
});
}

View File

@ -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));

View File

@ -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<DashboardChip[]>([]);
const pendingChipsRef = useRef<DashboardChip[]>([]);
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}
/>
</div>
@ -448,7 +500,12 @@ function DashboardDetailContent({
/>
<div className="flex-1 min-h-0">
<AssistantChat
chatAdapter={createDashboardChatAdapter(backendBaseUrl, currentTsxSource, handleCodeUpdate, currentUser, enabledAppIds, projectId, handleRunStart, handleRunEnd)}
chatAdapter={createDashboardChatAdapter(backendBaseUrl, currentTsxSource, handleCodeUpdate, currentUser, enabledAppIds, projectId, handleRunStart, handleRunEnd, handlePatchApplied, getPendingChips, consumePendingChips)}
composerTopContent={
pendingChips.length > 0
? <ChipBar chips={pendingChips} onRemove={removePendingChip} />
: undefined
}
historyAdapter={createHistoryAdapter(adminApp, dashboardId)}
toolComponents={<DashboardToolUI setCurrentCode={setCurrentTsxSource} currentCode={currentTsxSource} />}
useOffWhiteLightMode
@ -494,6 +551,64 @@ const DASHBOARD_COMPOSER_PLACEHOLDER = {
],
} as const;
function ChipBar({
chips,
onRemove,
}: {
chips: DashboardChip[],
onRemove: (id: string) => void,
}) {
return (
<div className="shrink-0 px-3 pt-2.5 pb-1 flex items-center gap-1.5 flex-wrap">
{chips.map((c) => {
if (c.kind === "widget") {
return (
<button
key={c.id}
type="button"
onClick={() => onRemove(c.id)}
title={`${c.name} — click to remove. Sent with your next message.`}
className="group inline-flex items-center gap-1.5 max-w-[200px] pl-1.5 pr-1 py-0.5 rounded-full bg-primary/[0.08] hover:bg-primary/[0.14] ring-1 ring-primary/15 hover:ring-primary/25 text-primary text-xs transition-colors"
>
<CursorClickIcon className="h-3 w-3 shrink-0" weight="fill" />
<span className="truncate font-medium">{c.name}</span>
<XIcon className="h-2.5 w-2.5 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" weight="bold" />
</button>
);
}
if (c.kind === "action-add-component") {
return (
<button
key={c.id}
type="button"
onClick={() => onRemove(c.id)}
title="Add a new component — click to remove."
className="group inline-flex items-center gap-1.5 max-w-[200px] pl-2 pr-1 py-0.5 rounded-full bg-emerald-500/10 hover:bg-emerald-500/15 ring-1 ring-emerald-500/20 hover:ring-emerald-500/30 text-emerald-700 dark:text-emerald-400 text-xs transition-colors"
>
<span className="truncate font-medium">Add component</span>
<XIcon className="h-2.5 w-2.5 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" weight="bold" />
</button>
);
}
// error
return (
<button
key={c.id}
type="button"
onClick={() => onRemove(c.id)}
title={`${c.message} — click to remove. Sent with your next message.`}
className="group inline-flex items-center gap-1.5 max-w-[200px] pl-1.5 pr-1 py-0.5 rounded-full bg-red-500/10 hover:bg-red-500/15 ring-1 ring-red-500/20 hover:ring-red-500/30 text-red-700 dark:text-red-400 text-xs transition-colors"
>
<WarningIcon className="h-3 w-3 shrink-0" weight="fill" />
<span className="truncate font-medium">Error</span>
<XIcon className="h-2.5 w-2.5 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" weight="bold" />
</button>
);
})}
</div>
);
}
function ChatPanelHeader({
displayName,
isEditingName,

View File

@ -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 (
<HideMessageActionsContext.Provider value={hideMessageActions}>
<HasRunningStatusContext.Provider value={!!runningStatusMessages}>
@ -110,7 +111,7 @@ export const Thread: FC<{
: "from-background via-background",
)}>
<ThreadScrollToBottom />
<Composer placeholder={composerPlaceholder} />
<Composer placeholder={composerPlaceholder} topContent={composerTopContent} />
</div>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
@ -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 (
<ComposerPrimitive.Root className="group/composer relative flex w-full flex-col rounded-2xl border border-border/20 dark:border-foreground/[0.08] bg-white dark:bg-background/90 backdrop-blur-xl shadow-sm dark:shadow-lg ring-1 ring-foreground/[0.04] transition-all duration-150 hover:transition-none focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-500/30">
{attachmentsEnabled && <ComposerAttachmentsRow />}
{topContent}
{typeof placeholder === "object" ? (
<ComposerAnimatedInput
prefix={placeholder.prefix}

View File

@ -212,17 +212,33 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
--ring: 240 4.9% 83.9%;
}
:root, .dark { --page-background: transparent; }
html, body {
html {
width: 100%;
height: 100%;
overflow-x: hidden;
}
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
min-height: 100%;
overflow-x: hidden;
font-family: Inter, system-ui, -apple-system, Segoe UI, sans-serif;
background: var(--page-background);
color: hsl(var(--foreground));
/* Flex column so #root fills remaining height when content is short, and
the add-component button (last body child) sits naturally at the bottom
of the scrollable region when content is tall. Without flex, #root's
height:100% would push the button below the viewport unreachable. */
display: flex;
flex-direction: column;
}
#root {
width: 100%;
overflow-x: hidden;
flex: 1 0 auto;
min-height: 0;
}
#root { width: 100%; height: 100%; overflow-x: hidden; }
* { box-sizing: border-box; }
.dark { color-scheme: dark; }
html, body, #root { scrollbar-width: none; }
@ -260,6 +276,39 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
}
.widget-overlay-btn:hover { transform: scale(1.08); }
.widget-overlay.active .widget-overlay-btn { opacity: 1; }
/* "Add a component" affordance sits at the bottom of the dashboard content,
inside the iframe so it scrolls naturally with the page (NOT sticky). Dashed
border matches the widget overlay so it reads as part of the same editor
language. Visible only in edit mode (chat open) toggled via
window.__chatOpen. */
.add-component-btn {
display: none;
align-items: center;
justify-content: center;
gap: 8px;
flex-shrink: 0;
width: calc(100% - 48px);
max-width: calc(80rem - 48px);
margin: 16px auto 12px;
padding: 14px 16px;
background: transparent;
border: 2px dashed hsl(var(--primary) / 0.35);
border-radius: 12px;
color: hsl(var(--muted-foreground));
font-family: Inter, system-ui, -apple-system, Segoe UI, sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.add-component-btn.visible { display: flex; }
.add-component-btn:hover {
border-color: hsl(var(--primary) / 0.6);
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.04);
}
.add-component-btn .plus-icon { width: 16px; height: 16px; }
</style>
</head>
<body>
@ -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 <script> tags earlier in <body> (widget overlay, add-component button) run
// synchronously as the parser hits them — BEFORE this text/babel block runs on
// DOMContentLoaded. Those scripts install listeners for 'chat-state-change' and
// call their syncVisibility() once at install time, where window.__chatOpen is
// still undefined. Re-dispatch the event now so they pick up the real flag and
// show/hide themselves correctly on first mount.
window.dispatchEvent(new Event('chat-state-change'));
// Theme syncing and chat state from parent window
window.addEventListener('message', (event) => {
@ -405,7 +461,7 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
render() {
if (this.state.hasError) {
return (
<div className="p-6 text-red-500">
<div className="p-6 text-red-500" data-stack-no-widget="true">
<h2 className="text-xl font-bold mb-2">Dashboard Error</h2>
<pre className="text-sm bg-red-950/20 p-4 rounded overflow-auto">
{this.state.error?.message || 'Unknown error'}
@ -427,36 +483,36 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
if (!rootElement) {
throw new Error('Root element not found');
}
// Initialize deps and boot the dashboard
initializeStackApp().then(() => {
const DashboardUI = window.DashboardUI;
const Recharts = window.Recharts;
if (!DashboardUI) {
throw new Error("Dashboard UI components failed to load in sandbox.");
}
if (!Recharts) {
throw new Error("Recharts failed to load in sandbox.");
}
// Execute AI-generated code with DashboardUI and Recharts in scope
const Dashboard = (() => {
${sourceCode}
return Dashboard;
})();
if (typeof Dashboard !== 'function') {
throw new Error('Dashboard component not found in generated code');
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
);
parent.postMessage({ type: "stack-ai-dashboard-ready" }, "*");
}).catch(error => {
const message = error instanceof Error ? error.message : "Failed to initialize dashboard";
@ -468,7 +524,7 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
const root = ReactDOM.createRoot(rootElement);
root.render(
<div className="p-6 text-red-500">
<div className="p-6 text-red-500" data-stack-no-widget="true">
<h2 className="text-xl font-bold mb-2">Failed to load dashboard</h2>
<pre className="text-sm bg-red-950/20 p-4 rounded">
{message}
@ -503,11 +559,20 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
function findWidget(el) {
var current = el;
var root = document.getElementById('root');
// Error screens (ErrorBoundary fallback, init-failure UI) are marked with
// data-stack-no-widget so the user can't "chip" an error widget — that would
// just round-trip the rendered error text back to the AI, which is useless.
if (el && typeof el.closest === 'function' && el.closest('[data-stack-no-widget]')) {
return null;
}
while (current && current !== root && current !== document.body) {
if (current === overlay || overlay.contains(current)) {
current = current.parentElement;
continue;
}
if (current.hasAttribute && current.hasAttribute('data-stack-no-widget')) {
return null;
}
var rect = current.getBoundingClientRect();
if (rect.width < 80 || rect.height < 50) { current = current.parentElement; continue; }
if (rect.width > window.innerWidth * 0.85 && rect.height > window.innerHeight * 0.85) {
@ -546,7 +611,10 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
currentWidget = null;
}
var lastCursor = null;
document.addEventListener('mousemove', function (e) {
lastCursor = { x: e.clientX, y: e.clientY };
if (!window.__chatOpen) return;
if (overlay.contains(e.target)) return;
var widget = findWidget(e.target);
@ -554,23 +622,82 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
else if (!widget) hideOverlay();
});
/* Scroll doesn't fire mousemove, so without this the overlay stays pinned at
the old viewport coordinates while the widget underneath scrolls away. Use
elementFromPoint at the cursor's last known position to figure out what's
actually under the cursor now and re-target or hide accordingly. Captured
on scroll with passive:true so it doesn't slow scrolling. */
function reevaluateFromScroll() {
if (!window.__chatOpen) { hideOverlay(); return; }
if (!lastCursor) { hideOverlay(); return; }
var el = document.elementFromPoint(lastCursor.x, lastCursor.y);
if (!el || overlay.contains(el)) return;
var widget = findWidget(el);
if (widget) showOverlay(widget);
else hideOverlay();
}
window.addEventListener('scroll', reevaluateFromScroll, { passive: true, capture: true });
document.addEventListener('mouseleave', function () { hideOverlay(); });
window.addEventListener('chat-state-change', function () { if (!window.__chatOpen) hideOverlay(); });
/* Build a CSS-ish selector path from #root down to the widget, capped at 10
segments so it stays AI-digestible. Each segment is the tag plus the first
className token (if any), plus a nth-of-type suffix only when the parent has
sibling tags of the same name. The path lets the AI ground the patch on a
real chunk of structure rather than inferring from heading text alone. */
function buildSelectorPath(el) {
var rootEl = document.getElementById('root');
var segments = [];
var node = el;
while (node && node !== rootEl && node !== document.body && segments.length < 10) {
var tag = node.tagName.toLowerCase();
var classToken = '';
if (typeof node.className === 'string') {
var firstClass = node.className.trim().split(/\s+/)[0];
if (firstClass && !/^widget-overlay/.test(firstClass)) {
classToken = '.' + firstClass;
}
}
var nthSelector = '';
var parent = node.parentElement;
if (parent) {
var sameTagSiblings = [];
for (var i = 0; i < parent.children.length; i++) {
if (parent.children[i].tagName === node.tagName) {
sameTagSiblings.push(parent.children[i]);
}
}
if (sameTagSiblings.length > 1) {
var idx = sameTagSiblings.indexOf(node) + 1;
nthSelector = ':nth-of-type(' + idx + ')';
}
}
segments.unshift(tag + classToken + nthSelector);
node = node.parentElement;
}
return segments.join(' > ');
}
/* ── Send DOM metadata to parent ── */
btn.addEventListener('click', function (e) {
e.stopPropagation();
e.preventDefault();
if (!currentWidget) return;
var heading = currentWidget.querySelector('h1,h2,h3,h4,h5,h6');
var heading = currentWidget.querySelector('h1,h2,h3,h4,h5,h6')
|| currentWidget.querySelector('span.font-semibold');
var widgetRect = currentWidget.getBoundingClientRect();
var outerHTML = '';
try { outerHTML = (currentWidget.outerHTML || '').slice(0, 300); } catch (_) {}
var metadata = {
heading: heading ? heading.textContent.trim() : null,
tagName: currentWidget.tagName.toLowerCase(),
classes: (typeof currentWidget.className === 'string' ? currentWidget.className : '').slice(0, 300),
textPreview: (currentWidget.textContent || '').trim().slice(0, 500),
rect: { width: Math.round(widgetRect.width), height: Math.round(widgetRect.height) },
selectorPath: buildSelectorPath(currentWidget),
outerHTMLSnippet: outerHTML,
};
window.parent.postMessage({ type: 'dashboard-widget-selected', metadata: metadata }, '*');
@ -578,6 +705,28 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo
});
})();
</script>
<!-- "Add a component" button appended as a sibling of #root so it sits at the
bottom of the dashboard content and scrolls into view (NOT sticky/fixed).
Visibility tracks window.__chatOpen so it only appears in edit mode. -->
<script>
(function () {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'add-component-btn';
btn.innerHTML = '<svg class="plus-icon" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></svg><span>Add a component</span>';
function syncVisibility() {
if (window.__chatOpen) btn.classList.add('visible');
else btn.classList.remove('visible');
}
btn.addEventListener('click', function () {
window.parent.postMessage({ type: 'dashboard-add-component-clicked' }, '*');
});
document.body.appendChild(btn);
syncVisibility();
window.addEventListener('chat-state-change', syncVisibility);
})();
</script>
</body>
</html>`;
}
@ -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<HTMLIFrameElement>(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;

View File

@ -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}
/>
</TooltipProvider>
{toolComponents}

View File

@ -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<ChatModelRunResult, void> {
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) {

View File

@ -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<string, string>();
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) => <ToolRender args={props.args} isRunning={props.status.type === "running"} />,
});
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 (
<>
<div className={cn(
"group flex items-stretch rounded-lg overflow-hidden",
"bg-foreground/[0.015] hover:bg-foreground/[0.035]",
"ring-1 ring-foreground/[0.05] hover:ring-foreground/[0.09]",
"transition-colors",
isActive && "ring-primary/30 bg-primary/[0.03]",
)}>
<button
type="button"
onClick={() => setOpen(true)}
className="flex-1 flex items-center gap-3 py-2 pl-2.5 pr-2 text-left min-w-0"
aria-label="View edit details"
>
<div className={cn(
"size-6 shrink-0 rounded-md flex items-center justify-center",
isActive ? "bg-primary/10 text-primary" : "bg-foreground/[0.05] text-muted-foreground",
)}>
{isRunning ? (
<span className="size-1.5 rounded-full bg-current" style={{ animation: "pulse 1.2s ease-in-out infinite" }} />
) : isActive ? (
<CheckCircleIcon className="size-3.5" weight="fill" />
) : (
<PencilSimpleIcon className="size-3.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] uppercase tracking-[0.08em] text-muted-foreground/70 font-medium">
{label}
</div>
<code className="block text-xs font-mono text-foreground/75 truncate">
{edits[0]?.oldText ? edits[0].oldText.replace(/\s+/g, " ").slice(0, 80) : "(pending)"}
</code>
</div>
</button>
{canRestore && !isActive && (
<SimpleTooltip tooltip="Restore this version">
<Button
variant="ghost"
size="icon"
className="h-auto w-8 rounded-none text-muted-foreground hover:text-foreground"
onClick={() => setCurrentCodeRef?.(snapshot)}
aria-label="Restore this version"
>
<ArrowCounterClockwiseIcon className="size-4" />
</Button>
</SimpleTooltip>
)}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Dashboard edits</DialogTitle>
<DialogDescription>
{isRunning ? "Streaming edits from the model…" : `${count} ${count === 1 ? "edit" : "edits"} applied to the source.`}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex flex-col gap-3">
{edits.length === 0 && (
<div className="text-xs text-muted-foreground">Waiting for edits</div>
)}
{edits.map((edit, i) => (
<div key={i} className="rounded-lg ring-1 ring-foreground/[0.06] overflow-hidden">
<div className="px-3 py-1.5 text-[10px] uppercase tracking-[0.08em] text-muted-foreground/70 bg-foreground/[0.02] border-b border-foreground/[0.05]">
Edit {i + 1}{typeof edit.occurrenceIndex === "number" ? ` · occurrence ${edit.occurrenceIndex}` : ""}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-foreground/[0.05]">
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-words text-red-500/90 bg-red-500/[0.03] max-h-48 overflow-auto">
{edit.oldText ?? ""}
</pre>
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-words text-emerald-600/90 bg-emerald-500/[0.03] max-h-48 overflow-auto">
{edit.newText ?? ""}
</pre>
</div>
</div>
))}
</div>
</DialogBody>
</DialogContent>
</Dialog>
</>
);
}
const PatchToolUI = makeAssistantToolUI<
PatchToolArgs,
"success"
>({
toolName: "patchDashboard",
render: (props) => <PatchToolRender args={props.args} isRunning={props.status.type === "running"} />,
});
/*
* 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 (
<>
<ToolUI />
<PatchToolUI />
<QueryAnalyticsToolUI />
</>
);