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:
1 parent
99d5855f6a
commit
2b82424d7f
10 files changed
+501
-50
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user