From 2b82424d7feabfdd84bc78d1e088ed78724cc045 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 15:31:34 -0800 Subject: [PATCH] Sitesmith: self-review sees the page it just built, reports honestly; per-message pictures A. A page/site replace lands on the canvas in loadState's deferred deserialize, so apply() returned before the canvas changed and the self-review composed, captured and summarised the PREVIOUS page (the live eval: every review said "the render doesn't match the page I built"). PageContext.whenCanvasSettled() resolves when the requested load really ran; apply() awaits it (bounded, 5 s) and returns the applied fingerprint (from the response tree for replaces). The review checks the live canvas against it right before composing and skips with a plain note otherwise. Header/footer/title come from latestChrome() (refs updated in the same tick), not the modal's one-render-old context. B. Review outcomes are fixed / fine / mismatch / not-applied / skipped and quote the model. An empty patch where the model says the render was not its page is "did not work this time", never "found nothing to fix"; an ask is not a review; ops naming missing nodes are "none could be applied" (applyPatch now counts ops that took effect). E. Pictures from earlier sessions are labelled "saved earlier" and never preselected; a line above the box lists what goes with THIS message; multi-file uploads select every file. Clear chat says it deletes the site's whole conversation for everyone (one thread per site, backend). Co-Authored-By: Claude Opus 5.5 --- craft/src/panels/sitesmith/AttachmentTray.tsx | 19 ++- .../sitesmith/SitesmithModal.flows.test.tsx | 112 +++++++++++++++++- craft/src/panels/sitesmith/SitesmithModal.tsx | 69 ++++++++--- .../panels/sitesmith/SitesmithPanels.test.tsx | 22 ++++ craft/src/panels/sitesmith/selfReview.test.ts | 68 +++++++++-- craft/src/panels/sitesmith/selfReview.ts | 68 +++++++++-- craft/src/state/PageContext.tsx | 38 ++++++ craft/src/utils/apply-ai-response.ts | 55 +++++++-- craft/src/utils/canvas-fingerprint.test.ts | 45 +++++++ craft/src/utils/canvas-fingerprint.ts | 55 +++++++++ 10 files changed, 501 insertions(+), 50 deletions(-) create mode 100644 craft/src/utils/canvas-fingerprint.test.ts create mode 100644 craft/src/utils/canvas-fingerprint.ts diff --git a/craft/src/panels/sitesmith/AttachmentTray.tsx b/craft/src/panels/sitesmith/AttachmentTray.tsx index c25689a..117e1ae 100644 --- a/craft/src/panels/sitesmith/AttachmentTray.tsx +++ b/craft/src/panels/sitesmith/AttachmentTray.tsx @@ -29,6 +29,8 @@ export const AttachmentTray: React.FC = ({ const [drag, setDrag] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); + /** Pictures uploaded through this tray now; every other one was saved in an earlier session. */ + const [fresh, setFresh] = useState>(() => new Set()); const mockups = images.filter((i) => i.kind === 'mockup'); const off = !!unavailableReason || !!disabled; const used = selected.length + referenceImageCount; @@ -37,12 +39,19 @@ export const AttachmentTray: React.FC = ({ if (!files || off) return; setErr(null); setBusy(true); + // Accumulate: `selected` is this render's value, so re-reading it per file would keep only the last one. + let sel = [...selected]; try { for (const f of Array.from(files)) { - if (used + 1 > maxAttach) { setErr(`You can attach up to ${maxAttach} images to one request.`); break; } + if (sel.length + referenceImageCount + 1 > maxAttach) { setErr(`You can attach up to ${maxAttach} images to one request.`); break; } const r = await onUpload(f); if (!r.ok) { setErr(r.message); break; } - if (r.image) onSelectedChange([...selected, r.image.id]); + if (r.image) { + const id = r.image.id; + sel = [...sel, id]; + setFresh((x) => new Set(x).add(id)); + onSelectedChange(sel); + } } } finally { setBusy(false); } }; @@ -92,16 +101,20 @@ export const AttachmentTray: React.FC = ({
    {mockups.map((im) => { const on = selected.includes(im.id); + const when = fresh.has(im.id) ? 'just uploaded' : 'saved earlier'; return (
  • {im.width && im.height ? `${im.width}×${im.height} · ` : ''}{formatBytes(im.size)}
    +
    + {fresh.has(im.id) ? 'Just uploaded' : `Saved earlier${im.created_at ? ` (${im.created_at.slice(0, 10)})` : ''}`} +
  • ); diff --git a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx index 3979b85..465f34d 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx @@ -36,7 +36,7 @@ const flush = (ms = 0) => h.act(async () => { await new Promise((r) => setTimeou interface Net { starts: any[]; cancels: any[]; deleted: string[]; uploads: number; - polls: any[]; caps: any; history: any; entitlement: any; assets: any[]; refs: any[]; startFail: boolean; uploadFail: boolean; + polls: any[]; caps: any; history: any; entitlement: any; assets: any[]; refs: any[]; refImages: any[]; startFail: boolean; uploadFail: boolean; } let net: Net; @@ -50,7 +50,8 @@ function installFetch() { if (url.includes('action=entitlement')) return json(net.entitlement); if (url.includes('action=capabilities')) return json(net.caps); if (url.includes('action=history')) return json(net.history); - if (url.includes('action=refs_list')) return json({ ok: true, references: net.refs, images: [] }); + if (url.includes('action=refs_list')) return json({ ok: true, references: net.refs, images: net.refImages }); + if (url.includes('action=ref_image_upload')) return json({ ok: true, image: { id: 'n'.repeat(32), kind: 'mockup', name: 'mockup-ols-wireframe', width: 1200, height: 1500, size: 5000, created_at: '2026-11-01 23:09:55' } }); if (url.includes('action=assets')) return json({ ok: true, assets: net.assets }); if (url.includes('action=review_render_upload')) { net.uploads++; return net.uploadFail ? json({ ok: false, error: 'x', message: 'Upload failed' }, 500) : json({ ok: true, image: { id: 'f'.repeat(32), kind: 'mockup', name: 'self-review-render.jpg', width: 1280, height: 900, size: 5000 } }); } if (url.includes('action=ref_image_delete')) { net.deleted.push(JSON.parse(init.body).id); return json({ ok: true }); } @@ -60,7 +61,7 @@ function installFetch() { const VISION_ON = { ok: true, vision: { available: true, source: 'primary', message: '' }, features: { mockups: true, reference_screenshots: true, captions: true, self_review: true }, limits: { max_ref_images: 12, max_site_refs: 20, max_upload_bytes: 8388608, max_attach_images: 4 } }; const VISION_OFF = { ...VISION_ON, vision: { available: false, source: 'none', message: 'This server has no vision-capable model.' }, features: { mockups: false, reference_screenshots: false, captions: false, self_review: false } }; const done = (response: any, extra: any = {}) => ({ ok: true, status: 'done', phase: 'done', result: { ok: true, response, ...extra } }); -const replaceHeading = (text: string, color?: string) => ({ type: 'patch', message: 'ok', ops: [{ op: 'replace_node', node_id: 'sec', tree: { type: { resolvedName: 'Container' }, props: { tag: 'section', style: { backgroundColor: '#1e3a8a' } }, nodes: [{ type: { resolvedName: 'Heading' }, props: { text, level: 'h2', style: color ? { color } : {} }, nodes: [] }] } }] }); +const replaceHeading = (text: string, color?: string, target = 'sec') => ({ type: 'patch', message: 'ok', ops: [{ op: 'replace_node', node_id: target, tree: { type: { resolvedName: 'Container' }, props: { tag: 'section', node_id: 'ai-sec', style: { backgroundColor: '#1e3a8a' } }, nodes: [{ type: { resolvedName: 'Heading' }, props: { text, level: 'h2', style: color ? { color } : {} }, nodes: [] }] } }] }); async function mount(opts: { profile?: boolean } = {}) { h = renderEditorHarness({ @@ -98,6 +99,7 @@ beforeEach(() => { { filename: 'b.png', relUrl: 'assets/b.png', url: '/api/b', width: 100, height: 100, caption: null, alt: null }, ], refs: [{ id: 5, url_label: 'nice.example', what_i_like: 'Clean menu', screenshot_ids: [] }], + refImages: [], startFail: false, uploadFail: false, }; vi.mocked(captureHtml).mockReset(); @@ -223,7 +225,7 @@ describe('Sitesmith modal: self-review', () => { await sendText('Build my home page'); // Between the two jobs the poll answers the review job. await flush(1250); - net.polls = [done(replaceHeading('Reviewed fix'))]; + net.polls = [done(replaceHeading('Reviewed fix', undefined, 'ai-sec'))]; await flush(2600); expect(vi.mocked(captureHtml)).toHaveBeenCalledTimes(1); expect(net.uploads).toBe(1); @@ -235,7 +237,9 @@ describe('Sitesmith modal: self-review', () => { expect(net.starts[1].mockup_ids).toBeUndefined(); expect(net.starts[1].use_assets).toBe(false); expect(net.deleted).toEqual(['f'.repeat(32)]); - expect(q('[data-testid=ss-review-note]')!.textContent).toMatch(/looked at the finished page/); + expect(h.container.textContent).toContain('Reviewed fix'); + expect(q('[data-testid=ss-review-note]')!.dataset.status).toBe('fixed'); + expect(q('[data-testid=ss-review-note]')!.textContent).toMatch(/looked at a screenshot of the finished page and made fixes/); }, 30000); test('capture failure: non-blocking note, first result stays applied, no second job', async () => { @@ -268,3 +272,101 @@ describe('Sitesmith modal: self-review', () => { expect(net.starts).toHaveLength(1); }, 20000); }); + +// ---- A: a whole-page replace lands on the canvas in a deferred load; the review must see the NEW page +import { PageProvider } from '../../state/PageContext'; +const replacePage = (text: string) => ({ + type: 'replace', scope: 'page', message: 'Built it', + pages: [{ name: 'Home', tree: { type: { resolvedName: 'Container' }, props: { tag: 'div', style: {} }, nodes: [ + { type: { resolvedName: 'Container' }, props: { tag: 'section', node_id: 'ai-hero-1', style: { backgroundColor: '#ffffff' } }, nodes: [ + { type: { resolvedName: 'Heading' }, props: { text, level: 'h1', node_id: 'ai-h-1', style: { color: '#111827' } }, nodes: [] }, + ] }, + ] } }], +}); +async function mountWithPages() { + h = renderEditorHarness({ + initialState: INITIAL, + wrap: (tree) => ( + + {tree} + + ), + }); + h.mountChild( {}} target={null} />); + h.act(() => extras.set('sitesmithProfile', PROFILE)); + await flush(); + await flush(); +} + +describe('Sitesmith modal: self-review after a whole-page replace (A)', () => { + const okCapture = () => vi.mocked(captureHtml).mockResolvedValue({ ok: true, blob: new Blob([new Uint8Array(3000)], { type: 'image/jpeg' }), width: 1280, height: 900, cropped: false }); + + test('the render and the review request show the page that was just applied, not the previous one', async () => { + okCapture(); + net.polls = [done(replacePage('Brand new hero'))]; + await mountWithPages(); + await sendText('Build my home page'); + await flush(1250); + net.polls = [done({ type: 'patch', message: 'Looks right.', ops: [] })]; + await flush(2600); + expect(h.container.textContent).toContain('Brand new hero'); + expect(vi.mocked(captureHtml)).toHaveBeenCalledTimes(1); + const html = vi.mocked(captureHtml).mock.calls[0][0] as string; + expect(html).toContain('Brand new hero'); + expect(html).not.toContain('Original heading'); + expect(net.starts).toHaveLength(2); + expect(net.starts[1].canvas_summary).toContain('ai-hero-1'); + expect(net.starts[1].canvas_summary).not.toContain('Original heading'); + expect(q('[data-testid=ss-review-note]')!.dataset.status).toBe('fine'); + expect(q('[data-testid=ss-review-note]')!.textContent).toContain('Looks right.'); + }, 30000); + + test('B: when Sitesmith says the screenshot is not its page, the note says so (never "found nothing to fix")', async () => { + okCapture(); + net.polls = [done(replacePage('Brand new hero'))]; + await mountWithPages(); + await sendText('Build my home page'); + await flush(1250); + net.polls = [done({ type: 'patch', ops: [], message: "No changes made: the render (a 'MySite' nav, a hero with only a button) doesn't match the page I built." })]; + await flush(2600); + const note = q('[data-testid=ss-review-note]')!; + expect(note.dataset.status).toBe('mismatch'); + expect(note.textContent).toMatch(/did not match the page it built, so it changed nothing/); + expect(note.textContent).not.toMatch(/found nothing to fix/); + }, 30000); +}); + +describe('Sitesmith modal: picture selection is per message (E)', () => { + test('a picture saved in an earlier session is not sent; the one attached now is, once, and the next message carries none', async () => { + net.refImages = [{ id: 'o'.repeat(32), kind: 'mockup', name: 'mockup-ols-wireframe', width: 1200, height: 1500, size: 5000, created_at: '2026-10-30 10:00:00' }]; + vi.mocked(captureHtml).mockResolvedValue({ ok: false, reason: 'off for this test' }); + await mount(); + await h.act(async () => { secBtn('mockups').click(); }); + const input = q('[data-testid=ss-mockup-input]') as HTMLInputElement; + await h.act(async () => { + Object.defineProperty(input, 'files', { value: [PNG_OK()], configurable: true }); + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + await flush(50); + const attached = q('[data-testid=ss-attached-line]')!; + expect(attached.textContent).toMatch(/1 picture attached to this message/); + await sendText('Build my home page following the layout of the attached mockup.'); + await flush(1500); + expect(net.starts[0].mockup_ids).toEqual(['n'.repeat(32)]); + expect(q('[data-testid=ss-attached-line]')).toBeNull(); + net.polls = [done(replaceHeading('Second'))]; + await sendText('Now tweak it'); + await flush(1500); + expect(net.starts[net.starts.length - 1].mockup_ids).toBeUndefined(); + }, 30000); + + test('Clear chat says plainly that it clears the whole conversation for this site', async () => { + net.history = { ok: true, messages: [{ role: 'user', content: 'hi', response_type: null, created_at: '2026-11-01 10:00:00' }], active_job: null }; + await mount(); + const btn = q('[data-testid=ss-clear-chat]') as HTMLButtonElement; + expect(btn.title).toMatch(/whole Sitesmith conversation for this site/); + expect(btn.title).toMatch(/everyone who edits it/); + await h.act(async () => { btn.click(); }); + expect(document.body.textContent).toMatch(/Clear for everyone on this site\?/); + }, 20000); +}); diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx index 9e0d3ef..5cc6b57 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.tsx @@ -11,12 +11,13 @@ import { import { applyRegeneratedSection, isProfileComplete, profileSummary } from '../../utils/sitesmith-profile'; import { useEditorConfig } from '../../state/EditorConfigContext'; import { useSitesmith } from '../../hooks/useSitesmith'; -import { useApplyAiResponse, repairForCanvas } from '../../utils/apply-ai-response'; +import { useApplyAiResponse, repairForCanvas, type AppliedCanvas } from '../../utils/apply-ai-response'; +import { canvasFingerprint, canvasShows } from '../../utils/canvas-fingerprint'; import { summarizeAdjustments, type ContrastAdjustment } from '../../utils/contrast-repair'; import { buildDesignPayload } from '../../utils/sitesmith-design'; import { composeReviewHtml } from '../../utils/compose-review-html'; import { captureHtml, resolveAssetUrls } from '../../utils/self-review-capture'; -import { runSelfReview } from './selfReview'; +import { runSelfReview, type SelfReviewOutcome } from './selfReview'; import { failureMessage } from './statusMessages'; import { useSitesmithRefs } from './useSitesmithRefs'; import { Section, warnBox, errBox as errBoxStyle, note as noteStyle } from './Section'; @@ -61,7 +62,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { const [reviewing, setReviewing] = useState(false); const [notes, setNotes] = useState([]); const [adjust, setAdjust] = useState<{ list: ContrastAdjustment[]; notChecked: number } | null>(null); - const [reviewNote, setReviewNote] = useState(null); + const [reviewNote, setReviewNote] = useState(null); const abortRef = useRef(null); const [pendingReplace, setPendingReplace] = useState<{ resp: SitesmithResponse; review: boolean } | null>(null); const [open, setOpen] = useState>({}); @@ -183,18 +184,30 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { return urls; }; - const doSelfReview = async () => { + const doSelfReview = async (applied: AppliedCanvas | undefined) => { setReviewing(true); setPhase('review'); setReviewNote(null); const urls = assetUrlMap(); + // The page is composed from exactly the state that was checked against the applied result. + let snapshot: string | null = null; try { const out = await runSelfReview({ - composeHtml: () => composeReviewHtml({ - serialized: query.serialize(), - headerState: pageCtx.headerPage.craftState, - footerState: pageCtx.footerPage.craftState, - title: pageCtx.pages.find((p) => p.id === pageCtx.activePageId)?.name ?? cfg.whpConfig?.siteName ?? 'Page', - design, - }), + canvasMismatch: () => { + if (!applied || !applied.settled) return 'the canvas had not finished loading the new page, so a screenshot would have shown the previous one'; + snapshot = query.serialize(); + if (!applied.expected) return 'the page that was built could not be checked against the canvas'; + if (!canvasShows(applied.expected, canvasFingerprint(snapshot))) return 'the canvas did not show the page that was just built, so a screenshot would have been of the wrong page'; + return null; + }, + composeHtml: () => { + const chrome = pageCtx.latestChrome(); + return composeReviewHtml({ + serialized: snapshot ?? query.serialize(), + headerState: chrome.headerState, + footerState: chrome.footerState, + title: chrome.pageName ?? cfg.whpConfig?.siteName ?? 'Page', + design, + }); + }, assetUrls: urls, allowUrls: Object.values(urls), resolveAssetUrls, @@ -211,13 +224,13 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { applyResult: async (res) => { if (!res.ok) return { ok: false, message: res.message }; const a = await apply(res.response); - return { ok: a.ok, message: a.message, summary: a.adjustments?.length ? summarizeAdjustments(a.adjustments, a.notChecked) : undefined }; + return { ok: a.ok, message: a.message, changed: a.changed, ops: a.ops, summary: a.adjustments?.length ? summarizeAdjustments(a.adjustments, a.notChecked) : undefined }; }, removeImage: (id) => ss.deleteRefImage(id), }); - setReviewNote(out.note); + setReviewNote(out); } catch { - setReviewNote('Self-review skipped: something went wrong. Your page was built as normal.'); + setReviewNote({ status: 'skipped', note: 'Self-review skipped: something went wrong. Your page was built as normal.' }); } finally { setReviewing(false); } }; @@ -227,7 +240,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { if (!applied.ok) { setError(friendly(applied.message || 'Apply failed')); return; } if (applied.adjustments?.length || applied.notChecked) setAdjust({ list: applied.adjustments ?? [], notChecked: applied.notChecked ?? 0 }); if (review && selfReviewOn && visionOk && features?.self_review !== false && resp.type !== 'ask' && !target && !pageCtx.isEditingGlobal) { - await doSelfReview(); + await doSelfReview(applied.canvas); } }; @@ -371,10 +384,10 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { @@ -437,7 +450,14 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => {
)} - {reviewNote &&
{reviewNote}
} + {reviewNote && ( +
{reviewNote.note}
+ )} {loading ?
Loading…
: } @@ -503,6 +523,17 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { ) : ( <> + {imagesOn && selMockups.length > 0 && ( +
+ + + {selMockups.length === 1 ? '1 picture' : `${selMockups.length} pictures`} attached to this message:{' '} + {selMockups.map((id) => refsApi.images.find((im) => im.id === id)?.name ?? 'picture').join(', ')}. + {' '}They are sent with the next message only. + + +
+ )} {!canChat && summary &&
Sitesmith is not available on your plan right now, so the message box is turned off. See the note above for what to do.
} { expect(box.querySelector('[role=alert]')!.textContent).toContain('up to 1 image'); expect(second.checked).toBe(false); }); + test('E: pictures saved earlier are labelled as such and never preselected; only the one just uploaded is', async () => { + await render(); + setFiles(box.querySelector('[data-testid=ss-mockup-input]') as HTMLInputElement, [png()]); + await flush(); + const boxes = [...box.querySelectorAll('[data-testid=ss-mockup-item] input[type=checkbox]')] as HTMLInputElement[]; + expect(boxes.map((b) => b.checked)).toEqual([false, false, true]); + expect(boxes[0].getAttribute('aria-label')).toBe('Use a.png (saved earlier) for the next request'); + expect(boxes[2].getAttribute('aria-label')).toBe('Use new.png (just uploaded) for the next request'); + }); + test('E: dropping several files selects every one of them, not just the last', async () => { + let n = 0; + const up = vi.fn(async () => ({ ok: true as const, image: img(`m${++n}`) })); + const Multi: React.FC = () => { + const [sel, setSel] = useState([]); + return (<> `/img/${id}`} + onUpload={up} onRemove={async () => ({ ok: true as const })} unavailableReason={null} />{sel.join(',')}); + }; + await render(); + setFiles(box.querySelector('[data-testid=ss-mockup-input]') as HTMLInputElement, [png('1.png'), png('2.png')]); + await flush(); await flush(); + expect(box.querySelector('[data-testid=sel]')!.textContent).toBe('m1,m2'); + }); test('vision unavailable: clear note, picker and checkboxes disabled', async () => { await render(); expect(box.querySelector('[data-testid=ss-vision-note]')!.textContent).toContain('not available on this server'); diff --git a/craft/src/panels/sitesmith/selfReview.test.ts b/craft/src/panels/sitesmith/selfReview.test.ts index 28aa6fc..c8a344f 100644 --- a/craft/src/panels/sitesmith/selfReview.test.ts +++ b/craft/src/panels/sitesmith/selfReview.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi } from 'vitest'; -import { runSelfReview, REVIEW_MESSAGE, type SelfReviewDeps } from './selfReview'; +import { runSelfReview, saysRenderMismatch, REVIEW_MESSAGE, type SelfReviewDeps } from './selfReview'; const blob = () => new Blob([new Uint8Array(3000)], { type: 'image/jpeg' }); const deps = (o: Partial = {}): SelfReviewDeps => ({ @@ -18,7 +18,7 @@ describe('runSelfReview', () => { test('round trip: capture -> upload -> ONE review call -> apply -> render image deleted', async () => { const d = deps(); const out = await runSelfReview(d); - expect(out.status).toBe('applied'); + expect(out.status).toBe('fixed'); expect(d.capture).toHaveBeenCalledTimes(1); expect(d.upload).toHaveBeenCalledTimes(1); expect(d.review).toHaveBeenCalledTimes(1); @@ -53,12 +53,66 @@ describe('runSelfReview', () => { expect(out).toMatchObject({ status: 'skipped' }); expect(await runSelfReview(deps({ composeHtml: () => { throw new Error('x'); } }))).toMatchObject({ status: 'skipped' }); }); - test('"looks good" results (empty patch / ask) apply nothing', async () => { - const d = deps({ review: vi.fn(async () => ({ ok: true as const, response: { type: 'patch' as const, message: 'fine', ops: [] } })) }); - expect((await runSelfReview(d)).status).toBe('looks-good'); + test('an empty patch on the right page is "fine", and quotes what Sitesmith said', async () => { + const d = deps({ review: vi.fn(async () => ({ ok: true as const, response: { type: 'patch' as const, message: 'Spacing and contrast look right.', ops: [] } })) }); + const out = await runSelfReview(d); + expect(out.status).toBe('fine'); + expect(out.note).toMatch(/made no changes/); + expect(out.note).toContain('Spacing and contrast look right.'); + expect(out.note).not.toMatch(/found nothing to fix/); expect(d.applyResult).not.toHaveBeenCalled(); - const d2 = deps({ review: vi.fn(async () => ({ ok: true as const, response: { type: 'ask' as const, question: 'q' } })) }); - expect((await runSelfReview(d2)).status).toBe('looks-good'); + }); + test('an ask is not reported as a review that found nothing', async () => { + const d2 = deps({ review: vi.fn(async () => ({ ok: true as const, response: { type: 'ask' as const, question: 'Which page?' } })) }); + const out = await runSelfReview(d2); + expect(out.status).toBe('skipped'); + expect(out.note).toMatch(/asked a question instead of reviewing/); + expect(out.note).toContain('Which page?'); + expect(d2.applyResult).not.toHaveBeenCalled(); + }); + // The five no-op replies the live evaluation got (the render was of the previous page). + const EVAL_REPLIES = [ + "No changes made: the render (a 'MySite' nav, a hero with only a button, and two stacked contact forms) doesn't match the page I built, and the canvas ids (n2-n6) don't match my ai-* ids. I can't safely target nodes from this, so tell me which page to fix or ask me to rebuild the home page.", + "No changes made. The render shows an empty hero with only a button, no brands, approach or values sections, and two duplicate contact forms. That doesn't match the page I built (hero, brands, approach, values, one contact form), so it looks like an older or partial render. I can't safely patch it, so I left your canvas untouched. Re-run the review once the new page has rendered.", + "No fixes made. This render shows the old page (MySite nav, an empty hero with one button, two identical contact forms), not the OLS Digital page I built last turn, and its node ids (n2 to n5) don't match mine (ai-hero-1, ai-brands-1 and so on). I can't tell which of those nodes is which, so I left the canvas alone. If the old page is really still live, re-apply the rebuild or ask me to redo the home page.", + "No changes made: the render (a 'MySite' nav, a hero with only a button, two stacked default contact forms) doesn't match the Tabletop Misfits page I built, and the canvas ids (n1-n6) don't match my ai-* ids, so I can't safely target nodes. Tell me which page to fix, or ask me to rebuild the home page.", + "No changes made: the render (a 'MySite' nav, a hero showing only a 'Get Started' button, and two stacked default contact forms) doesn't match any page I built, and the canvas ids (n1-n6) don't match my ai-* ids, so I can't safely target nodes with update_props. Tell me which page to fix, or ask me to rebuild the home page.", + ]; + test.each(EVAL_REPLIES)('model says the render is not its page -> "mismatch", never "found nothing to fix" (%#)', async (message) => { + const out = await runSelfReview(deps({ review: vi.fn(async () => ({ ok: true as const, response: { type: 'patch' as const, message, ops: [] } })) })); + expect(out.status).toBe('mismatch'); + expect(out.note).toMatch(/did not match the page it built/); + expect(out.note).not.toMatch(/found nothing to fix|made no changes\./); + }); + test('mismatch detection stays narrow: ordinary "looks fine" replies are not mismatches', () => { + for (const m of [ + 'Looks right: the page matches the brief, so no changes.', + 'Everything reads well; the button colour does not match the accent but that is a style choice, so I left it.', + 'The hero image is well cropped and nothing needs fixing.', + '', + ]) expect(saysRenderMismatch(m)).toBe(false); + }); + test('ops that name nodes not on the page: "not-applied", not "made fixes"', async () => { + const d = deps({ applyResult: vi.fn(async () => ({ ok: true, changed: 0, ops: 1 })) }); + const out = await runSelfReview(d); + expect(out.status).toBe('not-applied'); + expect(out.note).toMatch(/none could be applied/); + const d2 = deps({ + review: vi.fn(async () => ({ ok: true as const, response: { type: 'patch' as const, message: 'm', ops: [{ op: 'delete_node' as const, node_id: 'a' }, { op: 'delete_node' as const, node_id: 'b' }] } })), + applyResult: vi.fn(async () => ({ ok: true, changed: 1, ops: 2 })), + }); + const o2 = await runSelfReview(d2); + expect(o2.status).toBe('fixed'); + expect(o2.note).toMatch(/1 of 2 suggested fixes were applied/); + }); + test('canvas not showing the applied result: skipped with the reason, nothing captured or sent', async () => { + const d = deps({ canvasMismatch: () => 'the canvas had not finished loading the new page' }); + const out = await runSelfReview(d); + expect(out.status).toBe('skipped'); + expect(out.note).toContain('the canvas had not finished loading the new page'); + expect(d.capture).not.toHaveBeenCalled(); + expect(d.upload).not.toHaveBeenCalled(); + expect(d.review).not.toHaveBeenCalled(); }); test('assets are resolved before capture', async () => { const resolve = vi.fn((h: string) => h.replace('assets/a.png', '/api/a')); diff --git a/craft/src/panels/sitesmith/selfReview.ts b/craft/src/panels/sitesmith/selfReview.ts index ef4a6ab..cbf2023 100644 --- a/craft/src/panels/sitesmith/selfReview.ts +++ b/craft/src/panels/sitesmith/selfReview.ts @@ -12,6 +12,11 @@ export const REVIEW_MESSAGE = 'If it already looks right, reply with a patch that has no ops and say so. Do not redesign the page or change the wording.'; export interface SelfReviewDeps { + /** + * Null when the canvas shows the result that was just applied; otherwise why it does not. Checked + * immediately before the page is composed, in the same tick, so the screenshot is of that canvas. + */ + canvasMismatch?: () => string | null; /** Published-style HTML of the current page (untrusted; the capture sanitises it). */ composeHtml: () => string; /** `assets/` -> same-origin URL, for images in the capture. */ @@ -21,24 +26,55 @@ export interface SelfReviewDeps { upload: (file: File) => Promise<{ ok: true; id: string } | { ok: false; message: string }>; /** Start the follow-up job and wait for it. */ review: (message: string, imageId: string) => Promise; - /** Apply the review result (contrast repair included). */ - applyResult: (res: SendResult) => Promise<{ ok: boolean; message?: string; summary?: string }>; + /** Apply the review result (contrast repair included). `changed`/`ops`: patch ops that took effect / were sent. */ + applyResult: (res: SendResult) => Promise<{ ok: boolean; message?: string; summary?: string; changed?: number; ops?: number }>; removeImage: (id: string) => Promise; resolveAssetUrls: (html: string, urls: Record) => string; } -export type SelfReviewOutcome = - | { status: 'applied'; note: string } - | { status: 'looks-good'; note: string } - | { status: 'skipped'; note: string }; +/** + * - `fixed`: the review ran and at least one change was applied. + * - `fine`: the review ran on the right page and Sitesmith changed nothing. + * - `mismatch`: Sitesmith said the screenshot was not the page it built, so it changed nothing. + * - `not-applied`: Sitesmith proposed changes but none of them could be applied. + * - `skipped`: the review did not run, or failed (the note says why). + */ +export type SelfReviewStatus = 'fixed' | 'fine' | 'mismatch' | 'not-applied' | 'skipped'; +export interface SelfReviewOutcome { status: SelfReviewStatus; note: string } const skip = (why: string): SelfReviewOutcome => ({ status: 'skipped', note: `Self-review skipped: ${why}. Your page was built as normal.` }); +/** + * The model's own words that it was shown something other than what it built. A no-op patch is the + * model's only way to say so, and the evaluation showed it does, in prose; the backend has no + * structured flag for it (yet), so read the message. Deliberately narrow: "matches" alone is not enough. + */ +const NEG = String.raw`(?:does\s*n[o']?t|did\s*n[o']?t|do\s*n[o']?t|is\s*n[o']?t|isn't|doesn't|didn't|don't|not)`; +const MISMATCH_RES: RegExp[] = [ + // "the render ... doesn't match the page I built" + new RegExp(String.raw`\b(?:render|screenshot|capture)\b[^.]{0,120}?\b${NEG}\s+(?:seem\s+to\s+|appear\s+to\s+)?match\b`, 'i'), + // "... ids (n1-n6) don't match my ai-* ids" + new RegExp(String.raw`\bids?\b[^.]{0,60}?\b${NEG}\s+match\b`, 'i'), + // "shows the old page", "an older or partial render" + /\b(?:old|older|previous|earlier|wrong|stale|partial|different)\s+(?:or\s+\w+\s+)?(?:page|render|version|screenshot|canvas)\b/i, +]; +export function saysRenderMismatch(message: string | undefined | null): boolean { + return !!message && MISMATCH_RES.some((re) => re.test(message)); +} + +const quote = (m: string | undefined | null): string => { + const t = (m ?? '').trim().replace(/\s+/g, ' '); + if (!t) return ''; + return ` Sitesmith said: "${t.length > 280 ? t.slice(0, 277) + '...' : t}"`; +}; + /** One review round, never more. Never throws; any failure is a non-blocking note. */ export async function runSelfReview(d: SelfReviewDeps): Promise { let imageId: string | null = null; try { let html: string; + const off = d.canvasMismatch?.() ?? null; + if (off) return skip(off); try { html = d.resolveAssetUrls(d.composeHtml(), d.assetUrls); } catch { return skip('the page could not be rendered'); } const cap = await d.capture(html, { allowUrls: d.allowUrls, timeoutMs: 20000 }); if (!cap.ok) return skip(cap.reason); @@ -48,12 +84,26 @@ export async function runSelfReview(d: SelfReviewDeps): Promise string | null; + /** + * Resolves once every canvas load requested so far (page switch, AI page/site + * replace, header/footer refresh) has actually run its deferred deserialize: + * true when the requested state is what the canvas now holds, false when it + * fell back (bad state) instead. Lets a caller act on the canvas it just asked + * for -- e.g. Sitesmith's self-review screenshot -- instead of the previous one. + */ + whenCanvasSettled: () => Promise; + /** + * The header/footer states and active page name as of the latest change, + * including changes made in this same tick (React state lags a render behind). + */ + latestChrome: () => { headerState: string | null; footerState: string | null; pageName: string | null }; /** * Every serialized craft state the site holds right now: all pages * (the active one as the LIVE canvas), header, footer and global-section @@ -290,6 +303,8 @@ const PageContext = createContext({ finishGlobal: () => NO_COMMIT, globalZoneSaveView: () => null, pendingCanvasState: () => null, + whenCanvasSettled: () => Promise.resolve(true), + latestChrome: () => ({ headerState: null, footerState: null, pageName: null }), siteStateStrings: () => [], rewriteSiteStates: () => 0, switchPage: () => {}, @@ -516,6 +531,14 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => }, [extras]); const pendingCanvasState = useCallback((): string | null => pendingLoadRef.current.state, []); + /** The latest loadState's completion (see whenCanvasSettled). */ + const settledRef = useRef>(Promise.resolve(true)); + const whenCanvasSettled = useCallback((): Promise => settledRef.current, []); + const latestChrome = useCallback(() => ({ + headerState: headerPageRef.current.craftState, + footerState: footerPageRef.current.craftState, + pageName: pagesRef.current.find((p) => p.id === activePageIdRef.current)?.name ?? null, + }), []); const siteStateStrings = useCallback((): string[] => { const cur = activePageIdRef.current; @@ -580,6 +603,11 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => const clearHistory = !opts?.keepHistory; const seq = ++pendingLoadRef.current.seq; pendingLoadRef.current.state = opts?.applyAfter ?? (craftState || fallback); + let settle: (ok: boolean) => void = () => {}; + const prev = settledRef.current; + const mine = new Promise((r) => { settle = r; }); + // Chained, so a waiter always sees the LAST requested load, never an earlier one. + settledRef.current = Promise.all([prev, mine]).then(([, ok]) => ok); setTimeout(() => { if (pendingLoadRef.current.seq === seq) pendingLoadRef.current.state = null; const source = craftState || fallback; @@ -601,6 +629,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => // step on top of the freshly loaded (pre-sync) state. if (opts?.applyAfter) actions.deserialize(repairOrphanNodes(opts.applyAfter).state); opts?.onLoaded?.(); + settle(true); } catch (e) { console.error('Failed to deserialize state:', e); try { @@ -614,6 +643,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => } catch (_e2) { // give up } + settle(false); } }, 0); }, @@ -1018,6 +1048,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => craftState: treeToCraftState(p.tree), }; }); + pagesRef.current = built; setPages(built); // Load the first page into the live canvas const firstState = built[0].craftState; @@ -1039,10 +1070,13 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => if (isGlobalZoneId(currentId)) return false; const craftState = treeToCraftState(page.tree); if (currentId === HEADER_ID) { + headerPageRef.current = { ...headerPageRef.current, name: page.name, craftState }; setHeaderPage((prev) => ({ ...prev, name: page.name, craftState })); } else if (currentId === FOOTER_ID) { + footerPageRef.current = { ...footerPageRef.current, name: page.name, craftState }; setFooterPage((prev) => ({ ...prev, name: page.name, craftState })); } else { + pagesRef.current = pagesRef.current.map((p) => (p.id === currentId ? { ...p, name: page.name, craftState } : p)); setPages((prev) => prev.map((p) => (p.id === currentId ? { ...p, name: page.name, craftState } : p)), ); @@ -1057,6 +1091,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => */ const setHeader = useCallback((tree: SerializedTreeNode) => { const craftState = treeToCraftState(tree); + headerPageRef.current = { ...headerPageRef.current, craftState }; setHeaderPage((prev) => ({ ...prev, craftState })); // If the canvas is currently showing the header, refresh it live if (activePageIdRef.current === HEADER_ID) { @@ -1070,6 +1105,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => */ const setFooter = useCallback((tree: SerializedTreeNode) => { const craftState = treeToCraftState(tree); + footerPageRef.current = { ...footerPageRef.current, craftState }; setFooterPage((prev) => ({ ...prev, craftState })); // If the canvas is currently showing the footer, refresh it live if (activePageIdRef.current === FOOTER_ID) { @@ -1132,6 +1168,8 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => finishGlobal, globalZoneSaveView, pendingCanvasState, + whenCanvasSettled, + latestChrome, siteStateStrings, rewriteSiteStates, switchPage, diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 335f9f7..d8c8513 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -1,6 +1,7 @@ import { useEditor } from '@craftjs/core'; import { insertTargetOutsideInstance, isInsideInstance } from './global-sections'; -import { usePages } from '../state/PageContext'; +import { usePages, treeToCraftState } from '../state/PageContext'; +import { canvasFingerprint, type CanvasFingerprint } from './canvas-fingerprint'; import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith'; import { sanitizeAiTree, buildNodeTree } from './craft-tree'; import { cleanAiResponse } from '../sitesmith/clean-ai-response'; @@ -59,7 +60,31 @@ export function canvasNodeContext(query: any): PatchContext['nodeContext'] { }; } -export interface ApplyOutcome { ok: boolean; message?: string; adjustments?: ContrastAdjustment[]; notChecked?: number } +/** + * What the canvas should now show. `settled` is false when a whole-page/site load did not finish + * (or fell back to an empty page) within the bound; `expected` is the fingerprint of the applied + * result -- computed from the response tree itself for a page/site replace, from the live canvas + * right after the (synchronous) edits otherwise. + */ +export interface AppliedCanvas { settled: boolean; expected: CanvasFingerprint | null } + +export interface ApplyOutcome { + ok: boolean; message?: string; adjustments?: ContrastAdjustment[]; notChecked?: number; + canvas?: AppliedCanvas; + /** Patch only: how many ops actually changed the canvas (an op naming a missing node does nothing). */ + changed?: number; + /** Patch only: how many ops the response carried. */ + ops?: number; +} + +/** A canvas load is a setTimeout(0) away; 5 s means something is wrong, not slow. */ +const SETTLE_BOUND_MS = 5000; +function settleWithin(p: Promise, ms: number): Promise { + return new Promise((resolve) => { + const t = setTimeout(() => resolve(false), ms); + p.then((ok) => { clearTimeout(t); resolve(ok); }, () => { clearTimeout(t); resolve(false); }); + }); +} /** Repair text/background contrast in a response against the current design (pure; no model call). */ export function repairForCanvas(resp: SitesmithResponse, design: SiteDesign, query: any) { @@ -110,7 +135,18 @@ export function useApplyAiResponse() { const rep = repairForCanvas(resp, design, query); resp = rep.response; adjustments = rep.adjustments; notChecked = rep.notChecked; } - const done = (r: { ok: boolean; message?: string }): ApplyOutcome => (r.ok ? { ...r, adjustments, notChecked } : r); + const done = (r: { ok: boolean; message?: string; changed?: number; ops?: number }, canvas?: AppliedCanvas): ApplyOutcome => + (r.ok ? { ...r, adjustments, notChecked, canvas: canvas ?? liveCanvas() } : r); + const liveCanvas = (): AppliedCanvas => { + try { return { settled: true, expected: canvasFingerprint(query.serialize()) }; } catch { return { settled: true, expected: null }; } + }; + /** Wait for the deferred page load, then report what the canvas must show: the applied tree. */ + const loadedCanvas = async (tree: SerializedTreeNode): Promise => { + const settled = await settleWithin(pages.whenCanvasSettled(), SETTLE_BOUND_MS); + let expected: CanvasFingerprint | null = null; + try { expected = canvasFingerprint(treeToCraftState(tree)); } catch { /* null: unverifiable */ } + return { settled, expected }; + }; if (resp.type === 'replace') { if (resp.scope === 'site') { @@ -124,7 +160,7 @@ export function useApplyAiResponse() { } if (resp.header) pages.setHeader(resp.header.tree); if (resp.footer) pages.setFooter(resp.footer.tree); - return done({ ok: true }); + return done({ ok: true }, await loadedCanvas(resp.pages[0].tree)); } if (resp.scope === 'page') { @@ -134,7 +170,7 @@ export function useApplyAiResponse() { message: 'You are editing a global section. Click Done, then ask again to replace the page.', }; } - return done({ ok: true }); + return done({ ok: true }, await loadedCanvas(resp.pages[0].tree)); } if (resp.scope === 'section') { @@ -184,7 +220,8 @@ function applyPatch( actions: any, query: any, ops: SitesmithPatchOp[], -): { ok: boolean; message?: string } { +): { ok: boolean; message?: string; changed: number; ops: number } { + let changed = 0; for (const op of ops) { const id = findNodeIdByAiNodeId(query, op.node_id); if (!id) { @@ -199,6 +236,7 @@ function applyPatch( console.warn('sitesmith patch: update_props with invalid props payload, skipping', op.node_id); break; } + changed++; actions.setProp(id, (p: any) => { for (const [key, value] of Object.entries(patchProps)) { if (PROTECTED_UPDATE_PROPS_KEYS.has(key)) { @@ -239,6 +277,7 @@ function applyPatch( const index = siblings.indexOf(id); actions.delete(id); actions.addNodeTree(nodeTree, parent, index); + changed++; } catch (e) { console.warn('sitesmith patch: replace_node failed', e); } @@ -257,6 +296,7 @@ function applyPatch( const nodeTree = buildNodeTree(query, op.tree); if (at.index !== undefined) actions.addNodeTree(nodeTree, at.parentId, at.index); else actions.addNodeTree(nodeTree, at.parentId); + changed++; } catch (e) { console.warn(`sitesmith patch: ${op.op} failed`, e); } @@ -266,6 +306,7 @@ function applyPatch( case 'delete_node': try { actions.delete(id); + changed++; } catch (e) { console.warn('sitesmith patch: delete_node failed', e); } @@ -280,5 +321,5 @@ function applyPatch( console.warn('sitesmith patch: unknown op', op); } } - return { ok: true }; + return { ok: true, changed, ops: Array.isArray(ops) ? ops.length : 0 }; } diff --git a/craft/src/utils/canvas-fingerprint.test.ts b/craft/src/utils/canvas-fingerprint.test.ts new file mode 100644 index 0000000..aa2d849 --- /dev/null +++ b/craft/src/utils/canvas-fingerprint.test.ts @@ -0,0 +1,45 @@ +import { describe, test, expect } from 'vitest'; +import { canvasFingerprint, sameCanvas, canvasShows } from './canvas-fingerprint'; +import { treeToCraftState } from '../state/PageContext'; + +const tree = (text: string, id = 'ai-h') => ({ + type: { resolvedName: 'Container' }, props: { tag: 'div' }, nodes: [ + { type: { resolvedName: 'Heading' }, props: { text, node_id: id }, nodes: [] }, + ], +}) as any; + +describe('canvasFingerprint', () => { + test('the same tree loaded twice (fresh Craft ids each time) fingerprints the same', () => { + const a = canvasFingerprint(treeToCraftState(tree('x'))); + const b = canvasFingerprint(treeToCraftState(tree('x'))); + expect(a!.nodes).toBe(2); + expect(sameCanvas(a, b)).toBe(true); + }); + test('a different page does not', () => { + const a = canvasFingerprint(treeToCraftState(tree('x', 'ai-1'))); + const b = canvasFingerprint(treeToCraftState(tree('x', 'ai-2'))); + expect(sameCanvas(a, b)).toBe(false); + const c = canvasFingerprint(JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, nodes: [], linkedNodes: {} } })); + expect(sameCanvas(a, c)).toBe(false); + }); + test('garbage is null and never "same"', () => { + expect(canvasFingerprint('not json')).toBeNull(); + expect(canvasFingerprint('{}')).toBeNull(); + expect(sameCanvas(null, null)).toBe(false); + }); +}); + +describe('canvasShows', () => { + const fp = (parts: string[]) => ({ nodes: parts.length, signature: parts.join('>'), parts }); + test('identical, or with extra empty Containers Craft minted for unfilled slots: shows it', () => { + expect(canvasShows(fp(['Container', 'ColumnLayout#c', 'Heading#h']), fp(['Container', 'ColumnLayout#c', 'Heading#h']))).toBe(true); + expect(canvasShows(fp(['Container', 'ColumnLayout#c', 'Heading#h']), fp(['Container', 'ColumnLayout#c', 'Heading#h', 'Container']))).toBe(true); + }); + test('the previous page, a missing node or a foreign extra node: does not', () => { + const applied = fp(['Container', 'Container#ai-hero-1', 'Heading#ai-h-1']); + expect(canvasShows(applied, fp(['Container', 'HeroSimple', 'ContactForm', 'ContactForm']))).toBe(false); + expect(canvasShows(applied, fp(['Container', 'Container#ai-hero-1']))).toBe(false); + expect(canvasShows(applied, fp(['Container', 'Container#ai-hero-1', 'Heading#ai-h-1', 'TextBlock']))).toBe(false); + expect(canvasShows(null, applied)).toBe(false); + }); +}); diff --git a/craft/src/utils/canvas-fingerprint.ts b/craft/src/utils/canvas-fingerprint.ts new file mode 100644 index 0000000..460e39e --- /dev/null +++ b/craft/src/utils/canvas-fingerprint.ts @@ -0,0 +1,55 @@ +/** + * A cheap structural fingerprint of a serialized Craft.js state: the component type and the + * stable AI `node_id` of every node reachable from ROOT, in document order (children, then + * linked nodes). Craft's own node ids are ignored -- they are regenerated on every load -- so the + * same page loaded twice fingerprints the same, while a different page does not. + * + * Used by Sitesmith's self-review to prove the screenshot it is about to take shows the page that + * was just applied, not the previous canvas (a deferred load that had not run yet). + */ +export interface CanvasFingerprint { nodes: number; signature: string; parts: string[] } + +type Nodes = Record; nodes?: string[]; linkedNodes?: Record }>; + +export function canvasFingerprint(state: string | Nodes | null | undefined): CanvasFingerprint | null { + let nodes: Nodes; + try { + nodes = typeof state === 'string' ? JSON.parse(state) : (state as Nodes); + } catch { return null; } + if (!nodes || typeof nodes !== 'object' || !nodes.ROOT) return null; + const parts: string[] = []; + const seen = new Set(); + const walk = (id: string) => { + if (seen.has(id)) return; + const n = nodes[id]; + if (!n) return; + seen.add(id); + const t = typeof n.type === 'string' ? n.type : n.type?.resolvedName ?? '?'; + const aiId = typeof n.props?.node_id === 'string' ? n.props.node_id : ''; + parts.push(aiId ? `${t}#${aiId}` : t); + for (const c of n.nodes ?? []) walk(c); + for (const k of Object.keys(n.linkedNodes ?? {}).sort()) walk(n.linkedNodes![k]); + }; + walk('ROOT'); + return { nodes: parts.length, signature: parts.join('>'), parts }; +} + +export function sameCanvas(a: CanvasFingerprint | null, b: CanvasFingerprint | null): boolean { + return !!a && !!b && a.nodes === b.nodes && a.signature === b.signature; +} + +/** + * Does the live canvas show what was applied? Every node of `applied` must appear in `live` in the + * same order. `live` may carry extra plain Containers and nothing else: Craft itself mints an empty + * Container for a column or inner slot a tree did not fill (e.g. a 3-column layout given 2 + * columns). Any other difference -- a missing node, a different type or AI id -- is a mismatch. + */ +export function canvasShows(applied: CanvasFingerprint | null, live: CanvasFingerprint | null): boolean { + if (!applied || !live) return false; + let i = 0; + for (const part of live.parts) { + if (i < applied.parts.length && part === applied.parts[i]) { i++; continue; } + if (part !== 'Container') return false; + } + return i === applied.parts.length; +}