diff --git a/craft/src/panels/sitesmith/ScopeConfirmDialog.test.tsx b/craft/src/panels/sitesmith/ScopeConfirmDialog.test.tsx
index 39bad5b..0352e6b 100644
--- a/craft/src/panels/sitesmith/ScopeConfirmDialog.test.tsx
+++ b/craft/src/panels/sitesmith/ScopeConfirmDialog.test.tsx
@@ -55,6 +55,17 @@ describe('ScopeConfirmDialog site modes', () => {
expect(text()).toContain('This page was not built: About.');
expect(btns()).toEqual(['Cancel', 'Add these pages']);
});
+ test('r5: ONE kept page reads "keeps your other page (name)", never "your other 1 page"; the menus claim is the truthful one', async () => {
+ await render();
+ expect(text()).toContain('keeps your other page (MG TEST 2026.11.5 (delete me))');
+ expect(text()).not.toMatch(/other 1 page/);
+ expect(text()).toContain('uses the new header and footer, with menus that link to every page you will have.');
+ expect(text()).not.toContain('(they link to every page you will have)');
+ });
+ test('r5: several kept pages are counted and named', async () => {
+ await render();
+ expect(text()).toContain('keeps your other 2 pages (MG TEST, Team)');
+ });
test('complete site build: the replace confirm names the pages it deletes', async () => {
await render();
expect(text()).toContain('Replace your entire site?');
diff --git a/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx b/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx
index a80736a..aa70d6a 100644
--- a/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx
+++ b/craft/src/panels/sitesmith/ScopeConfirmDialog.tsx
@@ -65,7 +65,8 @@ export const ScopeConfirmDialog: React.FC = ({ open, pendingMessage, mode
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')}` : '',
+ // round 5: "keeps your other 1 page" -> "keeps your other page (MG TEST)"
+ kept.length === 1 ? `keeps your other page (${list(kept)})` : kept.length ? `keeps your other ${kept.length} pages (${list(kept)})` : '',
].filter(Boolean).join(', ');
body = (
<>
@@ -74,7 +75,7 @@ export const ScopeConfirmDialog: React.FC = ({ open, pendingMessage, mode
{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).
+ Add these pages {addText || 'adds the new pages'}, and uses the new header and footer, with menus that link to every page you will have.
{kept.length > 0 && (
diff --git a/craft/src/panels/sitesmith/statusMessages.test.ts b/craft/src/panels/sitesmith/statusMessages.test.ts
index cbb317f..206b7f7 100644
--- a/craft/src/panels/sitesmith/statusMessages.test.ts
+++ b/craft/src/panels/sitesmith/statusMessages.test.ts
@@ -46,3 +46,25 @@ describe('Sitesmith failure messages (G6)', () => {
expect(failureMessage({ ok: false, status: 'AI_ERROR', message: 'x' })).toBe(STATUS_MESSAGES.AI_ERROR.text);
});
});
+
+// ---- round 5: the provider refused this server's key / a whole site was refused before it started
+describe('round 5 statuses', () => {
+ test('PROVIDER_BUDGET, PROVIDER_AUTH and SITE_OVER_LIMIT are known server statuses (not the generic ERROR)', () => {
+ for (const st of ['PROVIDER_BUDGET', 'PROVIDER_AUTH', 'SITE_OVER_LIMIT']) {
+ expect(isSitesmithStatus(st), st).toBe(true);
+ expect(failureMessage({ ok: false, status: st as SitesmithStatus, message: '' })).not.toBe(STATUS_MESSAGES.ERROR.text);
+ }
+ });
+ test('without server text they say who can fix it, and never suggest retrying the same thing', () => {
+ expect(failureMessage({ ok: false, status: 'PROVIDER_BUDGET', message: '' })).toMatch(/budget.*administrator/i);
+ expect(failureMessage({ ok: false, status: 'PROVIDER_AUTH', message: '' })).toMatch(/key.*administrator/i);
+ expect(failureMessage({ ok: false, status: 'SITE_OVER_LIMIT', message: '' })).toMatch(/one page at a time/);
+ for (const st of ['PROVIDER_BUDGET', 'PROVIDER_AUTH'] as SitesmithStatus[]) expect(failureMessage({ ok: false, status: st, message: '' })).not.toMatch(/try again/i);
+ });
+ test('the server text (fixed wording + derived reason, never gateway text) is shown when present', () => {
+ const m = "The AI provider's budget for this server's Sitesmith key is used up, so Sitesmith cannot make AI calls right now. Please ask your administrator to raise the budget. Reason: budget or quota exceeded (HTTP 422).";
+ expect(failureMessage({ ok: false, status: 'PROVIDER_BUDGET', message: m })).toBe(m);
+ const u = "Building a whole website takes up to 5 AI calls (a plan, then one per page), and that could go over what is left of this server's daily Sitesmith limit, so Sitesmith did not start.";
+ expect(failureMessage({ ok: false, status: 'SITE_OVER_LIMIT', message: u })).toBe(u);
+ });
+});
diff --git a/craft/src/panels/sitesmith/statusMessages.ts b/craft/src/panels/sitesmith/statusMessages.ts
index f4c2922..fdfb33b 100644
--- a/craft/src/panels/sitesmith/statusMessages.ts
+++ b/craft/src/panels/sitesmith/statusMessages.ts
@@ -27,6 +27,12 @@ export const STATUS_MESSAGES: Record [l, ...(Array.isArray(l.children) ? flatten(l.children) : [])]);
}
-function repairNode(node: SerializedTreeNode, pages: PageData[], missing: Set): SerializedTreeNode {
+/** Does this nav already link at least one of the site's pages (the footer's "Explore" menu, not a "Legal" one)? */
+function linksAPage(node: SerializedTreeNode, pages: PageData[]): boolean {
+ const hrefs = new Set(pages.map((p, i) => pageHref(p, i)));
+ const raw: NavLink[] = Array.isArray(node.props?.links) ? (node.props.links as NavLink[]) : [];
+ return flatten(raw).some((l) => !!l && (l.source === 'page' || hrefs.has(normHref(l.href))));
+}
+
+function repairNode(node: SerializedTreeNode, pages: PageData[], missing: Set, onlyPageMenus = false): SerializedTreeNode {
let out = node;
- if (NAV.has(node.type?.resolvedName)) {
+ if (NAV.has(node.type?.resolvedName) && (!onlyPageMenus || linksAPage(node, pages))) {
const raw: NavLink[] = Array.isArray(node.props?.links) ? (node.props.links as NavLink[]) : [];
const pageHrefs = new Map(); // normalised href -> canonical href
pages.forEach((p, i) => pageHrefs.set(pageHref(p, i), pageHref(p, i)));
@@ -45,7 +52,7 @@ function repairNode(node: SerializedTreeNode, pages: PageData[], missing: Set repairNode(k, pages, missing));
+ const kids = node.nodes.map((k) => repairNode(k, pages, missing, onlyPageMenus));
out = { ...out, nodes: kids };
}
return out;
@@ -61,3 +68,15 @@ export function repairHeaderLinks(resp: SitesmithResponse, pages: PageData[]): {
const tree = repairNode(resp.header.tree, pages, missing);
return { resp: { ...resp, header: { ...resp.header, tree } }, missing: [...missing] };
}
+
+/**
+ * Round 5 (live run of 2026.11.9): after "Add these pages" the footer's "Explore" menu listed only Home, although
+ * the dialog promised menus linking every page the owner will have. The footer's PAGE menus -- a Navbar/Menu that
+ * already links at least one page of the site -- are repaired the same way as the header (followPages + every
+ * shown page); a footer menu of other links only (legal, social, anchors) is left alone.
+ */
+export function repairFooterLinks(resp: SitesmithResponse, pages: PageData[]): SitesmithResponse {
+ if (resp.type !== 'replace' || !resp.footer?.tree || !pages.length) return resp;
+ const tree = repairNode(resp.footer.tree, pages, new Set(), true);
+ return { ...resp, footer: { ...resp.footer, tree } };
+}
diff --git a/craft/src/sitesmith/replace-plan.test.ts b/craft/src/sitesmith/replace-plan.test.ts
index 95c72d1..873c2b0 100644
--- a/craft/src/sitesmith/replace-plan.test.ts
+++ b/craft/src/sitesmith/replace-plan.test.ts
@@ -208,3 +208,45 @@ describe('round 4: partial whole-site builds', () => {
expect(out.pages).toEqual((siteResp(["Home"]) as any).pages);
});
});
+
+// ---- round 5 item 3 (live run of 2026.11.9): after "Add these pages" the footer's Explore column listed only Home,
+// although the dialog promised menus linking every page the owner will have.
+describe('round 5: "Add these pages" repairs the footer page menus too', () => {
+ const tree = { type: { resolvedName: 'Container' }, props: {}, nodes: [] } as any;
+ const existing: any[] = [
+ { id: 'home', name: 'Home', slug: 'index', craftState: 'H' },
+ { id: 'p2', name: 'MG TEST 2026.11.5 (delete me)', slug: 'mobile-guard-test', craftState: 'M' },
+ ];
+ // the case-2 footer: brand column + an "Explore" Menu (pruned by the server to the built Home) + a legal menu
+ const footer = { type: { resolvedName: 'Container' }, props: { tag: 'footer' }, nodes: [
+ { type: { resolvedName: 'ColumnLayout' }, props: { columns: 3 }, nodes: [
+ { type: { resolvedName: 'Container' }, props: {}, nodes: [{ type: { resolvedName: 'Logo' }, props: { text: 'OLS Digital', href: '/' }, nodes: [] }] },
+ { type: { resolvedName: 'Container' }, props: {}, nodes: [{ type: { resolvedName: 'Menu' }, props: { links: [{ text: 'Home', href: '/' }] }, nodes: [] }] },
+ { type: { resolvedName: 'Container' }, props: {}, nodes: [{ type: { resolvedName: 'Menu' }, props: { links: [{ text: 'Privacy', href: '#privacy' }, { text: 'Mail', href: 'mailto:a@b.c' }] }, nodes: [] }] },
+ ] },
+ ] } as any;
+ const resp: SitesmithResponse = { type: 'replace', scope: 'site', message: 'Built 1 page: Home.', pages: [{ name: 'Home', tree }],
+ header: { tree: { type: { resolvedName: 'Navbar' }, props: { links: [{ text: 'Home', href: '/' }] }, nodes: [] } as any }, footer: { tree: footer } };
+ const cols = (r: any) => r.footer.tree.nodes[0].nodes;
+
+ test('the footer Explore menu links the kept page as well as the built one (and follows pages from now on)', async () => {
+ const { addPagesResponse } = await import('./replace-plan');
+ const out = addPagesResponse(resp, existing) as any;
+ const explore = cols(out)[1].nodes[0].props;
+ expect(explore.links.map((l: any) => l.href)).toEqual(['/', '/mobile-guard-test']);
+ expect(explore.followPages).toBe(true);
+ expect(out.header.tree.props.links.map((l: any) => l.href)).toEqual(['/', '/mobile-guard-test']);
+ });
+ test('a footer menu of other links only (legal, mail) is left exactly as it was; so is the logo', async () => {
+ const { addPagesResponse } = await import('./replace-plan');
+ const out = addPagesResponse(resp, existing) as any;
+ expect(cols(out)[2]).toEqual(footer.nodes[0].nodes[2]);
+ expect(cols(out)[0]).toEqual(footer.nodes[0].nodes[0]);
+ });
+ test('the input is not mutated', async () => {
+ const { addPagesResponse } = await import('./replace-plan');
+ const snap = JSON.stringify(resp);
+ addPagesResponse(resp, existing);
+ expect(JSON.stringify(resp)).toBe(snap);
+ });
+});
diff --git a/craft/src/sitesmith/replace-plan.ts b/craft/src/sitesmith/replace-plan.ts
index 74cc505..b134c0f 100644
--- a/craft/src/sitesmith/replace-plan.ts
+++ b/craft/src/sitesmith/replace-plan.ts
@@ -9,7 +9,7 @@
*/
import type { SitesmithResponse } from '../types/sitesmith';
import type { PageData } from '../types';
-import { repairHeaderLinks } from './header-repair';
+import { repairHeaderLinks, repairFooterLinks } from './header-repair';
import { mergeAiPages } from '../state/PageContext';
const SITE_PATTERNS: RegExp[] = [
@@ -136,13 +136,14 @@ export function planReplace(resp: SitesmithResponse, ctx: { userText: string | n
}
/**
- * "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).
+ * "Add these pages": the whole-site answer applied without deleting pages. The new header AND the footer's page
+ * menus must then link the kept pages as well as the built ones (repairHeaderLinks / repairFooterLinks against the
+ * merged page list) -- the dialog says so (round 5: the footer used to keep only the built pages).
*/
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;
+ return repairFooterLinks(repairHeaderLinks(resp, merged).resp, merged);
}
/** The same page answer without the header/footer (the owner chose to keep them). */
diff --git a/craft/src/types/sitesmith.ts b/craft/src/types/sitesmith.ts
index 543ce38..177478e 100644
--- a/craft/src/types/sitesmith.ts
+++ b/craft/src/types/sitesmith.ts
@@ -7,7 +7,10 @@ export type SitesmithServerStatus =
| 'USER_KILLSWITCH' | 'SERVER_KILLSWITCH'
| 'RATE_LIMITED' | 'BLOCKED' | 'AI_ERROR' | 'AI_INVALID'
| 'BAD_ATTACHMENT' | 'IMAGES_BUSY' | 'INTERRUPTED' | 'CANCELLED'
- | 'JOB_IN_PROGRESS' | 'TOO_MANY_JOBS' | 'SERVER_BUSY' | 'RESPONSE_TOO_LONG';
+ | 'JOB_IN_PROGRESS' | 'TOO_MANY_JOBS' | 'SERVER_BUSY' | 'RESPONSE_TOO_LONG'
+ // round 5: the AI provider refused this server's key (budget/quota used up | key not accepted); a whole-site
+ // build that could go over what is left of a daily limit is refused before it starts
+ | 'PROVIDER_BUDGET' | 'PROVIDER_AUTH' | 'SITE_OVER_LIMIT';
/** Statuses the editor's transport (hooks/useSitesmith.ts) produces itself. */
export type SitesmithClientStatus =
@@ -20,6 +23,7 @@ export const SITESMITH_SERVER_STATUSES: readonly SitesmithServerStatus[] = [
'OK_BONUS', 'OK_MONTHLY', 'DISABLED', 'CAP_REACHED', 'USER_KILLSWITCH', 'SERVER_KILLSWITCH',
'RATE_LIMITED', 'BLOCKED', 'AI_ERROR', 'AI_INVALID', 'BAD_ATTACHMENT', 'IMAGES_BUSY', 'INTERRUPTED',
'CANCELLED', 'JOB_IN_PROGRESS', 'TOO_MANY_JOBS', 'SERVER_BUSY', 'RESPONSE_TOO_LONG',
+ 'PROVIDER_BUDGET', 'PROVIDER_AUTH', 'SITE_OVER_LIMIT',
];
export const SITESMITH_CLIENT_STATUSES: readonly SitesmithClientStatus[] = [
'NETWORK', 'TIMEOUT', 'ABORTED', 'LOST_CONNECTION', 'AUTH_REQUIRED', 'FORBIDDEN', 'TOO_LARGE',