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:
1 parent
37935abad8
commit
2dcf8b7feb
10 files changed
+397
-43
No files matched your search
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user