mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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
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:
parent
dbc7988169
commit
d8b7499cae
@ -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,
|
||||
|
||||
@ -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."),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user