fix(sitesmith): a partial whole-site build never offers a bare destructive "Replace site"

Live case 2 (2026.11.8): the daily limit stopped a site build after page 1, the dialog still
said "Replace your entire site?" and accepting deleted the owner's other page. The server now
reports site_build {planned, built, missing}; when pages are missing the confirm becomes
"Sitesmith built only N of M pages" with:
- "Add these pages" (default focus): each built page replaces the page at the same address,
  new ones are appended, every other page is kept (PageContext mergeAiPages / addAiPages), and
  the new header is repaired to link the kept pages too;
- "Replace site and delete K pages" with the names it deletes (only when K > 0);
- Cancel.
A complete site build keeps "Replace your entire site?" and now names the pages it deletes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 18:29:56 -08:00
1 parent 37935abad8
commit 2dcf8b7feb
10 files changed
+397 -43

No files matched your search

+7 -1
View File
@@ -153,7 +153,13 @@ const failure = (status: SitesmithStatus, message: string, extra?: { jobId?: str
/** A job/send result from the server (untrusted JSON) as a typed SendResult. */
function asSendResult(res: any): SendResult {
if (res && res.ok === true && res.response && typeof res.response === 'object' && typeof res.response.type === 'string') {
return Array.isArray(res.notes) ? { ok: true, response: res.response, notes: res.notes.filter((n: unknown) => typeof n === 'string') } : { ok: true, response: res.response };
const out: SendResult = Array.isArray(res.notes) ? { ok: true, response: res.response, notes: res.notes.filter((n: unknown) => typeof n === 'string') } : { ok: true, response: res.response };
const sb = res.site_build;
const names = (v: unknown): string[] | null => (Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : null);
if (sb && typeof sb === 'object' && names(sb.planned) && names(sb.built) && names(sb.missing)) {
out.siteBuild = { planned: names(sb.planned)!, built: names(sb.built)!, missing: names(sb.missing)! };
}
return out;
}
return failure(toSitesmithStatus(res?.status, 'ERROR'), sensibleMessage(res) ?? 'Sitesmith could not complete that request. Please try again.');
}
@@ -27,3 +27,42 @@ describe('ScopeConfirmDialog chrome mode', () => {
expect(text()).toContain("Sitesmith's header left out 1 of your pages (About); a link to it was added.");
});
});
// ---- round 4 item 1: dialog states for whole-site answers
describe('ScopeConfirmDialog site modes', () => {
const btns = () => Array.from(box.querySelectorAll('button')).map((b) => b.textContent);
const btn = (t: string) => Array.from(box.querySelectorAll('button')).find((b) => b.textContent === t) as HTMLButtonElement;
const partial = { planned: 4, built: ['Home'], missing: ['About', 'Brands', 'Contact'], replaced: ['Home'], added: [], kept: ['MG TEST', 'Team'] };
test('partial build: says how many were built, names the missing ones, offers Add (default) vs Replace with the delete count', async () => {
const onAdd = vi.fn(); const onConfirm = vi.fn(); const onCancel = vi.fn();
await render(<ScopeConfirmDialog open mode="site-partial" impact={partial} onAdd={onAdd} onConfirm={onConfirm} onCancel={onCancel} />);
expect(text()).toContain('Sitesmith built only 1 of 4 pages');
expect(text()).toContain('These pages were not built: About, Brands, Contact.');
expect(text()).not.toContain('Replace your entire site?');
expect(btns()).toEqual(['Cancel', 'Replace site and delete 2 pages', 'Add these pages']);
expect(text()).toContain('Replace site deletes 2 of your pages (MG TEST, Team)');
expect(text()).toMatch(/Add these pages.*replaces your Home page.*keeps your other 2 pages/);
expect(document.activeElement?.textContent).toBe('Add these pages');
await act(async () => { btn('Add these pages').click(); });
expect(onAdd).toHaveBeenCalledTimes(1); expect(onConfirm).not.toHaveBeenCalled();
await act(async () => { btn('Replace site and delete 2 pages').click(); });
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test('partial build, singular wording and nothing else to delete: only Add + Cancel', async () => {
await render(<ScopeConfirmDialog open mode="site-partial" impact={{ ...partial, missing: ['About'], planned: 2, kept: [] }} onAdd={vi.fn()} onConfirm={vi.fn()} onCancel={vi.fn()} />);
expect(text()).toContain('Sitesmith built only 1 of 2 pages');
expect(text()).toContain('This page was not built: About.');
expect(btns()).toEqual(['Cancel', 'Add these pages']);
});
test('complete site build: the replace confirm names the pages it deletes', async () => {
await render(<ScopeConfirmDialog open mode="site" impact={{ ...partial, missing: [], planned: 1, kept: ['MG TEST'] }} onConfirm={vi.fn()} onCancel={vi.fn()} />);
expect(text()).toContain('Replace your entire site?');
expect(text()).toContain('This deletes 1 of your current pages that Sitesmith did not rebuild: MG TEST.');
expect(btns()).toEqual(['Cancel', 'Replace site']);
});
test('complete site build with nothing extra to delete: no delete sentence', async () => {
await render(<ScopeConfirmDialog open mode="site" impact={{ ...partial, missing: [], planned: 1, kept: [] }} onConfirm={vi.fn()} onCancel={vi.fn()} />);
expect(text()).not.toMatch(/This deletes/);
});
});
+102 -29
View File
@@ -1,55 +1,128 @@
import React from 'react';
import React, { useEffect, useRef } from 'react';
import type { SiteImpact } from '../../sitesmith/replace-plan';
interface Props {
open: boolean;
pendingMessage?: string;
/** 'site': replace every page + header + footer. 'chrome': the page is replaced either way; also use the new header/footer? */
mode?: 'site' | 'chrome';
/**
* 'site': replace every page + header + footer.
* 'site-partial' (round 4): some planned pages were not built -- "Add these pages" (default, deletes
* nothing) or "Replace site" with the number of pages it deletes. Never a bare replace confirm.
* 'chrome': the page is replaced either way; also use the new header/footer?
*/
mode?: 'site' | 'site-partial' | 'chrome';
/** chrome mode: titles of existing pages the new header left out and the repair linked back in. */
addedLinks?: string[];
/** site modes: what each choice does to the existing pages (replace-plan.ts siteImpact). */
impact?: SiteImpact;
onConfirm: () => void;
/** site-partial: add the built pages without deleting any page. */
onAdd?: () => void;
onCancel: () => void;
}
export const ScopeConfirmDialog: React.FC<Props> = ({ open, pendingMessage, mode = 'site', addedLinks, onConfirm, onCancel }) => {
const plural = (n: number, one: string, many: string = `${one}s`) => `${n} ${n === 1 ? one : many}`;
const list = (names: string[]) => names.slice(0, 6).join(', ') + (names.length > 6 ? ', ...' : '');
export const ScopeConfirmDialog: React.FC<Props> = ({ open, pendingMessage, mode = 'site', addedLinks, impact, onConfirm, onAdd, onCancel }) => {
const addRef = useRef<HTMLButtonElement>(null);
useEffect(() => { if (open && mode === 'site-partial') addRef.current?.focus(); }, [open, mode]);
if (!open) return null;
const overlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
const box: React.CSSProperties = { background: '#1a1a2e', border: '1px solid #3f3f46', borderRadius: 10, padding: 22, maxWidth: 480, color: '#fff' };
const cancel: React.CSSProperties = { background: '#27272a', color: '#fff', border: 'none', padding: '8px 14px', borderRadius: 6, cursor: 'pointer' };
const ok: React.CSSProperties = { background: '#b91c1c', color: '#fff', border: 'none', padding: '8px 14px', borderRadius: 6, cursor: 'pointer' };
const safe: React.CSSProperties = { ...ok, background: '#4f46e5' };
const warn: React.CSSProperties = { color: '#fca5a5', fontSize: 14, borderLeft: '3px solid #b91c1c', paddingLeft: 10 };
const kept = impact?.kept ?? [];
let body: React.ReactNode;
let buttons: React.ReactNode;
if (mode === 'chrome') {
body = (
<>
<h3 style={{ margin: 0 }}>Also use a matching header and footer?</h3>
<p style={{ color: '#cbd5e1', fontSize: 14 }}>
Your header and footer are still the starter ones ("MySite"). Sitesmith made a header and
footer that match this page. They are shared, so every page will show them. This page is
replaced either way. The content of your other pages is not changed, and the new header
keeps links to every page on your site.
{addedLinks && addedLinks.length > 0 && (
<> Sitesmith's header left out {addedLinks.length} of your pages ({addedLinks.slice(0, 4).join(', ')}{addedLinks.length > 4 ? ', ...' : ''}); {addedLinks.length === 1 ? 'a link to it was' : 'links to them were'} added.</>
)}
</p>
</>
);
buttons = (
<>
<button onClick={onCancel} style={cancel}>Keep my header and footer</button>
<button onClick={onConfirm} style={safe}>Use the new header and footer</button>
</>
);
} else if (mode === 'site-partial' && impact) {
const missing = impact.missing;
const replaced = impact.replaced;
const addText = [
replaced.length ? `replaces your ${list(replaced)} ${replaced.length === 1 ? 'page' : 'pages'}` : '',
impact.added.length ? `adds ${list(impact.added)}` : '',
kept.length ? `keeps your other ${plural(kept.length, 'page')}` : '',
].filter(Boolean).join(', ');
body = (
<>
<h3 style={{ margin: 0 }}>Sitesmith built only {impact.built.length} of {plural(impact.planned, 'page')}</h3>
<p style={{ color: '#cbd5e1', fontSize: 14 }}>
{missing.length === 1 ? 'This page was not built' : 'These pages were not built'}: {list(missing)}. You can ask for {missing.length === 1 ? 'it' : 'each one'} on its own afterwards.
</p>
<p style={{ color: '#cbd5e1', fontSize: 14 }}>
<strong>Add these pages</strong> {addText || 'adds the new pages'}, and uses the new header and footer (they link to every page you will have).
</p>
{kept.length > 0 && (
<p style={warn}>
Replace site deletes {kept.length} of your pages ({list(kept)}) and replaces your header and footer. Manual edits will be lost.
</p>
)}
</>
);
buttons = (
<>
<button onClick={onCancel} style={cancel}>Cancel</button>
{kept.length > 0 && <button onClick={onConfirm} style={ok}>Replace site and delete {plural(kept.length, 'page')}</button>}
<button ref={addRef} onClick={onAdd} style={safe}>Add these pages</button>
</>
);
} else {
body = (
<>
<h3 style={{ margin: 0 }}>Replace your entire site?</h3>
<p style={{ color: '#cbd5e1', fontSize: 14 }}>
You asked for the whole site, so Sitesmith will replace every page, your header, and your
footer with the new design. Manual edits will be lost.
</p>
{kept.length > 0 && (
<p style={warn}>
This deletes {kept.length} of your current pages that Sitesmith did not rebuild: {list(kept)}.
</p>
)}
</>
);
buttons = (
<>
<button onClick={onCancel} style={cancel}>Cancel</button>
<button onClick={onConfirm} style={ok}>Replace site</button>
</>
);
}
return (
<div role="dialog" aria-modal="true" style={overlay}>
<div style={box}>
{mode === 'chrome' ? (
<>
<h3 style={{ margin: 0 }}>Also use a matching header and footer?</h3>
<p style={{ color: '#cbd5e1', fontSize: 14 }}>
Your header and footer are still the starter ones ("MySite"). Sitesmith made a header and
footer that match this page. They are shared, so every page will show them. This page is
replaced either way. The content of your other pages is not changed, and the new header
keeps links to every page on your site.
{addedLinks && addedLinks.length > 0 && (
<> Sitesmith's header left out {addedLinks.length} of your pages ({addedLinks.slice(0, 4).join(', ')}{addedLinks.length > 4 ? ', ...' : ''}); {addedLinks.length === 1 ? 'a link to it was' : 'links to them were'} added.</>
)}
</p>
</>
) : (
<>
<h3 style={{ margin: 0 }}>Replace your entire site?</h3>
<p style={{ color: '#cbd5e1', fontSize: 14 }}>
You asked for the whole site, so Sitesmith will replace every page, your header, and your
footer with the new design. Manual edits will be lost.
</p>
</>
)}
{body}
{pendingMessage && (
<blockquote style={{ borderLeft: '3px solid #7c3aed', paddingLeft: 12, color: '#a5b4fc', fontSize: 13 }}>
{pendingMessage}
</blockquote>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14 }}>
<button onClick={onCancel} style={cancel}>{mode === 'chrome' ? 'Keep my header and footer' : 'Cancel'}</button>
<button onClick={onConfirm} style={mode === 'chrome' ? { ...ok, background: '#4f46e5' } : ok}>{mode === 'chrome' ? 'Use the new header and footer' : 'Replace site'}</button>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14, flexWrap: 'wrap' }}>
{buttons}
</div>
</div>
</div>
@@ -468,6 +468,68 @@ describe('Sitesmith modal: replace scope follows the request', () => {
info.mockRestore();
}, 30000);
// ---- round 4 item 1 (live case 2): a partial whole-site build must not delete the owner's pages
const partialSite = () => ({
...replacePage('Site hero'), scope: 'site', message: 'Planned a four-page site. Built 1 page: Home.',
pages: [replacePage('Site hero').pages[0]],
header: { tree: { type: { resolvedName: 'Navbar' }, props: { node_id: 'ai-nav-1', logoType: 'text', logoText: 'OLS Digital', links: [{ text: 'Home', href: '/' }] }, nodes: [] } },
});
const partialBuild = { site_build: { planned: ['Home', 'About', 'Brands', 'Contact'], built: ['Home'], missing: ['About', 'Brands', 'Contact'] } };
async function mountWithExtraPage() {
await mountP();
h.act(() => pagesCtx.addPage('MG TEST (delete me)', 'mobile-guard-test'));
await flush(50);
h.act(() => pagesCtx.switchPage(pagesCtx.pages[0].id));
await flush(50);
}
test('r4: partial site build -> not the bare replace confirm; "Add these pages" keeps the other page and links it', async () => {
net.polls = [done(partialSite(), partialBuild)];
await mountWithExtraPage();
expect(pagesCtx.pages.map((p) => p.name)).toEqual(['Home', 'MG TEST (delete me)']);
await sendText('Build me a website for OLS Digital with home, about, brands and contact pages.');
await flush(1250);
expect(dialog()).toBe('Sitesmith built only 1 of 4 pages');
expect(document.body.textContent).not.toContain('Replace your entire site?');
expect(document.body.textContent).toContain('These pages were not built: About, Brands, Contact.');
expect(document.body.textContent).toContain('Replace site deletes 1 of your pages (MG TEST (delete me))');
expect(btn('Replace site and delete 1 page')).toBeTruthy();
await h.act(async () => { btn('Add these pages').click(); });
await flush(300);
expect(pagesCtx.pages.map((p) => p.name)).toEqual(['Home', 'MG TEST (delete me)']);
expect(h.container.textContent).toContain('Site hero');
expect(headerLogo()).toBe('OLS Digital');
const st = JSON.parse(pagesCtx.latestChrome().headerState!);
const navP = Object.values<any>(st).find((n) => n.type?.resolvedName === 'Navbar')!.props;
expect(navP.links.map((l: any) => l.href)).toEqual(['/', '/mobile-guard-test']);
}, 30000);
test('r4: partial site build -> "Replace site and delete 1 page" is still possible, and does delete it', async () => {
net.polls = [done(partialSite(), partialBuild)];
await mountWithExtraPage();
await sendText('Build me a website for OLS Digital with home, about, brands and contact pages.');
await flush(1250);
await h.act(async () => { btn('Replace site and delete 1 page').click(); });
await flush(300);
expect(pagesCtx.pages.map((p) => p.name)).toEqual(['Home']);
}, 30000);
test('r4: partial site build -> Cancel changes nothing', async () => {
net.polls = [done(partialSite(), partialBuild)];
await mountWithExtraPage();
await sendText('Build me a website for OLS Digital with home, about, brands and contact pages.');
await flush(1250);
await h.act(async () => { btn('Cancel').click(); });
await flush(300);
expect(pagesCtx.pages.map((p) => p.name)).toEqual(['Home', 'MG TEST (delete me)']);
expect(h.container.textContent).not.toContain('Site hero');
}, 30000);
test('r4: a complete site build names the page the replace deletes', async () => {
net.polls = [done(siteAnswer(), { site_build: { planned: ['Home', 'About'], built: ['Home', 'About'], missing: [] } })];
await mountWithExtraPage();
await sendText('Build me a website for my company');
await flush(1250);
expect(dialog()).toBe('Replace your entire site?');
expect(document.body.textContent).toContain('This deletes 1 of your current pages that Sitesmith did not rebuild: MG TEST (delete me).');
}, 30000);
test('an explicit whole-site request keeps the whole-site confirm', async () => {
net.polls = [done(siteAnswer())];
await mountP();
+16 -6
View File
@@ -32,7 +32,7 @@ import { SitesmithTarget } from '../../state/SitesmithContext';
import { UpgradeBanner } from './UpgradeBanner';
import { ScopeConfirmDialog } from './ScopeConfirmDialog';
import { buildSitePagesPayload } from '../../utils/site-pages';
import { planReplace, isDefaultChrome, withoutChrome, missingUploadedImageNote } from '../../sitesmith/replace-plan';
import { planReplace, isDefaultChrome, withoutChrome, missingUploadedImageNote, addPagesResponse, type SiteImpact } from '../../sitesmith/replace-plan';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { SitesmithResponse } from '../../types/sitesmith';
@@ -66,7 +66,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const [adjust, setAdjust] = useState<{ list: ContrastAdjustment[]; notChecked: number } | null>(null);
const [reviewNote, setReviewNote] = useState<SelfReviewOutcome | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [pendingReplace, setPendingReplace] = useState<{ resp: SitesmithResponse; review: boolean; mode: 'site' | 'chrome'; addedLinks?: string[] } | null>(null);
const [pendingReplace, setPendingReplace] = useState<{ resp: SitesmithResponse; review: boolean; mode: 'site' | 'site-partial' | 'chrome'; addedLinks?: string[]; impact?: SiteImpact } | null>(null);
/** What the owner typed for the job in flight (a replace's scope is decided from it, see replace-plan). */
const lastRequestRef = useRef<string | null>(null);
const [open, setOpen] = useState<Record<string, boolean>>({});
@@ -244,8 +244,8 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
};
// ---- one pipeline for send, resume and confirm
const applyResponse = async (resp: SitesmithResponse, review: boolean) => {
const applied = await apply(resp, target?.nodeId, { userText: lastRequestRef.current });
const applyResponse = async (resp: SitesmithResponse, review: boolean, addPages = false) => {
const applied = await apply(resp, target?.nodeId, { userText: lastRequestRef.current, ...(addPages ? { addPages } : {}) });
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) {
@@ -263,12 +263,12 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
// A whole-site replace needs an explicit whole-site request; the shared header/footer change only
// when they are untouched defaults and the owner says yes (replace-plan.ts).
const userText = lastRequestRef.current ?? [...messages].reverse().find((m) => m.role === 'user')?.content ?? null;
const plan = planReplace(res.response, { userText, chromeDefault: chromeDefault(), pages: pageCtx.pages });
const plan = planReplace(res.response, { userText, chromeDefault: chromeDefault(), pages: pageCtx.pages, siteBuild: res.siteBuild });
const offered = useAssetsFlag ? (assetFilter ? assetFilter.length : assets.length) : 0;
const imgNote = missingUploadedImageNote(plan.resp, offered);
setNotes([...(res.notes ?? []), ...(plan.note ? [plan.note] : []), ...(imgNote ? [imgNote] : [])]);
if (plan.confirm) {
setPendingReplace({ resp: plan.resp, review, mode: plan.confirm, addedLinks: plan.addedLinks });
setPendingReplace({ resp: plan.resp, review, mode: plan.confirm, addedLinks: plan.addedLinks, impact: plan.impact });
return;
}
await applyResponse(plan.resp, review);
@@ -352,6 +352,14 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
setBusy(true);
try { await applyResponse(resp, review); } finally { setBusy(false); }
};
/** Partial site build: add the built pages, keep every other page, and link them from the new header. */
const addPages = async () => {
if (!pendingReplace) return;
const { resp, review } = pendingReplace;
setPendingReplace(null);
setBusy(true);
try { await applyResponse(addPagesResponse(resp, pageCtx.pages), review, true); } finally { setBusy(false); }
};
/** Site mode: cancel = nothing applied. Chrome mode: "keep mine" = the page only. */
const cancelReplace = async () => {
if (!pendingReplace) return;
@@ -575,7 +583,9 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
pendingMessage={pendingReplace && 'message' in pendingReplace.resp ? pendingReplace.resp.message : undefined}
mode={pendingReplace?.mode ?? 'site'}
addedLinks={pendingReplace?.addedLinks}
impact={pendingReplace?.impact}
onConfirm={confirmReplace}
onAdd={addPages}
onCancel={cancelReplace}
/>
</div>
+55
View File
@@ -153,3 +153,58 @@ describe('planReplace: header link repair', () => {
expect(plan.addedLinks).toBeUndefined();
});
});
// ---- round 4 item 1: a PARTIAL whole-site build (live case 2: "Built 1 page: Home", About/Brands/Contact missing)
// must never be offered as a bare "Replace site" -- that deleted the owner's other pages.
import { mergeAiPages } from '../state/PageContext';
describe('round 4: partial whole-site builds', () => {
const tree = { type: { resolvedName: 'Container' }, props: {}, nodes: [] } as any;
const existing: any[] = [
{ id: 'home', name: 'Home', slug: 'index', craftState: 'H', seo: { title: 'Old' } },
{ id: 'p2', name: 'MG TEST (delete me)', slug: 'mobile-guard-test', craftState: 'M' },
{ id: 'p3', name: 'About', slug: 'about', craftState: 'A' },
];
const siteResp = (names: string[]): SitesmithResponse => ({ type: 'replace', scope: 'site', message: 'Built 1 page: Home.', pages: names.map((name) => ({ name, tree })),
header: { tree: { type: { resolvedName: 'Navbar' }, props: { links: [{ text: 'Home', href: '/' }] }, nodes: [] } as any } });
test('mergeAiPages (pure): built pages replace the page at the same address, new ones are appended, the rest are kept', () => {
let n = 0;
const m = mergeAiPages(existing, [{ name: 'Home', craftState: 'NEW-H' }, { name: 'About', craftState: 'NEW-A' }, { name: 'Brands', craftState: 'NEW-B' }], () => `new${++n}`);
expect(m.pages.map((p) => [p.id, p.name, p.slug, p.craftState])).toEqual([
['home', 'Home', 'index', 'NEW-H'], ['p2', 'MG TEST (delete me)', 'mobile-guard-test', 'M'], ['p3', 'About', 'about', 'NEW-A'], ['new1', 'Brands', 'brands', 'NEW-B']]);
expect(m.pages[0].seo).toEqual({ title: 'Old' }); // the owner's page settings stay
expect(m.firstId).toBe('home');
expect(m.replaced).toEqual(['Home', 'About']);
expect(m.added).toEqual(['Brands']);
expect(m.kept).toEqual(['MG TEST (delete me)']);
expect(existing[0].craftState).toBe('H'); // input not mutated
});
test('mergeAiPages: an empty site gets the first page as index/home', () => {
const m = mergeAiPages([], [{ name: 'Home', craftState: 'X' }, { name: 'Contact', craftState: 'Y' }], () => 'n1');
expect(m.pages.map((p) => [p.id, p.slug])).toEqual([['home', 'index'], ['n1', 'contact']]);
});
test('missing pages -> confirm "site-partial" with explicit counts, never the bare site confirm', () => {
const plan = planReplace(siteResp(['Home']), { userText: 'Build me a website for OLS Digital', chromeDefault: true, pages: existing,
siteBuild: { planned: ['Home', 'About', 'Brands', 'Contact'], built: ['Home'], missing: ['About', 'Brands', 'Contact'] } });
expect(plan.confirm).toBe('site-partial');
expect(plan.impact).toEqual({ planned: 4, built: ['Home'], missing: ['About', 'Brands', 'Contact'],
replaced: ['Home'], added: [], kept: ['MG TEST (delete me)', 'About'] });
});
test('complete site build -> "site" confirm, still with the pages a replace would delete', () => {
const plan = planReplace(siteResp(['Home', 'Contact']), { userText: 'Build me a website', chromeDefault: true, pages: existing,
siteBuild: { planned: ['Home', 'Contact'], built: ['Home', 'Contact'], missing: [] } });
expect(plan.confirm).toBe('site');
expect(plan.impact?.kept).toEqual(['MG TEST (delete me)', 'About']);
});
test('an older server (no site_build) keeps the whole-site confirm', () => {
expect(planReplace(siteResp(['Home']), { userText: 'Build me a website', chromeDefault: true, pages: existing }).confirm).toBe('site');
});
test('addPagesPlan: the header gets links to the kept pages (followPages), the built pages are untouched', async () => {
const { addPagesResponse } = await import('./replace-plan');
const out = addPagesResponse(siteResp(['Home']), existing) as any;
expect(out.header.tree.props.followPages).toBe(true);
expect(out.header.tree.props.links.map((l: any) => l.href)).toEqual(['/', '/mobile-guard-test', '/about']);
expect(out.pages).toEqual((siteResp(["Home"]) as any).pages);
});
});
+44 -4
View File
@@ -10,6 +10,7 @@
import type { SitesmithResponse } from '../types/sitesmith';
import type { PageData } from '../types';
import { repairHeaderLinks } from './header-repair';
import { mergeAiPages } from '../state/PageContext';
const SITE_PATTERNS: RegExp[] = [
/\b(?:whole|entire|full|complete)\s+(?:web\s*)?site\b/i,
@@ -67,19 +68,48 @@ export function isDefaultChrome(headerState: string | null, footerState: string
return true;
}
/** What the server says it built for a whole-site request (newer backends; absent on older ones). */
export interface SiteBuildInfo { planned: string[]; built: string[]; missing: string[] }
/**
* What a whole-site answer does to the owner's pages. `replaced`/`added`/`kept` describe "Add these
* pages" (mergeAiPages); a "Replace site" deletes exactly the `kept` pages.
*/
export interface SiteImpact { planned: number; built: string[]; missing: string[]; replaced: string[]; added: string[]; kept: string[] }
export interface ReplacePlan {
resp: SitesmithResponse;
/** 'site': confirm a whole-site replace. 'chrome': ask whether to also use the new header/footer. */
confirm: 'site' | 'chrome' | null;
/**
* 'site': confirm a whole-site replace. 'site-partial' (round 4): some planned pages were not built, so the
* owner chooses "Add these pages" (default, deletes nothing) or "Replace site" (with the delete count).
* 'chrome': ask whether to also use the new header/footer.
*/
confirm: 'site' | 'site-partial' | 'chrome' | null;
/** Whole-site confirms: what each choice does to the existing pages. */
impact?: SiteImpact;
/** Shown to the owner when the plan changed what Sitesmith returned. */
note?: string;
/** Titles of existing pages the new header left out and the repair linked back in (chrome confirm only). */
addedLinks?: string[];
}
export function planReplace(resp: SitesmithResponse, ctx: { userText: string | null | undefined; chromeDefault: boolean; pages?: PageData[] }): ReplacePlan {
/** Ids for a preview merge only (never stored): unique, so page-keyed helpers see distinct pages. */
const mintPreviewId = () => { let i = 0; return () => `__preview_${++i}`; };
export function siteImpact(resp: Extract<SitesmithResponse, { type: 'replace' }>, pages: PageData[], siteBuild?: SiteBuildInfo | null): SiteImpact {
const m = mergeAiPages(pages, resp.pages.map((p) => ({ name: p.name, craftState: null })), mintPreviewId());
const built = resp.pages.map((p) => p.name);
const missing = siteBuild ? siteBuild.missing.filter((n) => !built.includes(n)) : [];
return { planned: siteBuild ? Math.max(siteBuild.planned.length, built.length) : built.length, built, missing, replaced: m.replaced, added: m.added, kept: m.kept };
}
export function planReplace(resp: SitesmithResponse, ctx: { userText: string | null | undefined; chromeDefault: boolean; pages?: PageData[]; siteBuild?: SiteBuildInfo | null }): ReplacePlan {
if (resp.type !== 'replace' || resp.scope === 'section' || !resp.pages.length) return { resp, confirm: null };
if (resp.scope === 'site' && explicitSiteRequest(ctx.userText)) return { resp, confirm: 'site' };
if (resp.scope === 'site' && explicitSiteRequest(ctx.userText)) {
const impact = siteImpact(resp, ctx.pages ?? [], ctx.siteBuild);
// a partial build is never offered as a bare destructive replace (live case 2 deleted the owner's pages)
return { resp, confirm: impact.missing.length ? 'site-partial' : 'site', impact };
}
const notes: string[] = [];
let page: Extract<SitesmithResponse, { type: 'replace' }> = { ...resp };
@@ -105,6 +135,16 @@ export function planReplace(resp: SitesmithResponse, ctx: { userText: string | n
return { resp: rest as SitesmithResponse, confirm: null, note: notes.join(' ') };
}
/**
* "Add these pages": the whole-site answer applied without deleting pages. The new header must then link
* the kept pages as well as the built ones (repairHeaderLinks against the merged page list).
*/
export function addPagesResponse(resp: SitesmithResponse, pages: PageData[]): SitesmithResponse {
if (resp.type !== 'replace') return resp;
const merged = mergeAiPages(pages, resp.pages.map((p) => ({ name: p.name, craftState: null })), mintPreviewId()).pages;
return repairHeaderLinks(resp, merged).resp;
}
/** The same page answer without the header/footer (the owner chose to keep them). */
export function withoutChrome(resp: SitesmithResponse): SitesmithResponse {
if (resp.type !== 'replace') return resp;
+65
View File
@@ -134,6 +134,12 @@ interface PageContextValue {
replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => boolean;
/** False (nothing changed) while a global-section master is on the canvas (#57). */
replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => boolean;
/**
* AI helper (round 4): ADD a whole-site answer without deleting pages -- each built page replaces the
* page at the same address, new ones are appended, every other page is kept (mergeAiPages).
* False (nothing changed) while a global-section master is open (#57) or when there is nothing to add.
*/
addAiPages: (pages: { name: string; tree: SerializedTreeNode }[]) => boolean;
setHeader: (tree: SerializedTreeNode) => void;
setFooter: (tree: SerializedTreeNode) => void;
siteDesign: SiteDesign;
@@ -324,6 +330,7 @@ const PageContext = createContext<PageContextValue>({
setActivePageIdDirect: () => {},
replaceAllPages: () => false,
replaceCurrentPage: () => false,
addAiPages: () => false,
setHeader: () => {},
setFooter: () => {},
siteDesign: {} as SiteDesign,
@@ -345,6 +352,42 @@ function slugify(name: string): string {
return slug || 'page';
}
/**
* Pure (round 4, live case 2): how a whole-site answer is ADDED to the existing pages instead of replacing
* them. The first new page lands on the landing page (address "/"); any other new page replaces the
* existing page with the same slug; the rest are appended with a unique slug. The owner's other pages and
* the replaced pages' own settings (id, slug, seo, nav) are kept. Inputs are not mutated.
*/
export function mergeAiPages(
existing: PageData[],
incoming: { name: string; craftState: string | null }[],
mintId: () => string = nextPageId,
): { pages: PageData[]; firstId: string; replaced: string[]; added: string[]; kept: string[] } {
const out: PageData[] = existing.map((p) => ({ ...p }));
const taken = new Set<number>();
const replaced: string[] = [];
const added: string[] = [];
let firstId = '';
incoming.forEach((inc, k) => {
const slug = slugify(inc.name);
const idx = k === 0 ? (out.length ? 0 : -1) : out.findIndex((p, i) => i > 0 && i < existing.length && !taken.has(i) && p.slug === slug);
let id: string;
if (idx >= 0 && !taken.has(idx)) {
replaced.push(out[idx].name);
out[idx] = { ...out[idx], name: inc.name, craftState: inc.craftState };
taken.add(idx);
id = out[idx].id;
} else {
const first = out.length === 0;
id = first ? 'home' : mintId();
out.push({ id, name: inc.name, slug: first ? 'index' : uniqueSlug(slug, out.map((p) => p.slug)), craftState: inc.craftState });
added.push(inc.name);
}
if (k === 0) firstId = id;
});
return { pages: out, firstId, replaced, added, kept: existing.filter((_, i) => !taken.has(i)).map((p) => p.name) };
}
/**
* Append `-2`, `-3`, … to `base` until it no longer collides with
* `existingSlugs`. Two pages that slugify to the same string (e.g. both
@@ -1058,6 +1101,27 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
return true;
}, [loadState]);
const addAiPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]): boolean => {
if (newPages.length === 0) return false;
if (isGlobalZoneId(activePageIdRef.current)) return false;
// the kept pages must carry what is on the canvas right now, not their last stored state
const currentId = activePageIdRef.current;
let base = pagesRef.current;
if (currentId === HEADER_ID || currentId === FOOTER_ID) saveCurrentState();
else {
const cur = query.serialize();
base = base.map((p) => (p.id === currentId ? { ...p, craftState: cur } : p));
}
const merged = mergeAiPages(base, newPages.map((p) => ({ name: p.name, craftState: treeToCraftState(p.tree) })));
pagesRef.current = merged.pages;
setPages(merged.pages);
const first = merged.pages.find((p) => p.id === merged.firstId)!;
setActivePageId(first.id);
activePageIdRef.current = first.id;
loadState(first.craftState, EMPTY_CANVAS);
return true;
}, [loadState, saveCurrentState, query]);
/**
* AI helper: replace the current page's tree.
* Deserializes the new tree into the live Craft.js canvas and persists it.
@@ -1189,6 +1253,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setActivePageIdDirect,
replaceAllPages,
replaceCurrentPage,
addAiPages,
setHeader,
setFooter,
siteDesign: design,
+3 -1
View File
@@ -75,6 +75,8 @@ export type SitesmithResponse =
| { type: 'patch'; ops: SitesmithPatchOp[]; message: string; render_mismatch?: boolean; }
| { type: 'ask'; question: string; options?: string[]; };
export interface SendResultOk { ok: true; response: SitesmithResponse; /** Non-blocking server messages (e.g. images skipped). */ notes?: string[]; }
export interface SendResultOk { ok: true; response: SitesmithResponse; /** Non-blocking server messages (e.g. images skipped). */ notes?: string[];
/** Whole-site builds (newer backends): which planned pages were built / missing. */
siteBuild?: { planned: string[]; built: string[]; missing: string[] }; }
export interface SendResultErr { ok: false; status: SitesmithStatus; message: string; /** The job this failure belongs to (resumable when we only stopped waiting). */ jobId?: string; }
export type SendResult = SendResultOk | SendResultErr;
+4 -2
View File
@@ -112,7 +112,8 @@ export function useApplyAiResponse() {
return async function apply(
resp: SitesmithResponse,
targetNodeId?: string,
opts: { repairContrast?: boolean; /** The owner's request text (an owner-named button colour exempts the CTA check). */ userText?: string | null } = {},
opts: { repairContrast?: boolean; /** The owner's request text (an owner-named button colour exempts the CTA check). */ userText?: string | null;
/** scope=site only: ADD the pages (keep every other page) instead of replacing the site (round 4). */ addPages?: boolean } = {},
): Promise<ApplyOutcome> {
// 'ask' type = AI wants clarification, nothing to apply
if (resp.type === 'ask') return { ok: true };
@@ -156,7 +157,8 @@ export function useApplyAiResponse() {
if (resp.type === 'replace') {
if (resp.scope === 'site') {
if (!pages.replaceAllPages(resp.pages.map((p) => ({ name: p.name, tree: p.tree })))) {
const sitePages = resp.pages.map((p) => ({ name: p.name, tree: p.tree }));
if (!(opts.addPages ? pages.addAiPages(sitePages) : pages.replaceAllPages(sitePages))) {
return {
ok: false,
message: pages.isEditingGlobal