From 201486f3574777e75c843b0f05c302a258e9fcbb Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 17:21:14 -0800 Subject: [PATCH] sitesmith: site_pages in requests, per-page build phases, 10 minute cap Co-Authored-By: Claude Opus 5.5 --- craft/src/hooks/useSitesmith.test.ts | 40 +++++++++++++++++-- craft/src/hooks/useSitesmith.ts | 6 ++- craft/src/panels/sitesmith/JobStatus.tsx | 21 +++++++++- .../panels/sitesmith/SitesmithPanels.test.tsx | 25 ++++++++++++ craft/src/utils/site-pages.test.ts | 26 ++++++++++++ craft/src/utils/site-pages.ts | 15 +++++++ 6 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 craft/src/utils/site-pages.test.ts create mode 100644 craft/src/utils/site-pages.ts diff --git a/craft/src/hooks/useSitesmith.test.ts b/craft/src/hooks/useSitesmith.test.ts index 62b81ee..897f633 100644 --- a/craft/src/hooks/useSitesmith.test.ts +++ b/craft/src/hooks/useSitesmith.test.ts @@ -153,7 +153,7 @@ describe('useSitesmith start+poll', () => { // 60s at <=5s spacing -> well under the 60 a fixed 1s interval would give expect(polls).toBeLessThan(25); expect(polls).toBeGreaterThan(10); - await act(async () => { await vi.advanceTimersByTimeAsync(400000); }); + await act(async () => { await vi.advanceTimersByTimeAsync(600000); }); await p; }); @@ -210,14 +210,24 @@ describe('useSitesmith start+poll', () => { expect(res.message).toMatch(/reopen Sitesmith/); }); - test('overall 6 minute cap -> TIMEOUT (job may still finish)', async () => { + test('overall 10 minute cap (per-page whole-site builds) -> TIMEOUT (job may still finish)', async () => { script(OKSTART, [running()]); - const res = await sendFor(7 * 60 * 1000, 'hi', 'c'); + const res = await sendFor(11 * 60 * 1000, 'hi', 'c'); expect(res).toMatchObject({ ok: false, status: 'TIMEOUT', jobId: JOB }); expect(res.message).toMatch(/background/); expect(res.message).toMatch(/history/); }); + test('a job still running after 8 minutes is still being waited for (cap is 10, not 6)', async () => { + script(OKSTART, [running('page:3/5')]); + vi.useFakeTimers(); + let p!: Promise; let settled = false; + await act(async () => { p = api.send('hi', 'c'); p.then(() => { settled = true; }); await vi.advanceTimersByTimeAsync(8 * 60 * 1000); }); + expect(settled).toBe(false); + await act(async () => { await vi.advanceTimersByTimeAsync(3 * 60 * 1000); }); + expect(await p).toMatchObject({ ok: false, status: 'TIMEOUT' }); + }); + test('abort via signal stops polling and resolves ABORTED', async () => { script(OKSTART, [running()]); const ac = new AbortController(); @@ -430,6 +440,30 @@ describe('request payload: design, assets and attachments', () => { }); }); + test('site_pages rides on a normal send and a regenerate, never on a review', async () => { + const bodies: any[] = []; + override = (url, init) => { + if (url.includes('action=start')) { bodies.push(JSON.parse(init.body)); return { status: 200, json: async () => ({ ok: true, job_id: 'j7', status: 'running', phase: 'queued' }) }; } + if (url.includes('action=poll')) return { status: 200, json: async () => ({ ok: true, status: 'done', phase: 'done', result: { ok: true, response: { type: 'ask', question: 'q' } } }) }; + return undefined; + }; + const sitePages = [{ title: 'Home', path: '/' }, { title: 'About', path: '/about' }]; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + try { + await act(async () => { + for (const o of [{ sitePages }, { sitePages, intent: 'regenerate_section' as const }, { sitePages, intent: 'review' as const, reviewImageIds: ['b'.repeat(32)] }, {}]) { + const p = api.send('hi', 'canvas', undefined, o); + await vi.advanceTimersByTimeAsync(1200); + await p; + } + }); + } finally { vi.useRealTimers(); } + expect(bodies[0].site_pages).toEqual(sitePages); + expect(bodies[1].site_pages).toEqual(sitePages); + expect(bodies[2].site_pages).toBeUndefined(); + expect(bodies[3].site_pages).toBeUndefined(); + }); + test('self-review: intent review + review_image_ids (the render is never sent as a mockup); typed failure statuses', async () => { const bodies: any[] = []; override = (url, init) => { diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts index da14c41..8c2532b 100644 --- a/craft/src/hooks/useSitesmith.ts +++ b/craft/src/hooks/useSitesmith.ts @@ -70,6 +70,8 @@ export interface SitesmithSendOpts { assetFilenames?: string[]; /** The shared header/footer are still the editor's untouched starter ones (sent as chrome_default). */ chromeDefault?: boolean; + /** The site's existing pages (utils/site-pages.ts), sent as site_pages on every non-review request. */ + sitePages?: { title: string; path: string }[]; onProgress?: (phase: string) => void; signal?: AbortSignal; } @@ -82,7 +84,8 @@ export type SitesmithHookStatus = SitesmithClientStatus; const POLL_START_MS = 1000; const POLL_GROWTH = 1.4; const POLL_CAP_MS = 5000; -const OVERALL_CAP_MS = 6 * 60 * 1000; +// A whole-site build runs one model call per page server-side. +const OVERALL_CAP_MS = 10 * 60 * 1000; const MAX_POLL_FAILURES = 5; export const MSG_NETWORK = 'Could not reach the server. Check your connection and try again.'; @@ -365,6 +368,7 @@ export function useSitesmith(siteId: number) { 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; + if (opts?.sitePages?.length && opts?.intent !== 'review') startBody.site_pages = opts.sitePages.slice(0, 30); const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, { method: 'POST', credentials: 'include', diff --git a/craft/src/panels/sitesmith/JobStatus.tsx b/craft/src/panels/sitesmith/JobStatus.tsx index cede96a..da6cf67 100644 --- a/craft/src/panels/sitesmith/JobStatus.tsx +++ b/craft/src/panels/sitesmith/JobStatus.tsx @@ -10,8 +10,25 @@ export const PHASE_LABEL: Record = { finalizing: 'Finishing up', done: 'Done', review: 'Reviewing the finished page', + // Whole-site builds run one model call per page on the server. + planning: 'Planning your pages', + assembling: 'Putting your site together', }; +const PAGE_PHASE = /^page:(\d)\/(\d)$/; + +/** Plain-words label for a phase; `page:K/N` is built from the numbers, anything unknown is "Working". */ +export function phaseLabel(phase: string): string { + const m = PAGE_PHASE.exec(phase); + if (m) return `Building page ${m[1]} of ${m[2]}`; + return PHASE_LABEL[phase] ?? 'Working'; +} + +/** The progress-bar stage a phase belongs to: the per-page build steps are all part of generating. */ +function stageOf(phase: string): string { + return phase === 'planning' || phase === 'assembling' || PAGE_PHASE.test(phase) ? 'generating' : phase; +} + const ORDER = ['queued', 'preparing', 'captioning', 'generating', 'finalizing']; interface Props { @@ -31,8 +48,8 @@ export const JobStatus: React.FC = ({ phase, startedAgoS = 0, onCancel, c const id = window.setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 1000); return () => window.clearInterval(id); }, [t0]); - const label = cancelling ? 'Cancelling…' : (PHASE_LABEL[phase] ?? 'Working'); - const idx = ORDER.indexOf(phase); + const label = cancelling ? 'Cancelling…' : phaseLabel(phase); + const idx = ORDER.indexOf(stageOf(phase)); return (
diff --git a/craft/src/panels/sitesmith/SitesmithPanels.test.tsx b/craft/src/panels/sitesmith/SitesmithPanels.test.tsx index 1c007f3..c4d3942 100644 --- a/craft/src/panels/sitesmith/SitesmithPanels.test.tsx +++ b/craft/src/panels/sitesmith/SitesmithPanels.test.tsx @@ -234,3 +234,28 @@ describe('JobStatus', () => { expect((box.querySelector('[data-testid=ss-cancel]') as HTMLButtonElement).disabled).toBe(true); }); }); + +describe('JobStatus: multi-page build phases', () => { + const label = () => box.querySelector('[data-testid=ss-phase-label]')!.textContent; + const bars = () => Array.from(box.querySelectorAll('[aria-hidden] > span')) as HTMLElement[]; + test.each([ + ['planning', 'Planning your pages'], + ['page:2/5', 'Building page 2 of 5'], + ['assembling', 'Putting your site together'], + ['something-new', 'Working'], + ])('%s -> %s, data-phase keeps the raw phase', async (phase, text) => { + await render(); + expect(label()).toContain(text); + expect(box.querySelector('[data-testid=ss-job-status]')!.getAttribute('data-phase')).toBe(phase); + }); + test('page:K/N and planning/assembling sit in the generating stage of the progress bar', async () => { + await render(); + const gen = bars().map((b) => b.style.background).join('|'); + for (const p of ['planning', 'page:1/3', 'assembling']) { + await render(); + expect(bars().map((b) => b.style.background).join('|')).toBe(gen); + } + await render(); + expect(bars()).toHaveLength(0); + }); +}); diff --git a/craft/src/utils/site-pages.test.ts b/craft/src/utils/site-pages.test.ts new file mode 100644 index 0000000..01dc6b4 --- /dev/null +++ b/craft/src/utils/site-pages.test.ts @@ -0,0 +1,26 @@ +import { describe, test, expect } from 'vitest'; +import { buildSitePagesPayload } from './site-pages'; +import type { PageData } from '../types'; + +const pg = (id: string, name: string, slug: string, nav?: PageData['nav']): PageData => ({ id, name, slug, craftState: null, nav }); + +describe('buildSitePagesPayload', () => { + test('landing page is "/", others "/", in page order, title = nav label or name', () => { + const out = buildSitePagesPayload([pg('a', 'Home', 'index'), pg('b', 'About us', 'about', { label: 'About' } as any), pg('c', 'Menu', 'menu')]); + expect(out).toEqual([ + { title: 'Home', path: '/' }, + { title: 'About', path: '/about' }, + { title: 'Menu', path: '/menu' }, + ]); + }); + test('hidden-from-nav pages are included', () => { + expect(buildSitePagesPayload([pg('a', 'Home', 'index'), pg('b', 'Secret', 'secret', { show: false } as any)])).toHaveLength(2); + }); + test('titles are cut to 80 chars and the list to 30 pages', () => { + const many = Array.from({ length: 40 }, (_, i) => pg(`p${i}`, 'x'.repeat(100), `s${i}`)); + const out = buildSitePagesPayload(many); + expect(out).toHaveLength(30); + expect(out[0].title).toHaveLength(80); + }); + test('no pages -> empty array', () => { expect(buildSitePagesPayload([])).toEqual([]); }); +}); diff --git a/craft/src/utils/site-pages.ts b/craft/src/utils/site-pages.ts new file mode 100644 index 0000000..a384a6a --- /dev/null +++ b/craft/src/utils/site-pages.ts @@ -0,0 +1,15 @@ +import type { PageData } from '../types'; +import { pageHref } from './nav-links'; + +export interface SitePageRef { title: string; path: string } + +/** + * The site's pages as sent to Sitesmith (`site_pages`): every page in page order, hidden-from-nav pages + * included, path = the published href (nav-links pageHref). Capped at 30 pages / 80-char titles (contract). + */ +export function buildSitePagesPayload(pages: PageData[]): SitePageRef[] { + return pages.slice(0, 30).map((p, i) => ({ + title: String(p.nav?.label || p.name || '').slice(0, 80), + path: pageHref(p, i), + })); +}