From 2dcf8b7feb690cb5c0ac3240ae61a0f0c06b5d44 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 18:29:56 -0800 Subject: [PATCH] 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 --- craft/src/hooks/useSitesmith.ts | 8 +- .../sitesmith/ScopeConfirmDialog.test.tsx | 39 ++++++ .../panels/sitesmith/ScopeConfirmDialog.tsx | 131 ++++++++++++++---- .../sitesmith/SitesmithModal.flows.test.tsx | 62 +++++++++ craft/src/panels/sitesmith/SitesmithModal.tsx | 22 ++- craft/src/sitesmith/replace-plan.test.ts | 55 ++++++++ craft/src/sitesmith/replace-plan.ts | 48 ++++++- craft/src/state/PageContext.tsx | 65 +++++++++ craft/src/types/sitesmith.ts | 4 +- craft/src/utils/apply-ai-response.ts | 6 +- 10 files changed, 397 insertions(+), 43 deletions(-) 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. + )} +

+ + ); + buttons = ( + <> + + + + ); + } 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 = ( + <> +

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. +

+ )} + + ); + buttons = ( + <> + + {kept.length > 0 && } + + + ); + } else { + body = ( + <> +

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. +

+ {kept.length > 0 && ( +

+ This deletes {kept.length} of your current pages that Sitesmith did not rebuild: {list(kept)}. +

+ )} + + ); + buttons = ( + <> + + + + ); + } return (
- {mode === 'chrome' ? ( - <> -

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. - )} -

- - ) : ( - <> -

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