* feat(core): add visual diff and patch apply tools
diff_visual renders two nodes at one scale through the existing raster export, compares them with pixelmatch, and returns the diff PNG with the changed ratio and region in source-node coordinates. It takes export_image's scale and maxEdge inputs. FigmaAPI gains a CanvasKit-backed raster codec and a pageId export option, so the app and headless CLI decode pixels and render nodes off the current page.
diff_apply applies diff_create and diff_show patches through the Figma API, validates every node before changing any, and supports dryRun and force. diff_show now simulates changes on a detached copy with the same property code. One serializer and parser back all three. diffDocuments compares two documents page by page by name path.
Image tool results now reach models as media with their metadata as text, for any tool rather than export_image alone. diff_create, diff_jsx, and diff_visual join the default AI tool set, and the diff tools are no longer hidden from WebMCP.
* feat(ai): show what each AI edit changed in its tool call
Reviewing an AI run meant reading tool output or undoing steps to see
what moved. Each document-changing call now rebuilds its page before
and after from snapshots taken around it, and diffs each top-level
layer's JSX with jsdiff, the same patch diff_jsx returns, to find the
layers it changed. After the call returns, the changed region renders
in both states at one size and pixelmatch highlights the difference.
The tool card opens on a Changes view with a before/after slider, the
pixel highlight, and a CodeMirror merge view of the JSX. Records are
saved with the conversation next to attachments.
Calls snapshot their page individually instead of through one shared
variable, so concurrent calls in a step no longer overwrite each
other's undo state. Core gains graphFromPageSnapshot for rebuilding a
past page state, diffPageLayersJSX and jsxPatch (now shared with
diff_jsx), renderRegionToImage for rendering two states of one region
pixel for pixel, and comparePNGs on the raster codec. Settings > Chat >
Change previews sets the stored image size or turns images off.
* feat(cli): add diff commands and agent diff guidance
openpencil diff create, jsx, show, apply, and visual run the Core diff tools on a file or the running app; apply writes back with --write or --output like eval. diff files compares two documents page by page and exits 1 when they differ.
The chat prompt asks the agent to edit in place and to verify risky edits against a reference copy with diff_jsx, diff_create, and diff_visual. The skill, CLI reference, MCP tool table, and a new Comparing Designs page document the commands and tools.
* feat(ai): render tool calls as summarized, highlighted cards
Every tool call showed only a status and its output as a JSON string,
so render calls hid their JSX, export_image dumped base64, and long
runs filled the transcript with identical rows.
A call now shows a one-line summary read from its input and chips that
select and zoom to the layers it touched, switching to the run's page
when needed. Expanded, it shows the JSX or script it wrote and its
JSON input and output in a read-only CodeMirror view, and exported
images inline. Render calls can be expanded while their input streams,
so the JSX appears alongside the canvas preview. Consecutive calls
beyond three fold into one row that keeps the latest call visible.
CodeMirror loads with the first expanded call. The code theme gains a
monospace fallback because the editor font variable is not always
emitted.
* feat(ai): let the chat AI diff its run against the starting state
The diff tools compare two nodes, so checking an edit meant cloning a
reference first, which the agent rarely did. diff_changes compares the
current page, or one node under it, with the page as it was before the
run first edited it, in diff_create's patch format. The app keeps that
page snapshot per run and exposes it through FigmaAPI.changeBaseline;
MCP and WebMCP have no run, so the tool is offered only to the AI chat,
where it is enabled by default and the prompt asks for it before
reporting.
* feat(ai): revert, regenerate, and edit chat turns
A reply that went wrong could only be undone step by step from the Edit
menu, and asking again meant typing a new message on top of the old
edits. Each run now keeps the undo entries its document edits push, and
the reply offers Revert changes while those entries are still the
newest on the undo stack, so it never undoes an edit made since. The
last reply can be regenerated, and the last message without
attachments edited and sent again; both undo the replaced reply's edits
first when they can.
UndoManager gains peekUndo so a caller can tell whether its entries
are still on top, and turn state follows the store's history:changed
event. The submission's chat dependency narrows to the members it uses.
* feat(core): diff and patch node trees as JSX attributes
diff_create, diff_show, diff_apply, and diffDocuments used a hand-rolled
`key: value` property format that covered about fifteen properties,
matched children by name path, and could not see moves.
Nodes are now projected to the attributes the JSX export prints, and
jsondiffpatch matches children (by ID or by name path) and detects
moves. Patches list `-`/`+` attribute lines per node plus moved, added,
and removed children. diff_apply checks every hunk first, applies
attribute changes through the renderer's prop handling, and changes only
the fields an attribute moves, so IDs, instance links, and other state
survive. diff_show takes JSX attributes instead of a JSON props object.
design-jsx gains sceneNodeAttributes, parseJSXAttributes, and
jsxNodeFields for this, and the export round-trip property table is
shared so every case is also diffed and applied. `diff files` loads its
documents in order so node IDs, and so its patches, are deterministic.
* feat(ai): report diff_changes as a patch diff_apply can replay
diff_changes printed a unified diff of the JSX, which agents could read
but not apply. It now diffs the run's baseline against the live page
with the patch engine, matching nodes by ID, so a rename is a changed
name and the output replays on the starting state with diff_apply. The
chat's Changes view keeps the JSX line diff, which is for people.
* chore: format the merged AI tool exports
* fix(core): keep diff_apply atomic and diff files honest about differences
- Added nodes render before anything else changes; if one fails, for
example on a missing component, the rendered ones are deleted and
nothing else is committed.
- A hunk with an attribute the renderer ignores fails instead of
reporting "unchanged".
- diffDocuments reports `changed` from page statuses, and a page only
one document has gets its status but no patch, since patches do not
add or remove pages. diff files uses it, so an added empty page no
longer reads as a match.
- diff files rejects a --page neither document has and a --depth that
is not a non-negative integer, exiting 2; diff_create's depth is
validated the same way.
* refactor(ai): drop the unused tool JSON slot and place the JSX summary comment
* refactor(ai): find a tool change's clipping region with jsdiff
clipChangedJSX scanned both JSX sources character by character for their common start and end. diffLines gives the unchanged lines before the first change and after the last; the app now declares the diff dependency Core already uses.
* fix(ai): keep a turn revertable when it ends with a view change
Every mutating AI tool pushes an undo entry, but a turn recorded only those of tools that change the document. A run that closed with viewport_zoom_to_fit left that entry on top of the undo stack, outside the turn, so Revert changes never appeared and Regenerate did not undo the reply. The turn now records every entry its run pushes.
* test(ai): give the fake chat Chat's sendMessage signature
The test type check added in #896 rejects a fake whose sendMessage requires text; Chat's takes an optional message, and the fake reads only its ID.
* feat(ai): keep reverted replies marked and tell the model about them
Reverting a reply undid its edits and hid the button, so the chat still read as if the edits were there, and the model's next request still carried the tool calls and results that made them, so it could build on nodes that no longer existed. A reverted reply is now marked in its message metadata, which conversations store, and shows Changes reverted. The next request carries a hidden note, in the way referenced nodes are passed, for each revert no request in the history has reported; a resent message reports again what the message it replaces reported. Edit > Redo bringing the edits back removes the mark. The revert bookkeeping lives in submission/reverts.ts, and ChatInstance moves to submission/types.ts beside ChatSubmission.
* fix(ai): keep reverted replies cloneable so the chat still saves
Marking a reply copied the chat's reactive message, so the copy carried proxied parts and saving the conversation failed with DataCloneError on the revert and on every save after it. The message is unwrapped before it is copied. The unit test's chat now keeps messages in a deep ref like @ai-sdk/vue's Chat and saves them synchronously like the history does, which reproduces the error. A new browser test drives the app's own tool loop with a scripted model: it reverts a real render, checks the mark, the note in the next request, that the chat saves without errors, and that the mark is still there after reopening the conversation.
* feat(ai): restore a reverted reply's changes from the chat
A reverted reply only said Changes reverted, so bringing its edits back meant Edit > Redo. While nothing has been edited since the revert, the reply now offers Restore changes, which redoes exactly its edits; the existing Redo listener then removes the mark. Once other edits close Redo, the reply stays marked. UndoManager.peekRedo mirrors peekUndo, so a turn can tell its entries are the next Redo applies.