diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts
index 8c2532b..169819e 100644
--- a/craft/src/hooks/useSitesmith.ts
+++ b/craft/src/hooks/useSitesmith.ts
@@ -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.');
}
diff --git a/craft/src/panels/sitesmith/ScopeConfirmDialog.test.tsx b/craft/src/panels/sitesmith/ScopeConfirmDialog.test.tsx
index 3890d6b..39bad5b 100644
--- a/craft/src/panels/sitesmith/ScopeConfirmDialog.test.tsx
+++ b/craft/src/panels/sitesmith/ScopeConfirmDialog.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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();
+ expect(text()).not.toMatch(/This deletes/);
+ });
+});
diff --git a/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx b/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx
index 8bb31d1..a80736a 100644
--- a/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx
+++ b/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx
@@ -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 = ({ 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 = ({ open, pendingMessage, mode = 'site', addedLinks, impact, onConfirm, onAdd, onCancel }) => {
+ const addRef = useRef(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 = (
+ <>
+
Also use a matching header and footer?
+
+ 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.>
+ )}
+
Sitesmith built only {impact.built.length} of {plural(impact.planned, 'page')}
+
+ {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.
+
+
+ Add these pages {addText || 'adds the new pages'}, and uses the new header and footer (they link to every page you will have).
+
+ {kept.length > 0 && (
+
+ Replace site deletes {kept.length} of your pages ({list(kept)}) and replaces your header and footer. Manual edits will be lost.
+
- 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.>
- )}
-
- >
- ) : (
- <>
-
Replace your entire site?
-
- 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.
-
- >
- )}
+ {body}
{pendingMessage && (
{pendingMessage}
)}
-
-
-
+
+ {buttons}
diff --git a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx
index 37ab043..b2e9ad9 100644
--- a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx
+++ b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx
@@ -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(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();
diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx
index 3f90dbc..44903be 100644
--- a/craft/src/panels/sitesmith/SitesmithModal.tsx
+++ b/craft/src/panels/sitesmith/SitesmithModal.tsx
@@ -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 = ({ onClose, target }) => {
const [adjust, setAdjust] = useState<{ list: ContrastAdjustment[]; notChecked: number } | null>(null);
const [reviewNote, setReviewNote] = useState(null);
const abortRef = useRef(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(null);
const [open, setOpen] = useState>({});
@@ -244,8 +244,8 @@ export const SitesmithModal: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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}
/>
diff --git a/craft/src/sitesmith/replace-plan.test.ts b/craft/src/sitesmith/replace-plan.test.ts
index 1413864..95c72d1 100644
--- a/craft/src/sitesmith/replace-plan.test.ts
+++ b/craft/src/sitesmith/replace-plan.test.ts
@@ -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);
+ });
+});
diff --git a/craft/src/sitesmith/replace-plan.ts b/craft/src/sitesmith/replace-plan.ts
index b8ce6d0..74cc505 100644
--- a/craft/src/sitesmith/replace-plan.ts
+++ b/craft/src/sitesmith/replace-plan.ts
@@ -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, 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 = { ...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;
diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx
index 1576990..77d5af1 100644
--- a/craft/src/state/PageContext.tsx
+++ b/craft/src/state/PageContext.tsx
@@ -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({
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();
+ 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,
diff --git a/craft/src/types/sitesmith.ts b/craft/src/types/sitesmith.ts
index 82ad503..2b10d34 100644
--- a/craft/src/types/sitesmith.ts
+++ b/craft/src/types/sitesmith.ts
@@ -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;
diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts
index 0f42178..8b2f4f7 100644
--- a/craft/src/utils/apply-ai-response.ts
+++ b/craft/src/utils/apply-ai-response.ts
@@ -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 {
// '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