diff --git a/packages/ui/src/features/canvas/components/FreeformPreview.tsx b/packages/ui/src/features/canvas/components/FreeformPreview.tsx index 37b1f62bfc..d15656cd69 100644 --- a/packages/ui/src/features/canvas/components/FreeformPreview.tsx +++ b/packages/ui/src/features/canvas/components/FreeformPreview.tsx @@ -6,7 +6,7 @@ import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { Box, Flex } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; -import { type ReactNode, useCallback } from "react"; +import { type ReactNode, useCallback, useState } from "react"; // Render each canvas's live app at 1/SCALE of the card width, then shrink so it // fits inside the preview frame as a thumbnail. @@ -34,6 +34,17 @@ export function FreeformPreview({ className?: string; }) { const [ref, inView] = useInView(PREVIEW_VIEWPORT); + // A preview whose sandbox never painted (a CDN module that wouldn't load, a + // compile error) leaves an empty frame that reads as "this canvas is blank". + // Tracking the first render tells that apart from a canvas that rendered and + // only later reported an error, which should keep showing what it painted. + const [rendered, setRendered] = useState(false); + const [failed, setFailed] = useState(false); + const onRendered = useCallback(() => { + setRendered(true); + setFailed(false); + }, []); + const onError = useCallback(() => setFailed(true), []); // Preview data handler: swallow captures so a thumbnail never emits analytics // events, but let reads through (cached, shared with the full view) so the @@ -69,17 +80,14 @@ export function FreeformPreview({ } - label="Preview unavailable" - /> - } + fallback={} > @@ -94,10 +102,23 @@ export function FreeformPreview({ label="Nothing built yet" /> )} + {/* Overlays the SCALED frame from outside it, so the message reads at + full size rather than shrunk to thumbnail scale. */} + {failed && !rendered && } ); } +// The sandbox failed before painting anything, so the frame is blank. +function PreviewUnavailable() { + return ( + } + label="Preview unavailable" + /> + ); +} + function PreviewPlaceholder({ icon, label, diff --git a/packages/ui/src/features/canvas/freeform/CanvasErrorState.test.tsx b/packages/ui/src/features/canvas/freeform/CanvasErrorState.test.tsx new file mode 100644 index 0000000000..e96c6480e6 --- /dev/null +++ b/packages/ui/src/features/canvas/freeform/CanvasErrorState.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CanvasErrorState } from "./CanvasErrorState"; + +describe("CanvasErrorState", () => { + it("explains the failure and offers a retry", async () => { + const onRetry = vi.fn(); + render( + , + ); + + expect( + screen.getByText("Couldn't load the canvas runtime."), + ).toBeInTheDocument(); + screen.getByRole("button", { name: "Try again" }).click(); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("only offers the agent in edit mode", () => { + const { rerender } = render( + , + ); + expect( + screen.queryByRole("button", { name: "Ask agent to fix" }), + ).not.toBeInTheDocument(); + + rerender( + , + ); + expect( + screen.getByRole("button", { name: "Ask agent to fix" }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/canvas/freeform/CanvasErrorState.tsx b/packages/ui/src/features/canvas/freeform/CanvasErrorState.tsx new file mode 100644 index 0000000000..a62e7cf002 --- /dev/null +++ b/packages/ui/src/features/canvas/freeform/CanvasErrorState.tsx @@ -0,0 +1,49 @@ +import { WarningIcon } from "@phosphor-icons/react"; +import { + Button, + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@posthog/quill"; + +// Shown when a canvas has nothing on screen because it failed to load or render: +// a CDN/runtime fetch that never landed, a compile error, or a render throw the +// sandbox's boundary swallowed. Without it the viewport is simply blank — and in +// view mode (no toolbar) that blank carries no explanation and no way out. +// Recoverable: "Try again" rebuilds the frame, and in edit mode the agent can be +// pointed straight at the error. +export function CanvasErrorState({ + message, + onRetry, + onAskAgent, +}: { + message: string; + onRetry: () => void; + /** Edit mode only — omitted in view mode, where there's no composer. */ + onAskAgent?: () => void; +}) { + return ( + + + + + + Couldn't load this canvas + {message} + + + + {onAskAgent && ( + + )} + + + ); +} diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx index 7d8d142e4e..35cc5a5b6c 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx @@ -1,23 +1,39 @@ import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { FreeformCanvas } from "./FreeformCanvas"; +import { CANVAS_BOOT_TIMEOUT_ERROR, FreeformCanvas } from "./FreeformCanvas"; vi.mock("@posthog/ui/shell/openExternal", () => ({ openExternalUrl: vi.fn(), })); -const renderCanvas = () => { +const renderCanvas = ( + props?: Partial>, +) => { render( , ); return screen.getByTitle("Canvas") as HTMLIFrameElement; }; +const postFrameFromCanvas = ( + iframe: HTMLIFrameElement, + frame: Record, +) => { + window.dispatchEvent( + new MessageEvent("message", { + data: { channel: "posthog-canvas", ...frame }, + source: iframe.contentWindow, + }), + ); +}; + const postFromCanvas = (iframe: HTMLIFrameElement, url: string) => { window.dispatchEvent( new MessageEvent("message", { @@ -37,6 +53,51 @@ describe("FreeformCanvas", () => { ); }); + // A sandbox that never boots (blocked/hung CDN fetch, blocked srcDoc) posts + // nothing at all, so without a watchdog the host shows an indefinitely blank + // frame with nothing to act on. + describe("boot watchdog", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("reports an error when the sandbox never renders", () => { + const onError = vi.fn(); + renderCanvas({ onError }); + + vi.advanceTimersByTime(30_001); + + expect(onError).toHaveBeenCalledWith(CANVAS_BOOT_TIMEOUT_ERROR); + }); + + it.each([ + { name: "rendered", frame: { type: "rendered" } }, + { name: "error", frame: { type: "error", message: "boom" } }, + ])("stops waiting once the sandbox posts $name", ({ frame }) => { + const onError = vi.fn(); + const iframe = renderCanvas({ onError }); + + postFrameFromCanvas(iframe, frame); + onError.mockClear(); + vi.advanceTimersByTime(30_001); + + expect(onError).not.toHaveBeenCalled(); + }); + + it("does not wait on a canvas with no code to render", () => { + const onError = vi.fn(); + renderCanvas({ code: "", onError }); + + vi.advanceTimersByTime(30_001); + + expect(onError).not.toHaveBeenCalled(); + }); + }); + describe("open-external", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx index afbce9eb32..4ae891a347 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx @@ -23,6 +23,16 @@ const log = logger.scope("freeform-canvas"); // Canvas code can post open-external without a gesture, so opens are limited. const EXTERNAL_OPEN_MIN_INTERVAL_MS = 1_000; +// How long to wait for the sandbox to report `rendered` (or an error) before +// treating the boot as failed. The sandbox reports as soon as it commits its +// first render — it does NOT wait for the canvas's data — so this only has to +// cover fetching the CDN modules, hence generous but finite. Without it a boot +// that stalls without erroring (a hung CDN request, a blocked srcDoc) leaves the +// host showing an indefinitely blank frame with nothing to act on. +const BOOT_TIMEOUT_MS = 30_000; +export const CANVAS_BOOT_TIMEOUT_ERROR = + "The canvas didn't finish loading. Check your connection and try again."; + export interface FreeformCanvasProps { /** The single-file React source to render. */ code: string; @@ -105,6 +115,30 @@ export function FreeformCanvas({ theme, }; + // Boot watchdog: armed whenever the sandbox is (re)booted or handed new code, + // cleared by the first `rendered` or `error` frame. On expiry it synthesises an + // error so the host can offer a recoverable state instead of a blank frame. + const bootTimerRef = useRef | null>(null); + const clearBootTimer = useCallback(() => { + if (bootTimerRef.current === null) return; + clearTimeout(bootTimerRef.current); + bootTimerRef.current = null; + }, []); + const armBootTimer = useCallback(() => { + clearBootTimer(); + bootTimerRef.current = setTimeout(() => { + bootTimerRef.current = null; + latest.current.onError?.(CANVAS_BOOT_TIMEOUT_ERROR); + }, BOOT_TIMEOUT_MS); + }, [clearBootTimer]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: srcDoc identity tracks a reload, which reboots the sandbox. + useEffect(() => { + if (!code) return; + armBootTimer(); + return clearBootTimer; + }, [code, srcDoc, armBootTimer, clearBootTimer]); + const postInit = useCallback(() => { const p = latest.current; iframeRef.current?.contentWindow?.postMessage( @@ -169,10 +203,12 @@ export function FreeformCanvas({ break; } case "error": + clearBootTimer(); log.warn("Freeform canvas error", { message: msg.message }); latest.current.onError?.(msg.message, msg.stack); break; case "rendered": + clearBootTimer(); latest.current.onRendered?.(); break; case "navigate": @@ -215,7 +251,7 @@ export function FreeformCanvas({ window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, [postInit]); + }, [postInit, clearBootTimer]); // Re-send init when the code / mode / analytics change, if the iframe is ready. // NB: reference code/mode/analytics DIRECTLY here (not via postInit, which diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx index d59ac286a4..802864e1e3 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx @@ -18,6 +18,7 @@ import { EmptyMedia, EmptyTitle, } from "@posthog/quill"; +import { useCanvasFrameStore } from "@posthog/ui/features/canvas/freeform/canvasFrameStore"; import { isCanvasGenerating, isCanvasGenerationRunning, @@ -44,11 +45,15 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useMemo, useRef, useState } from "react"; +import { CanvasErrorState } from "./CanvasErrorState"; import { CanvasFramePlaceholder } from "./CanvasFramePlaceholder"; import { CanvasGenerateHero } from "./CanvasGenerateHero"; import { CanvasPermissionDialog } from "./CanvasPermissionDialog"; import { CanvasSidePanel } from "./CanvasSidePanel"; -import { handleFreeformDataRequest } from "./freeformDataBridge"; +import { + CANVAS_QUERY_KEY, + handleFreeformDataRequest, +} from "./freeformDataBridge"; import { useCanvasNavigation, useHomeCanvasReset } from "./useHomeCanvasView"; // The dashboardId a thread is keyed on ("dashboard:" → ""). @@ -69,11 +74,13 @@ export function FreeformCanvasView({ interactive: boolean; }) { const dashboardId = dashboardIdOf(threadId); - const { code, versions, currentVersionId, runtimeError } = + const { code, versions, currentVersionId, runtimeError, hasRendered } = useFreeformThread(threadId); const undo = useFreeformChatStore((s) => s.undo); const redo = useFreeformChatStore((s) => s.redo); const setRuntimeError = useFreeformChatStore((s) => s.setRuntimeError); + const markRendered = useFreeformChatStore((s) => s.markRendered); + const remountFrame = useCanvasFrameStore((s) => s.remount); // Right-hand panel state (persisted minimize + width). `startedTaskId` is a // local bridge so the composer floats to the side immediately on submit, @@ -209,8 +216,8 @@ export function FreeformCanvasView({ [threadId, setRuntimeError], ); const onRendered = useCallback( - () => setRuntimeError(threadId, null), - [threadId, setRuntimeError], + () => markRendered(threadId), + [threadId, markRendered], ); // Routes the canvas's allowlisted nav intents within this channel. @@ -236,7 +243,13 @@ export function FreeformCanvasView({ // Deriving from the record rather than waiting on the seed also means a seed // that never runs can't strand the canvas on a spinner. const renderCode = code || dashboard?.code || ""; - const showCanvas = !!renderCode; + // The canvas errored with nothing on screen — a CDN/runtime load failure, a + // compile error, or a render throw the sandbox boundary swallowed. The frame is + // blank, so surface a recoverable error instead of an empty viewport (which in + // view mode carries no affordance at all). A canvas that DID render keeps + // showing, with only the toolbar's error notice. + const showErrorState = !!renderCode && !!runtimeError && !hasRendered; + const showCanvas = !!renderCode && !showErrorState; // `isGenerating` keys off the effective task (the optimistic bridge right after // submit, then the polled record) and short-circuits on a terminal run — so a // failed/cancelled run can't strand the canvas body on the spinner. @@ -250,8 +263,20 @@ export function FreeformCanvasView({ // and only when no run is in flight. After submit it floats into the panel. const showHero = interactive && !renderCode && !effectiveTaskId && !dashboardLoading; - // The side panel only exists once there's a canvas or an active run. - const showPanel = interactive && (showCanvas || !!effectiveTaskId); + // The side panel only exists once there's a canvas or an active run. It stays + // mounted in the error state too, so "Ask agent to fix" has a composer to + // prefill. + const showPanel = + interactive && (showCanvas || showErrorState || !!effectiveTaskId); + + // Recover from a blank/wedged frame: drop the host-side read cache so data + // queries re-run, recreate the iframe element (a fresh document = a fresh + // attempt at the CDN modules), and clear the error so the frame is shown again. + const onRetry = () => { + void queryClient.invalidateQueries({ queryKey: [CANVAS_QUERY_KEY] }); + remountFrame(dashboardId); + setRuntimeError(threadId, null); + }; return ( @@ -387,7 +412,13 @@ export function FreeformCanvasView({ ) : ( - {showGeneratingState ? ( + {showErrorState ? ( + + ) : showGeneratingState ? ( { expect(html).toContain("event.defaultPrevented"); }); + // A failed CDN fetch used to take the whole bootstrap module down with it + // (a static top-level `import` of Babel), so the error handlers and the + // "ready" handshake never ran and the host was left with a permanently blank + // frame it had no way to distinguish from a canvas that hadn't rendered. + it("loads the transpiler lazily so a CDN failure is reported, not fatal", () => { + const html = buildSandboxDocument("edit"); + expect(html).not.toContain(`import * as Babel from "${BABEL_URL}"`); + expect(html).toContain(`loadModule("${BABEL_URL}")`); + expect(html).toContain("const Babel = await loadBabel();"); + }); + + it("registers its error reporting before anything that can fail", () => { + const html = buildSandboxDocument("edit"); + const reporter = html.indexOf('window.addEventListener("error"'); + const rejections = html.indexOf( + 'window.addEventListener("unhandledrejection"', + ); + const firstCdnLoad = html.indexOf("loadModule("); + expect(reporter).toBeGreaterThan(-1); + expect(reporter).toBeLessThan(firstCdnLoad); + expect(rejections).toBeLessThan(firstCdnLoad); + }); + // The document paints before its stylesheets load and before the host's // theme message arrives. A light fallback there flashed white over a dark // app every time a canvas preview scrolled into view. diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 8f4f73d27f..c972ef4fb2 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -21,6 +21,12 @@ import { // and forbids third-party egress entirely. export type SandboxMode = "edit" | "view"; +// Prefix for the error the sandbox reports when a CDN module (Babel, React, or a +// whitelisted package) can't be fetched. Interpolated into the bootstrap below — +// keep it free of quotes and backslashes. +const CANVAS_RUNTIME_LOAD_ERROR = + "Couldn't load the canvas runtime. Check your connection and try again."; + // Which in-browser Tailwind engine the EDIT-mode sandbox runs. "v4" matches the // Quill version we ship (Quill is authored for Tailwind v4) and lets us drop the // v3 Play CDN's preflight-off hack, the `not-disabled` variant shim, the manual @@ -198,10 +204,56 @@ export function buildSandboxDocument( // a Blob module (which resolves bare imports via the import map above), and // reports lifecycle + errors back to the host. const bootstrap = /* js */ ` - import * as Babel from "${FREEFORM_BABEL_URL}"; const CHANNEL = "posthog-canvas"; const post = (msg) => parent.postMessage({ channel: CHANNEL, ...msg }, "*"); + // --- error reporting (feeds the host's error surface + self-repair loop) --- + // Declared FIRST, before anything that can fail, so no failure below is silent. + const reportError = (message, stack) => + post({ type: "error", message: String(message ?? "Unknown error"), stack }); + window.addEventListener("error", (e) => + reportError(e.message, e.error && e.error.stack), + ); + window.addEventListener("unhandledrejection", (e) => + reportError( + (e.reason && e.reason.message) || e.reason, + e.reason && e.reason.stack, + ), + ); + + // The transpiler and the canvas's packages are fetched from the CDN at render + // time, so a CDN/network hiccup MUST be reported rather than being fatal. A + // static top-level \`import\` of Babel would abort this whole module when the + // fetch fails — taking the error handlers and the "ready" handshake with it, + // so the host got no message at all and showed a permanently blank canvas it + // couldn't tell apart from one that simply hadn't rendered. Loading lazily + // instead keeps this module alive to report the failure. + // + // A failed fetch is NOT recoverable inside this document: the browser records + // the specifier as errored in its module map, so re-importing rethrows without + // a network attempt. Recovering means a fresh document — which is what the + // host's "Try again" does (it recreates the iframe element). + let babelPromise = null; + const loadModule = async (spec) => { + try { + return await import(spec); + } catch (err) { + throw new Error( + "${CANVAS_RUNTIME_LOAD_ERROR}" + + ' ("' + spec + '": ' + ((err && err.message) || err) + ")", + ); + } + }; + const loadBabel = () => { + if (!babelPromise) { + babelPromise = loadModule("${FREEFORM_BABEL_URL}").catch((err) => { + babelPromise = null; + throw err; + }); + } + return babelPromise; + }; + // --- data shim: the ONLY way canvas code reaches PostHog. No token here. --- const pending = new Map(); let reqSeq = 0; @@ -311,19 +363,6 @@ export function buildSandboxDocument( const applyTheme = (theme) => document.documentElement.classList.toggle("dark", theme === "dark"); - // --- error reporting (feeds the host's self-repair loop) --- - const reportError = (message, stack) => - post({ type: "error", message: String(message ?? "Unknown error"), stack }); - window.addEventListener("error", (e) => - reportError(e.message, e.error && e.error.stack), - ); - window.addEventListener("unhandledrejection", (e) => - reportError( - (e.reason && e.reason.message) || e.reason, - e.reason && e.reason.stack, - ), - ); - // JSX text and attribute strings never process \\uXXXX escapes (they render // verbatim, e.g. "\\u00b7" instead of "·"), but generated canvases still // contain them despite the prompt rules — decode at transpile time so both @@ -362,6 +401,8 @@ export function buildSandboxDocument( const mount = async (code) => { const seq = ++mountSeq; try { + const Babel = await loadBabel(); + if (seq !== mountSeq) return; // a newer snapshot superseded this one const out = Babel.transform(code, { filename: "canvas.tsx", plugins: [jsxUnicodeEscapesPlugin], @@ -384,8 +425,8 @@ export function buildSandboxDocument( if (typeof Comp !== "function") { throw new Error("Canvas must \`export default\` a React component."); } - const React = await import("react"); - const { createRoot } = await import("react-dom/client"); + const React = await loadModule("react"); + const { createRoot } = await loadModule("react-dom/client"); if (seq !== mountSeq) return; const el = document.getElementById("root"); if (!root) root = createRoot(el); diff --git a/packages/ui/src/features/canvas/stores/freeformChatStore.test.ts b/packages/ui/src/features/canvas/stores/freeformChatStore.test.ts new file mode 100644 index 0000000000..fc86bcb828 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/freeformChatStore.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + EMPTY_FREEFORM_THREAD, + useFreeformChatStore, +} from "./freeformChatStore"; + +vi.mock("../hostClient", () => ({ + hostClient: () => ({ + dashboards: { saveFreeform: { mutate: vi.fn().mockResolvedValue({}) } }, + }), +})); + +const THREAD = "dashboard:d1"; + +const version = (id: string, code: string) => ({ + id, + code, + context: "", + createdAt: 0, +}); + +const thread = () => + useFreeformChatStore.getState().threads[THREAD] ?? EMPTY_FREEFORM_THREAD; + +beforeEach(() => { + useFreeformChatStore.setState({ threads: {}, threadOrder: [] }); +}); + +// `hasRendered` is what separates "errored with nothing on screen" — the blank +// canvas — from "errored after rendering", where the canvas is still visible and +// a toolbar notice is enough. +describe("freeformChatStore render health", () => { + it("starts out as not-yet-rendered with no error", () => { + expect(thread().hasRendered).toBe(false); + expect(thread().runtimeError).toBeNull(); + }); + + it("clears the error and records the render when the sandbox commits one", () => { + const { setRuntimeError, markRendered } = useFreeformChatStore.getState(); + + setRuntimeError(THREAD, "boom"); + expect(thread().runtimeError).toBe("boom"); + + markRendered(THREAD); + expect(thread().runtimeError).toBeNull(); + expect(thread().hasRendered).toBe(true); + }); + + it("keeps an error raised after a successful render", () => { + const { markRendered, setRuntimeError } = useFreeformChatStore.getState(); + + markRendered(THREAD); + setRuntimeError(THREAD, "a later data failure"); + + expect(thread().hasRendered).toBe(true); + expect(thread().runtimeError).toBe("a later data failure"); + }); + + it("resets render health when a record seeds new code", () => { + const { markRendered, syncFromRecord } = useFreeformChatStore.getState(); + + markRendered(THREAD); + syncFromRecord(THREAD, { code: "export default () => null" }); + + expect(thread().code).toBe("export default () => null"); + expect(thread().hasRendered).toBe(false); + }); + + it.each([ + { + name: "undo", + // Starts on the head version, so undo has somewhere to go. + startOnOlderVersion: false, + act: () => useFreeformChatStore.getState().undo(THREAD), + }, + { + name: "redo", + startOnOlderVersion: true, + act: () => useFreeformChatStore.getState().redo(THREAD), + }, + { + name: "goToLatest", + startOnOlderVersion: true, + act: () => useFreeformChatStore.getState().goToLatest(THREAD), + }, + ])( + "resets render health when $name swaps the live code", + ({ startOnOlderVersion, act }) => { + const { syncFromRecord, markRendered, undo } = + useFreeformChatStore.getState(); + syncFromRecord(THREAD, { + code: "v2", + versions: [version("a", "v1"), version("b", "v2")], + currentVersionId: "b", + }); + if (startOnOlderVersion) undo(THREAD); + markRendered(THREAD); + + act(); + + expect(thread().hasRendered).toBe(false); + }, + ); +}); diff --git a/packages/ui/src/features/canvas/stores/freeformChatStore.ts b/packages/ui/src/features/canvas/stores/freeformChatStore.ts index 011196f808..1b3e5cf280 100644 --- a/packages/ui/src/features/canvas/stores/freeformChatStore.ts +++ b/packages/ui/src/features/canvas/stores/freeformChatStore.ts @@ -39,6 +39,14 @@ export interface FreeformThreadState { isSaving: boolean; /** Latest runtime/compile error reported by the sandbox (self-repair signal). */ runtimeError: string | null; + /** + * Whether the sandbox has committed a render of the CURRENT code. Together with + * `runtimeError` this separates "errored with nothing on screen" (a blank + * surface, so show a recoverable error) from "errored after rendering" (the + * canvas is visible, so the toolbar notice is enough). Reset whenever the live + * code changes, since the new code hasn't rendered yet. + */ + hasRendered: boolean; } export const EMPTY_FREEFORM_THREAD: FreeformThreadState = { @@ -49,6 +57,7 @@ export const EMPTY_FREEFORM_THREAD: FreeformThreadState = { context: "", isSaving: false, runtimeError: null, + hasRendered: false, }; interface FreeformChatStore { @@ -71,6 +80,9 @@ interface FreeformChatStore { undo: (threadId: string) => void; redo: (threadId: string) => void; setRuntimeError: (threadId: string, message: string | null) => void; + /** The sandbox committed a render: clears any error and records that the + * current code has something on screen. */ + markRendered: (threadId: string) => void; /** * Revert: when viewing a non-latest version, make it the head (drop the newer * versions) and autosave. The canvas then continues from this version. @@ -95,6 +107,15 @@ function newId(): string { return crypto.randomUUID(); } +// Applied by every patch that swaps the live `code`: the incoming source hasn't +// rendered yet, and the outgoing source's error no longer describes what's on +// screen. Keeping these in step with `code` is what lets the view tell a blank +// surface (errored, nothing rendered) from a rendered canvas that later errored. +const RESET_RENDER_HEALTH = { + runtimeError: null, + hasRendered: false, +} satisfies Pick; + // The dashboardId a thread persists to ("dashboard:" → ""). function dashboardIdOf(threadId: string): string { return threadId.replace(/^dashboard:/, ""); @@ -173,6 +194,7 @@ export const useFreeformChatStore = create()((set, get) => { record.currentVersionId ?? record.versions?.at(-1)?.id ?? null, templateId: record.templateId ?? prev.templateId, context: record.context ?? "", + ...RESET_RENDER_HEALTH, })); return { @@ -281,6 +303,7 @@ export const useFreeformChatStore = create()((set, get) => { code: target.code, context: target.context ?? prev.context, currentVersionId: target.id, + ...RESET_RENDER_HEALTH, }; }); }, @@ -297,6 +320,7 @@ export const useFreeformChatStore = create()((set, get) => { code: target.code, context: target.context ?? prev.context, currentVersionId: target.id, + ...RESET_RENDER_HEALTH, }; }); }, @@ -305,6 +329,14 @@ export const useFreeformChatStore = create()((set, get) => { patch(threadId, (prev) => ({ ...prev, runtimeError: message })); }, + markRendered: (threadId) => { + patch(threadId, (prev) => ({ + ...prev, + runtimeError: null, + hasRendered: true, + })); + }, + revert: (threadId) => { // Guard against an evicted/never-seeded thread: patch() would otherwise // materialize EMPTY_FREEFORM_THREAD and persist() would then save @@ -332,6 +364,7 @@ export const useFreeformChatStore = create()((set, get) => { code: head.code, context: head.context ?? prev.context, currentVersionId: head.id, + ...RESET_RENDER_HEALTH, }; }); },