block
*/
import { useAtomValue, useSetAtom } from "jotai";
-import { Layout, RefreshCw } from "lucide-react";
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { Layout, PenTool, RefreshCw } from "lucide-react";
+import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
+import Button from "@src/components/Button";
import DiffStatsBadge from "@src/components/DiffStatsBadge";
import IconButton from "@src/components/IconButton";
import TabPill from "@src/components/TabPill";
import { NoDragRegion } from "@src/components/WindowChrome";
import { SIMULATOR_PRIMARY_SIDEBAR } from "@src/config/simulatorPrimarySidebar";
-import CanvasPreviewSurface from "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasPreviewSurface";
+import CanvasRevisionProgress from "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionProgress";
+import { isCanvasRevisionDraftRelevant } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevisionProgressState";
import type { CanvasInlineMode } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types";
+import { useCanvasRevisionDraftForSession } from "@src/engines/SessionCore";
import type { SessionEvent } from "@src/engines/SessionCore/core/types";
import { usePublishWorkstationTabHeader } from "@src/hooks/workStation";
import { SessionReplayCodeMirrorViewer } from "@src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer";
@@ -40,6 +37,7 @@ import {
PrimarySidebarLayoutWithSections,
SimulatorReplayChrome,
WorkStationShell,
+ WorkstationToolbarTooltip,
buildPrimarySidebarConfig,
} from "@src/modules/WorkStation/shared";
import type { PrimarySidebarTab } from "@src/modules/WorkStation/shared/PrimarySidebarLayout/PrimarySidebarLayoutWithSections";
@@ -55,11 +53,20 @@ import {
import type { SimulatorAppProps } from "../core/types";
import { useSimulatorAppState } from "../core/useSimulatorAppState";
import { CANVAS_APP_CONFIG } from "./canvasConfig";
+import {
+ type CanvasViewTab,
+ createCanvasInteractionState,
+ reconcileCanvasInteractionState,
+ reloadCanvas,
+ selectCanvasEvent,
+ setCanvasViewTab,
+ toggleCanvasComparison,
+} from "./canvasInteractionState";
+import { projectLatestCanvasEvents } from "./canvasRevisionProjection";
+import CanvasDesignSurface from "./design/CanvasDesignSurface";
// ─── types ────────────────────────────────────────────────────────────────────
-type ViewTab = "canvas" | "source" | "compare";
-
interface CanvasPayload {
mode: CanvasInlineMode;
content?: string;
@@ -391,30 +398,31 @@ interface CanvasIframeProps {
payload: CanvasPayload;
reloadKey: number;
title: string;
+ eventId: string;
+ sessionId: string;
+ designEnabled: boolean;
+ onRequestDisableDesign: () => void;
}
const CanvasIframe: React.FC = ({
payload,
reloadKey,
title,
+ eventId,
+ sessionId,
+ designEnabled,
+ onRequestDisableDesign,
}) => {
- const { t } = useTranslation("sessions");
-
return (
-
-
- {payload.streaming
- ? t("canvasCard.waiting", "Waiting for content…")
- : t("canvasCard.empty", "No content")}
-
- Keep me
", + }); + const firstPatch = canvasEvent("first-patch", { + revises: "original", + edits: [{ find: "Start", replace: "Start setup" }], + }); + const secondPatch = canvasEvent("second-patch", { + revises: "first-patch", + edits: [{ find: "Keep me", replace: "Still here" }], + }); + + const [latest] = projectLatestCanvasEvents([ + original, + firstPatch, + secondPatch, + ]); + + expect(latest.id).toBe("second-patch"); + expect(latest.args.content).toBe( + "Still here
" + ); + }); + + it("keeps the last valid Canvas when a compact edit is stale or ambiguous", () => { + const original = canvasEvent("original", { + content: "SameSame", + }); + const ambiguous = canvasEvent("ambiguous", { + revises: "original", + edits: [{ find: "Same", replace: "Changed" }], + }); + + expect(projectLatestCanvasEvents([original, ambiguous])).toEqual([ + original, + ]); + }); + + it("supports deliberate replace-all edits", () => { + const original = canvasEvent("original", { + content: "small small", + }); + const revision = canvasEvent("revision", { + revises: "original", + edits: [{ find: "small", replace: "large", all: true }], + }); + + const [latest] = projectLatestCanvasEvents([original, revision]); + expect(latest.args.content).toBe("large large"); + }); + + it("ignores malformed dedicated revisions instead of creating new Canvases", () => { + const missing = canvasEvent("missing", { revises: "not-present" }); + const futureRevision = canvasEvent("future-revision", { + revises: "future-target", + }); + const futureTarget = canvasEvent("future-target"); + const otherSession = canvasEvent("other-session", { + sessionId: "session-b", + revises: "future-target", + }); + + expect( + projectLatestCanvasEvents([ + missing, + futureRevision, + futureTarget, + otherSession, + ]) + ).toEqual([futureTarget]); + }); + + it("keeps the last valid Canvas when a revision fails", () => { + const original = canvasEvent("original"); + const failed = canvasEvent("failed-revision", { + revises: "original", + status: "failed", + }); + + expect(projectLatestCanvasEvents([original, failed])).toEqual([original]); + }); + + it("still projects persisted legacy revision chains", () => { + const original = canvasEvent("original"); + const legacyRevision = canvasEvent("legacy-revision", { + revises: "original", + legacyRevision: true, + }); + + expect(projectLatestCanvasEvents([original, legacyRevision])).toEqual([ + legacyRevision, + ]); + }); + + it("keeps malformed legacy metadata visible as a separate historical Canvas", () => { + const legacy = canvasEvent("legacy", { + revises: "missing", + legacyRevision: true, + }); + + expect(projectLatestCanvasEvents([legacy])).toEqual([legacy]); + }); +}); diff --git a/src/engines/Simulator/apps/canvas/canvasRevisionProjection.ts b/src/engines/Simulator/apps/canvas/canvasRevisionProjection.ts new file mode 100644 index 000000000..0570868ac --- /dev/null +++ b/src/engines/Simulator/apps/canvas/canvasRevisionProjection.ts @@ -0,0 +1,68 @@ +import { + getCanvasRevisionTargetId, + isCanvasRevisionToolName, + materializeCanvasRevisionArgs, +} from "@src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +/** + * Project immutable render events into logical Canvases. + * + * A valid revision must point backwards to another Canvas event in the same + * session. The latest event replaces that logical Canvas at its original list + * position, while every event remains available in the session history for + * replay and diagnostics. + */ +export function projectLatestCanvasEvents( + events: readonly SessionEvent[] +): SessionEvent[] { + const projected: SessionEvent[] = []; + const eventById = new Map
+ Label
+
+
+ `);
+
+ expect(result).toContain("color:red");
+ expect(result).toContain("Label");
+ expect(result).not.toContain("href=");
+ expect(result).not.toContain("src=");
+ expect(result).not.toContain("onerror");
+ expect(result).not.toContain("url(");
+ expect(result).not.toContain("script");
+ });
+
+ it("returns valid bounded fallback markup", () => {
+ const result = sanitizeDomPreviewHtml(
+ `