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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 15:31:34 -08:00
1 parent 99d5855f6a
commit 2b82424d7f
10 files changed
+501 -50

No files matched your search

+16 -3
View File
@@ -29,6 +29,8 @@ export const AttachmentTray: React.FC<Props> = ({
const [drag, setDrag] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
/** Pictures uploaded through this tray now; every other one was saved in an earlier session. */
const [fresh, setFresh] = useState<Set<string>>(() => 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<Props> = ({
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<Props> = ({
<ul style={{ listStyle: 'none', margin: '10px 0 0', padding: 0, display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{mockups.map((im) => {
const on = selected.includes(im.id);
const when = fresh.has(im.id) ? 'just uploaded' : 'saved earlier';
return (
<li key={im.id} style={{ width: 96 }} data-testid="ss-mockup-item">
<label style={{ display: 'block', cursor: 'pointer' }}>
<img src={imageUrl(im.id)} alt={`Mockup ${im.name}`} style={{ ...thumb, width: 96, height: 72, outline: on ? '2px solid #a78bfa' : 'none' }} />
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#d4d4d8', marginTop: 3 }}>
<input type="checkbox" checked={on} disabled={off} onChange={() => toggle(im.id)} aria-label={`Use ${im.name} for the next request`} />
<input type="checkbox" checked={on} disabled={off} onChange={() => toggle(im.id)} aria-label={`Use ${im.name} (${when}) for the next request`} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{im.name}</span>
</span>
</label>
<div style={{ fontSize: 10, color: '#71717a' }}>{im.width && im.height ? `${im.width}×${im.height} · ` : ''}{formatBytes(im.size)}</div>
<div style={{ fontSize: 10, color: fresh.has(im.id) ? '#a78bfa' : '#71717a' }} data-testid="ss-mockup-when">
{fresh.has(im.id) ? 'Just uploaded' : `Saved earlier${im.created_at ? ` (${im.created_at.slice(0, 10)})` : ''}`}
</div>
<button type="button" style={{ ...btnDanger, marginTop: 3, padding: '2px 6px', fontSize: 11 }} disabled={off} onClick={() => void remove(im.id)} aria-label={`Remove ${im.name}`}>Remove</button>
</li>
);
@@ -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) => (
<EditorConfigProvider config={CFG as any}>
<SaveGuardProvider><NoticeProvider><ProjectExtrasProvider><SiteDesignProvider><SitesmithProvider>{tree}</SitesmithProvider></SiteDesignProvider></ProjectExtrasProvider></NoticeProvider></SaveGuardProvider>
</EditorConfigProvider>
),
});
h.mountChild(<PageProvider><Spy /><ProfileWizardHost /><SitesmithModal onClose={() => {}} target={null} /></PageProvider>);
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);
});
+50 -19
View File
@@ -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<Props> = ({ onClose, target }) => {
const [reviewing, setReviewing] = useState(false);
const [notes, setNotes] = useState<string[]>([]);
const [adjust, setAdjust] = useState<{ list: ContrastAdjustment[]; notChecked: number } | null>(null);
const [reviewNote, setReviewNote] = useState<string | null>(null);
const [reviewNote, setReviewNote] = useState<SelfReviewOutcome | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [pendingReplace, setPendingReplace] = useState<{ resp: SitesmithResponse; review: boolean } | null>(null);
const [open, setOpen] = useState<Record<string, boolean>>({});
@@ -183,18 +184,30 @@ export const SitesmithModal: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ onClose, target }) => {
<button
onClick={handleClearChatClick}
style={{ ...clearBtn, background: '#b91c1c', color: '#fff', borderColor: '#b91c1c' }}
title="Click again to permanently clear chat history"
title="Click again to delete the whole Sitesmith conversation for this site, including messages from anyone else who edits it"
autoFocus
>
Confirm clear?
Clear for everyone on this site?
</button>
<button onClick={cancelClearChat} style={clearBtn} title="Cancel">
Cancel
@@ -386,7 +399,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
style={{ ...clearBtn, ...(working ? { opacity: 0.5, cursor: 'not-allowed' } : {}) }}
disabled={working}
data-testid="ss-clear-chat"
title={working ? 'Available when Sitesmith has finished' : 'Clear all Sitesmith chat history for this site (canvas is unaffected)'}
title={working ? 'Available when Sitesmith has finished' : 'Delete the whole Sitesmith conversation for this site, for everyone who edits it. Your pages are not changed.'}
>
Clear chat
</button>
@@ -437,7 +450,14 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
</ul>
</details>
)}
{reviewNote && <div role="note" style={{ ...noteStyle, marginBottom: 8 }} data-testid="ss-review-note">{reviewNote}</div>}
{reviewNote && (
<div
role="note"
style={{ ...(reviewNote.status === 'mismatch' || reviewNote.status === 'not-applied' ? warnBox : noteStyle), marginBottom: 8 }}
data-testid="ss-review-note"
data-status={reviewNote.status}
>{reviewNote.note}</div>
)}
{loading
? <div style={{ color: '#71717a', textAlign: 'center', padding: 30 }}>Loading…</div>
: <MessageList messages={messages} />}
@@ -503,6 +523,17 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
<JobStatus phase={phase} startedAgoS={resumed ? activeJob?.elapsed_s : 0} resumed={resumed} cancelling={cancelling} onCancel={reviewing ? undefined : handleCancel} />
) : (
<>
{imagesOn && selMockups.length > 0 && (
<div style={{ ...noteStyle, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }} data-testid="ss-attached-line">
<i className="fa fa-paperclip" aria-hidden />
<span>
{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.
</span>
<button type="button" style={clearBtn} onClick={() => setSelMockups([])} data-testid="ss-attached-clear">Detach</button>
</div>
)}
{!canChat && summary && <div style={{ ...warnBox, marginBottom: 8 }} data-testid="ss-disabled-note">Sitesmith is not available on your plan right now, so the message box is turned off. See the note above for what to do.</div>}
<ChatInput
disabled={!canChat}
@@ -72,6 +72,28 @@ describe('AttachmentTray', () => {
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(<Harness />);
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<string[]>([]);
return (<><AttachmentTray images={[img('m1'), img('m2')]} selected={sel} onSelectedChange={setSel} maxAttach={4} referenceImageCount={0} imageUrl={(id) => `/img/${id}`}
onUpload={up} onRemove={async () => ({ ok: true as const })} unavailableReason={null} /><output data-testid="sel">{sel.join(',')}</output></>);
};
await render(<Multi />);
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(<Harness reason="Image features are not available on this server." />);
expect(box.querySelector('[data-testid=ss-vision-note]')!.textContent).toContain('not available on this server');
+61 -7
View File
@@ -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> = {}): 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'));
+59 -9
View File
@@ -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/<file>` -> 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<SendResult>;
/** 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<unknown>;
resolveAssetUrls: (html: string, urls: Record<string, string>) => 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<SelfReviewOutcome> {
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<SelfReviewOutcom
imageId = up.id;
const res = await d.review(REVIEW_MESSAGE, imageId);
if (!res.ok) return skip(res.message || 'the review request failed');
if (res.response.type === 'ask') return { status: 'looks-good', note: 'Self-review: Sitesmith had no changes to make.' };
if (res.response.type === 'patch' && res.response.ops.length === 0) return { status: 'looks-good', note: 'Self-review: Sitesmith looked at the finished page and found nothing to fix.' };
const r = res.response;
if (r.type === 'ask') {
return { status: 'skipped', note: `Self-review: Sitesmith asked a question instead of reviewing the page, so nothing was changed.${quote(r.question)}` };
}
const msg = 'message' in r ? r.message : '';
if (r.type === 'patch' && r.ops.length === 0) {
if (saysRenderMismatch(msg)) {
return { status: 'mismatch', note: `Self-review did not work this time: Sitesmith said the screenshot did not match the page it built, so it changed nothing. Your page is as built; check it yourself.${quote(msg)}` };
}
return { status: 'fine', note: `Self-review: Sitesmith looked at a screenshot of the finished page and made no changes.${quote(msg)}` };
}
const applied = await d.applyResult(res);
if (!applied.ok) return skip(applied.message || 'the fixes could not be applied');
if (r.type === 'patch' && applied.changed === 0) {
return { status: 'not-applied', note: `Self-review: Sitesmith suggested ${r.ops.length === 1 ? 'a fix' : `${r.ops.length} fixes`}, but none could be applied (the parts it named are not on the page), so nothing changed.${quote(msg)}` };
}
const extra = applied.summary ? ` ${applied.summary}.` : '';
return { status: 'applied', note: `Self-review: Sitesmith looked at the finished page and made fixes.${extra} Use Undo to go back.` };
const partial = r.type === 'patch' && applied.changed !== undefined && applied.changed < r.ops.length
? ` ${applied.changed} of ${r.ops.length} suggested fixes were applied; the rest named parts that are not on the page.` : '';
return { status: 'fixed', note: `Self-review: Sitesmith looked at a screenshot of the finished page and made fixes.${partial}${extra} Use Undo to go back.${quote(msg)}` };
} catch (e) {
return skip(e instanceof Error && e.message ? e.message.slice(0, 100) : 'something went wrong');
} finally {
+38
View File
@@ -57,6 +57,19 @@ interface PageContextValue {
* in the new page's slot.
*/
pendingCanvasState: () => 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<boolean>;
/**
* 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<PageContextValue>({
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<boolean>>(Promise.resolve(true));
const whenCanvasSettled = useCallback((): Promise<boolean> => 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<boolean>((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,
+48 -7
View File
@@ -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<boolean>, ms: number): Promise<boolean> {
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<AppliedCanvas> => {
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 };
}
@@ -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);
});
});
+55
View File
@@ -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<string, { type?: { resolvedName?: string } | string; props?: Record<string, unknown>; nodes?: string[]; linkedNodes?: Record<string, string> }>;
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<string>();
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;
}