diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts index 7c62fe5..da14c41 100644 --- a/craft/src/hooks/useSitesmith.ts +++ b/craft/src/hooks/useSitesmith.ts @@ -68,6 +68,8 @@ export interface SitesmithSendOpts { design?: Record; /** Uploaded assets Sitesmith may see (their filenames, validated by the backend against the site's assets). Omitted = all. */ assetFilenames?: string[]; + /** The shared header/footer are still the editor's untouched starter ones (sent as chrome_default). */ + chromeDefault?: boolean; onProgress?: (phase: string) => void; signal?: AbortSignal; } @@ -362,6 +364,7 @@ export function useSitesmith(siteId: number) { if (opts?.useAssets !== undefined) startBody.use_assets = opts.useAssets; if (opts?.intent === 'review' && opts.reviewImageIds?.length) startBody.review_image_ids = opts.reviewImageIds; if (opts?.assetFilenames) startBody.asset_filenames = opts.assetFilenames; + if (opts?.chromeDefault && opts?.intent !== 'review') startBody.chrome_default = true; const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, { method: 'POST', credentials: 'include', diff --git a/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx b/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx index d31cfc8..014f7e3 100644 --- a/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx +++ b/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx @@ -3,11 +3,13 @@ import React from 'react'; 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'; onConfirm: () => void; onCancel: () => void; } -export const ScopeConfirmDialog: React.FC = ({ open, pendingMessage, onConfirm, onCancel }) => { +export const ScopeConfirmDialog: React.FC = ({ open, pendingMessage, mode = 'site', onConfirm, onCancel }) => { 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' }; @@ -16,19 +18,32 @@ export const ScopeConfirmDialog: React.FC = ({ open, pendingMessage, onCo return (
-

Replace your entire site?

-

- Sitesmith will replace every page, your header, and your footer with the new design. - Manual edits will be lost. -

+ {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; your other pages are not changed. +

+ + ) : ( + <> +

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

+ + )} {pendingMessage && (
{pendingMessage}
)}
- - + +
diff --git a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx index 465f34d..9769bd1 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx @@ -370,3 +370,72 @@ describe('Sitesmith modal: picture selection is per message (E)', () => { expect(document.body.textContent).toMatch(/Clear for everyone on this site\?/); }, 20000); }); + +// ---- scope: a page request never becomes a whole-site replace (eval 2026-11-01, "Build my home page" -> scope site) +import { usePages } from '../../state/PageContext'; +describe('Sitesmith modal: replace scope follows the request', () => { + let pagesCtx: ReturnType; + const PagesSpy: React.FC = () => { pagesCtx = usePages(); return null; }; + const navTree = (logo: string) => ({ type: { resolvedName: 'Navbar' }, props: { node_id: 'ai-nav-1', logoType: 'text', logoText: logo, links: [] }, nodes: [] }); + const siteAnswer = () => ({ + ...replacePage('Site hero'), scope: 'site', + pages: [replacePage('Site hero').pages[0], { name: 'About', tree: { type: { resolvedName: 'Container' }, props: { tag: 'div' }, nodes: [] } }], + header: { tree: navTree('OLS Digital') }, footer: { tree: { type: { resolvedName: 'Footer' }, props: { node_id: 'ai-footer-1', text: '© OLS Digital' }, nodes: [] } }, + }); + async function mountP() { + try { window.localStorage.setItem('sitesmith.selfReview', 'off'); } catch { /* */ } + h = renderEditorHarness({ + initialState: INITIAL, + wrap: (tree) => ( + + {tree} + + ), + }); + h.mountChild( {}} target={null} />); + h.act(() => extras.set('sitesmithProfile', PROFILE)); + await flush(); await flush(); + } + const dialog = () => document.querySelector('[role=dialog][aria-modal=true] h3')?.textContent ?? null; + const btn = (label: string) => Array.from(document.querySelectorAll('[role=dialog] button')).find((b) => b.textContent === label) as HTMLButtonElement; + const headerLogo = () => { const st = JSON.parse(pagesCtx.latestChrome().headerState!); return Object.values(st).find((n) => n.type?.resolvedName === 'Navbar')?.props.logoText; }; + + test('page request answered with scope site + starter chrome: page replaced, asked about header/footer only; "keep" keeps them', async () => { + net.polls = [done(siteAnswer())]; + await mountP(); + await sendText('Build my home page following the layout of the attached mockup.'); + await flush(1250); + expect(net.starts[0].chrome_default).toBe(true); + expect(dialog()).toBe('Also use a matching header and footer?'); + expect(document.body.textContent).not.toContain('Replace your entire site?'); + await h.act(async () => { btn('Keep my header and footer').click(); }); + await flush(300); + expect(h.container.textContent).toContain('Site hero'); + expect(pagesCtx.pages).toHaveLength(1); // the "About" page from the site answer was NOT added + expect(headerLogo()).toBe('MySite'); + expect(document.body.textContent).toMatch(/only this page was replaced/); + }, 30000); + + test('... and "use the new header and footer" swaps the starter chrome, still one page', async () => { + net.polls = [done(siteAnswer())]; + await mountP(); + await sendText('Build my home page'); + await flush(1250); + await h.act(async () => { btn('Use the new header and footer').click(); }); + await flush(300); + expect(pagesCtx.pages).toHaveLength(1); + expect(headerLogo()).toBe('OLS Digital'); + }, 30000); + + test('an explicit whole-site request keeps the whole-site confirm', async () => { + net.polls = [done(siteAnswer())]; + await mountP(); + await sendText('Build me a website for my company'); + await flush(1250); + expect(dialog()).toBe('Replace your entire site?'); + await h.act(async () => { btn('Replace site').click(); }); + await flush(300); + expect(pagesCtx.pages.map((p) => p.name)).toEqual(['Home', 'About']); + expect(headerLogo()).toBe('OLS Digital'); + }, 30000); +}); diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx index 82e7ca5..502b694 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.tsx @@ -31,6 +31,7 @@ import { summarizeCanvas } from '../../utils/canvas-summary'; import { SitesmithTarget } from '../../state/SitesmithContext'; import { UpgradeBanner } from './UpgradeBanner'; import { ScopeConfirmDialog } from './ScopeConfirmDialog'; +import { planReplace, isDefaultChrome, withoutChrome } from '../../sitesmith/replace-plan'; import { MessageList } from './MessageList'; import { ChatInput } from './ChatInput'; import { SitesmithResponse } from '../../types/sitesmith'; @@ -64,7 +65,9 @@ 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 } | null>(null); + const [pendingReplace, setPendingReplace] = useState<{ resp: SitesmithResponse; review: boolean; mode: 'site' | 'chrome' } | 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>({}); const [selMockups, setSelMockups] = useState([]); const [selRefs, setSelRefs] = useState([]); @@ -164,8 +167,12 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { return t; }; + const chromeDefault = () => { + try { const c = pageCtx.latestChrome(); return isDefaultChrome(c.headerState, c.footerState); } catch { return false; } + }; const sendOpts = () => ({ design: buildDesignPayload(design), + chromeDefault: chromeDefault(), mockupIds: imagesOn ? selMockups : [], referenceIds: selRefs, useAssets: useAssetsFlag, @@ -251,12 +258,16 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { setError(failureMessage(res)); return; } - setNotes(res.notes ?? []); - if (res.response.type === 'replace' && res.response.scope === 'site') { - setPendingReplace({ resp: res.response, review }); + // 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() }); + setNotes([...(res.notes ?? []), ...(plan.note ? [plan.note] : [])]); + if (plan.confirm) { + setPendingReplace({ resp: plan.resp, review, mode: plan.confirm }); return; } - await applyResponse(res.response, review); + await applyResponse(plan.resp, review); }; const runJob = async (start: (o: { onProgress: (p: string) => void; signal: AbortSignal }) => Promise, review: boolean) => { @@ -271,6 +282,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { }; const handleSend = async (text: string) => { + lastRequestRef.current = text; const canvas = summarizeCanvas(query.getSerializedNodes()); const opts = sendOpts(); await runJob((o) => send(text, canvas, targetPayload, { ...opts, ...o }), true); @@ -291,6 +303,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { if (resumedRef.current || busy || !canChat) return; resumedRef.current = true; setResumed(true); + lastRequestRef.current = null; setPhase(activeJob.phase || 'generating'); void runJob((o) => resumeJob(activeJob.job_id, o), true); }); // eslint-disable-line react-hooks/exhaustive-deps @@ -335,6 +348,15 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { setBusy(true); try { await applyResponse(resp, review); } finally { setBusy(false); } }; + /** Site mode: cancel = nothing applied. Chrome mode: "keep mine" = the page only. */ + const cancelReplace = async () => { + if (!pendingReplace) return; + const { resp, review, mode } = pendingReplace; + setPendingReplace(null); + if (mode !== 'chrome') return; + setBusy(true); + try { await applyResponse(withoutChrome(resp), review); } finally { setBusy(false); } + }; const cancelClearChat = () => { if (clearChatTimeoutRef.current) clearTimeout(clearChatTimeoutRef.current); @@ -547,8 +569,9 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { setPendingReplace(null)} + onCancel={cancelReplace} /> diff --git a/craft/src/sitesmith/replace-plan.test.ts b/craft/src/sitesmith/replace-plan.test.ts new file mode 100644 index 0000000..49330c7 --- /dev/null +++ b/craft/src/sitesmith/replace-plan.test.ts @@ -0,0 +1,90 @@ +import { describe, test, expect } from 'vitest'; +import { explicitSiteRequest, isDefaultChrome, planReplace } from './replace-plan'; +import { DEFAULT_HEADER_STATE } from '../state/PageContext'; +import type { SitesmithResponse } from '../types/sitesmith'; + +const tree = (name = 'Container') => ({ type: { resolvedName: name }, props: {}, nodes: [] }) as any; +const siteResp = (n = 1): SitesmithResponse => ({ + type: 'replace', scope: 'site', message: 'Built it', + pages: Array.from({ length: n }, (_, i) => ({ name: i ? `P${i}` : 'Home', tree: tree() })), + header: { tree: tree('Navbar') }, footer: { tree: tree('Footer') }, +}); + +describe('explicitSiteRequest', () => { + test.each([ + 'Build me a website for my bakery', + 'Build my website', + 'Create a new site for my plumbing business', + 'Redo the whole site', + 'Rebuild the entire website in a darker style', + 'Make a 4 page site with home, about, menu and contact', + 'Update all pages to use the new colours', + 'Change the font on every page', + 'Build my bakery website', + ])('site: %s', (t) => expect(explicitSiteRequest(t)).toBe(true)); + test.each([ + 'Build my home page following the layout of the attached mockup.', + 'Build the home page for my site.', + 'Build the home page for my site. Use the site I like as inspiration.', + 'Redo the home page', + 'Make this page look more premium', + 'Add a pricing section', + '', + ])('page: %s', (t) => expect(explicitSiteRequest(t)).toBe(false)); +}); + +describe('isDefaultChrome', () => { + const header = (mut: (n: any) => void) => { const s = JSON.parse(DEFAULT_HEADER_STATE); mut(s); return JSON.stringify(s); }; + test('the seed header + no footer is default, also after pages were added to the nav', () => { + expect(isDefaultChrome(DEFAULT_HEADER_STATE, null)).toBe(true); + expect(isDefaultChrome(header((s) => s['header-navbar'].props.links.splice(1, 0, { text: 'About', href: '/about', source: 'page', pageId: 'p2' })), null)).toBe(true); + expect(isDefaultChrome(null, null)).toBe(true); + }); + test('any owner change makes it custom', () => { + expect(isDefaultChrome(header((s) => { s['header-navbar'].props.logoText = 'Acme'; }), null)).toBe(false); + expect(isDefaultChrome(header((s) => { s['header-navbar'].props.logoType = 'image'; s['header-navbar'].props.logoImage = 'assets/logo.png'; }), null)).toBe(false); + expect(isDefaultChrome(header((s) => { s['header-navbar'].props.links[1].text = 'Book now'; }), null)).toBe(false); + expect(isDefaultChrome(header((s) => { s.ROOT.nodes.push('x'); s.x = { type: { resolvedName: 'TextBlock' }, props: {}, nodes: [] }; }), null)).toBe(false); + const footer = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, props: {}, nodes: ['f'] }, f: { type: { resolvedName: 'Footer' }, props: { text: '© 2026 Acme Ltd' }, nodes: [] } }); + expect(isDefaultChrome(DEFAULT_HEADER_STATE, footer)).toBe(false); + const seedFooter = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, props: {}, nodes: ['f'] }, f: { type: { resolvedName: 'Footer' }, props: { text: '© 2026 MySite. All rights reserved.' }, nodes: [] } }); + expect(isDefaultChrome(DEFAULT_HEADER_STATE, seedFooter)).toBe(true); + expect(isDefaultChrome('{not json', null)).toBe(false); + }); +}); + +describe('planReplace', () => { + test('eval case: "Build my home page" answered with scope site -> a PAGE replace, no site confirm', () => { + const p = planReplace(siteResp(1), { userText: 'Build my home page following the layout of the attached mockup.', chromeDefault: false }); + expect(p.confirm).toBeNull(); + expect(p.resp).toMatchObject({ type: 'replace', scope: 'page', pages: [{ name: 'Home' }] }); + expect((p.resp as any).header).toBeUndefined(); + expect((p.resp as any).footer).toBeUndefined(); + expect(p.note).toMatch(/only this page/i); + }); + test('page request + site answer + default chrome -> page replace that ASKS about header/footer', () => { + const p = planReplace(siteResp(3), { userText: 'Build my home page', chromeDefault: true }); + expect(p.confirm).toBe('chrome'); + expect(p.resp).toMatchObject({ scope: 'page', pages: [{ name: 'Home' }], header: { tree: { type: { resolvedName: 'Navbar' } } } }); + expect((p.resp as any).pages).toHaveLength(1); + }); + test('explicit site request keeps the site replace and its confirm', () => { + const p = planReplace(siteResp(3), { userText: 'Build me a website for my bakery', chromeDefault: false }); + expect(p.confirm).toBe('site'); + expect(p.resp).toEqual(siteResp(3)); + }); + test('page answer with header/footer: asked only when chrome is default, otherwise dropped', () => { + const resp: SitesmithResponse = { ...(siteResp(1) as any), scope: 'page' }; + expect(planReplace(resp, { userText: 'Build my home page', chromeDefault: true }).confirm).toBe('chrome'); + const kept = planReplace(resp, { userText: 'Build my home page', chromeDefault: false }); + expect(kept.confirm).toBeNull(); + expect((kept.resp as any).header).toBeUndefined(); + expect(kept.note).toMatch(/kept your header and footer/i); + }); + test('plain page / section / patch answers pass through untouched', () => { + const page: SitesmithResponse = { type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'Home', tree: tree() }] }; + expect(planReplace(page, { userText: 'x', chromeDefault: true })).toEqual({ resp: page, confirm: null }); + const patch: SitesmithResponse = { type: 'patch', message: 'm', ops: [] }; + expect(planReplace(patch, { userText: 'x', chromeDefault: true })).toEqual({ resp: patch, confirm: null }); + }); +}); diff --git a/craft/src/sitesmith/replace-plan.ts b/craft/src/sitesmith/replace-plan.ts new file mode 100644 index 0000000..48862f3 --- /dev/null +++ b/craft/src/sitesmith/replace-plan.ts @@ -0,0 +1,101 @@ +/** + * How a Sitesmith `replace` answer is applied, decided from what the owner ASKED for -- not from the + * scope the model chose. Eval 2026-11-01: "Build my home page" came back as scope "site" and confirming + * would have replaced every page. A whole-site replace now needs an explicit whole-site request; any + * other request replaces only the current page. + * + * The shared header/footer are replaced alongside a page only when they are still the editor's untouched + * defaults ("MySite" + a "Get Started" button) and the owner confirms; otherwise they are kept. + */ +import type { SitesmithResponse } from '../types/sitesmith'; + +const SITE_PATTERNS: RegExp[] = [ + /\b(?:whole|entire|full|complete)\s+(?:web\s*)?site\b/i, + /\b(?:all|every)\s+(?:of\s+)?(?:the\s+|my\s+|our\s+)?(?:pages|page)\b/i, + /\b(?:\d+|two|three|four|five|six|several|multiple|a few)[\s-]+pages?\b/i, + // "build me a website", "create a new site for ...", "build my bakery website" + /\b(?:build|make|create|design|redo|rebuild|generate|set\s+up)\s+(?:me\s+|us\s+)?(?:a|an|my|the|our)\s+(?:[\w'-]+\s+){0,2}?(?:web\s*)?site\b/i, +]; + +/** True only when the owner explicitly asked for the whole site (every page). */ +export function explicitSiteRequest(text: string | null | undefined): boolean { + if (!text) return false; + return SITE_PATTERNS.some((re) => re.test(text)); +} + +type Node = { type?: { resolvedName?: string }; props?: Record; nodes?: string[] }; + +function parseState(s: string | null): Record | null | 'bad' { + if (s == null || s === '') return null; + try { + const o = JSON.parse(s); + return o && typeof o === 'object' ? o : 'bad'; + } catch { return 'bad'; } +} + +/** Non-root nodes of a serialized Craft state. */ +const contentNodes = (st: Record) => Object.entries(st).filter(([id]) => id !== 'ROOT').map(([, n]) => n); + +/** + * The header is the seed Navbar (text logo "MySite", no image, its only non-page link the "Get Started" + * CTA) and the footer is absent or the seed Footer line. Page links the nav added by itself do not count + * as edits. Anything unreadable counts as customised (never replace what we cannot judge). + */ +export function isDefaultChrome(headerState: string | null, footerState: string | null): boolean { + const h = parseState(headerState); + const f = parseState(footerState); + if (h === 'bad' || f === 'bad') return false; + if (h) { + const nodes = contentNodes(h); + if (nodes.length !== 1 || nodes[0].type?.resolvedName !== 'Navbar') return false; + const p = nodes[0].props ?? {}; + if (p.logoText !== 'MySite' || (p.logoType && p.logoType !== 'text') || p.logoImage) return false; + const own = (Array.isArray(p.links) ? p.links : []).filter((l: any) => l?.source !== 'page'); + if (own.some((l: any) => !(l?.text === 'Get Started' && (l?.href ?? '#') === '#'))) return false; + } + if (f) { + const nodes = contentNodes(f); + if (nodes.length > 1) return false; + if (nodes.length === 1) { + const n = nodes[0]; + if (n.type?.resolvedName !== 'Footer' || !/\bMySite\b/.test(String(n.props?.text ?? ''))) return false; + if (Array.isArray(n.props?.links) && n.props!.links.length) return false; + } + } + return true; +} + +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; + /** Shown to the owner when the plan changed what Sitesmith returned. */ + note?: string; +} + +export function planReplace(resp: SitesmithResponse, ctx: { userText: string | null | undefined; chromeDefault: boolean }): 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' }; + + const notes: string[] = []; + let page: Extract = { ...resp }; + if (resp.scope === 'site') { + page = { ...resp, scope: 'page', pages: [resp.pages[0]] }; + notes.push(resp.pages.length > 1 + ? `Sitesmith built ${resp.pages.length} pages, but you asked for one page, so only this page was replaced. Ask for "the whole site" to replace every page.` + : 'You asked for one page, so only this page was replaced; your other pages were not touched.'); + } + const hasChrome = !!(page.header || page.footer); + if (!hasChrome) return { resp: page, confirm: null, ...(notes.length ? { note: notes.join(' ') } : {}) }; + if (ctx.chromeDefault) return { resp: page, confirm: 'chrome', ...(notes.length ? { note: notes.join(' ') } : {}) }; + const { header: _h, footer: _f, ...rest } = page; + notes.push('Sitesmith also suggested a new header and footer, but yours have been edited, so it kept your header and footer.'); + return { resp: rest as SitesmithResponse, confirm: null, note: notes.join(' ') }; +} + +/** 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; + const { header: _h, footer: _f, ...rest } = resp; + return rest as SitesmithResponse; +} diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 900c202..aba1d8c 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -165,6 +165,9 @@ export function useApplyAiResponse() { message: 'You are editing a global section. Click Done, then ask again to replace the page.', }; } + // Only present when the owner agreed to swap the starter header/footer (sitesmith/replace-plan.ts). + if (resp.header) pages.setHeader(resp.header.tree); + if (resp.footer) pages.setFooter(resp.footer.tree); return done({ ok: true }, await loadedCanvas(resp.pages[0].tree)); }