Merge feat/sitesmith-hook into release/sitesmith-colors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 10:54:46 -08:00
commit 57734fbe51
2 files changed
+748 -33

No files matched your search

+324 -2
View File
@@ -16,14 +16,23 @@ let root: Root;
let container: HTMLDivElement;
let sends: any[];
let entitlement: any;
// Per-test override: return a response-ish object to take over, or undefined to fall through.
let override: ((url: string, init?: any) => any) | undefined;
let calls: Array<{ url: string; init?: any }>;
const Probe: React.FC = () => { api = useSitesmith(7); extras = useProjectExtras(); return null; };
beforeEach(async () => {
sends = [];
override = undefined;
calls = [];
entitlement = { ok: true, summary: { enabled: true, status: 'OK_MONTHLY' } };
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => {
const json = (v: unknown) => ({ json: async () => v });
const json = (v: unknown) => ({ status: 200, json: async () => v });
calls.push({ url, init });
if (override) { const o = await override(url, init); if (o !== undefined) return o; }
// Legacy backend: no `start` action, so existing tests exercise the blocking `send` path.
if (url.includes('action=start')) return { status: 400, json: async () => ({ ok: false, error: 'unknown_action' }) };
if (url.includes('action=send')) { sends.push(JSON.parse(init.body)); return json({ ok: true, response: { type: 'ask', question: 'q' } }); }
if (url.includes('action=entitlement')) return json(entitlement);
return json({ ok: true, messages: [] });
@@ -39,7 +48,7 @@ beforeEach(async () => {
);
});
});
afterEach(() => { act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); });
afterEach(() => { vi.useRealTimers(); act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); });
describe('useSitesmith send body', () => {
test('omits profile and intent when none is stored or given', async () => {
@@ -79,3 +88,316 @@ describe('useSitesmith send body', () => {
expect(sends[0].profile).toEqual(PROFILE);
});
});
// ---------------------------------------------------------------------------
// start + poll transport
// ---------------------------------------------------------------------------
type R = { status?: number; body?: any; raw?: 'nonjson' | 'throw' };
const resp = (r: R) => {
if (r.raw === 'throw') throw new TypeError('Failed to fetch');
return { status: r.status ?? 200, json: async () => { if (r.raw === 'nonjson') throw new SyntaxError('Unexpected token <'); return r.body; } };
};
const JOB = 'a'.repeat(32);
let starts: any[];
let polls: number;
let history: any;
let historyFetches: number;
/** Scripted backend: start -> startRes, poll -> pollScript (last entry repeats). */
function script(startRes: R, pollScript: R[]) {
starts = []; polls = 0; historyFetches = 0;
history = { ok: true, messages: [] };
override = (url, init) => {
if (url.includes('action=start')) { starts.push(JSON.parse(init.body)); return resp(startRes); }
if (url.includes('action=poll')) { const r = pollScript[Math.min(polls, pollScript.length - 1)]; polls++; return resp(r); }
if (url.includes('action=history')) { historyFetches++; return resp({ body: history }); }
return undefined;
};
}
const running = (phase = 'generating', extra: any = {}): R => ({ body: { ok: true, job_id: JOB, status: 'running', phase, elapsed_s: 1, ...extra } });
const done = (result: any, status = 'done'): R => ({ body: { ok: true, job_id: JOB, status, phase: 'done', elapsed_s: 9, result } });
const OKSTART: R = { body: { ok: true, job_id: JOB, status: 'running', phase: 'queued', created_at: 1 } };
const DONE_OK = done({ ok: true, response: { type: 'ask', question: 'q' } });
/** Start a send, drive fake timers for `ms`, and return its result. */
async function sendFor(ms: number, ...args: any[]): Promise<any> {
vi.useFakeTimers();
let p!: Promise<any>;
await act(async () => { p = (api.send as any)(...args); await vi.advanceTimersByTimeAsync(ms); });
return await p;
}
describe('useSitesmith start+poll', () => {
test('happy path: start then poll to done, reports phases, refreshes history', async () => {
script(OKSTART, [running('captioning'), running('generating'), running('generating'), DONE_OK]);
const phases: string[] = [];
const before = calls.filter((c) => c.url.includes('action=history')).length;
const res = await sendFor(30000, 'hi', 'canvas', undefined, { mockupIds: ['m1'], referenceIds: [3], useAssets: false, onProgress: (p: string) => phases.push(p) });
expect(res).toEqual({ ok: true, response: { type: 'ask', question: 'q' } });
expect(phases).toEqual(['queued', 'captioning', 'generating', 'done']);
expect(starts[0]).toMatchObject({ message: 'hi', mockup_ids: ['m1'], reference_ids: [3], use_assets: false });
expect(calls.filter((c) => c.url.includes('action=history')).length).toBeGreaterThan(before);
expect(calls.some((c) => c.url.includes('action=send'))).toBe(false);
});
test('polls back off from ~1s towards a 5s cap', async () => {
script(OKSTART, [running()]);
vi.useFakeTimers();
let p!: Promise<any>;
await act(async () => { p = api.send('hi', 'c'); await vi.advanceTimersByTimeAsync(900); });
expect(polls).toBe(0);
await act(async () => { await vi.advanceTimersByTimeAsync(200); });
expect(polls).toBe(1);
await act(async () => { await vi.advanceTimersByTimeAsync(60000); });
// 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 p;
});
test('retry_after_ms from the server is honoured', async () => {
script(OKSTART, [running('generating', { retry_after_ms: 4000 }), DONE_OK]);
vi.useFakeTimers();
let p!: Promise<any>;
await act(async () => { p = api.send('hi', 'c'); await vi.advanceTimersByTimeAsync(1100); });
expect(polls).toBe(1);
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
expect(polls).toBe(1);
await act(async () => { await vi.advanceTimersByTimeAsync(1100); });
expect(polls).toBe(2);
expect((await p).ok).toBe(true);
});
test('job error result maps to {ok:false,status,message}', async () => {
script(OKSTART, [done({ ok: false, status: 'AI_ERROR', message: 'The model failed.' }, 'error')]);
const res = await sendFor(3000, 'hi', 'c');
expect(res).toMatchObject({ ok: false, status: 'AI_ERROR', message: 'The model failed.' });
});
test('cancelled job resolves CANCELLED', async () => {
script(OKSTART, [done({ ok: false, status: 'CANCELLED', message: 'Cancelled.' }, 'cancelled')]);
const res = await sendFor(3000, 'hi', 'c');
expect(res).toMatchObject({ ok: false, status: 'CANCELLED' });
});
test('entitlement refusal from start (HTTP 200) passes through', async () => {
script({ body: { ok: false, status: 'CAP_REACHED', message: 'Monthly cap reached.' } }, [DONE_OK]);
const res = await sendFor(10, 'hi', 'c');
expect(res).toMatchObject({ ok: false, status: 'CAP_REACHED', message: 'Monthly cap reached.' });
expect(polls).toBe(0);
});
test('network error on start gives a friendly message and does not throw', async () => {
script({ raw: 'throw' }, [DONE_OK]);
const res = await sendFor(10, 'hi', 'c');
expect(res).toEqual({ ok: false, status: 'NETWORK', message: 'Could not reach the server. Check your connection and try again.' });
});
test('transient poll failures are retried and recovered', async () => {
script(OKSTART, [{ raw: 'throw' }, { status: 502, raw: 'nonjson' }, { raw: 'throw' }, DONE_OK]);
const res = await sendFor(60000, 'hi', 'c');
expect(res.ok).toBe(true);
expect(polls).toBe(4);
});
test('poll failures exhausted -> LOST_CONNECTION mentioning reopening', async () => {
script(OKSTART, [{ raw: 'throw' }]);
const res = await sendFor(120000, 'hi', 'c');
expect(res).toMatchObject({ ok: false, status: 'LOST_CONNECTION', jobId: JOB });
expect(res.message).toMatch(/Lost connection/);
expect(res.message).toMatch(/reopen Sitesmith/);
});
test('overall 6 minute cap -> TIMEOUT (job may still finish)', async () => {
script(OKSTART, [running()]);
const res = await sendFor(7 * 60 * 1000, 'hi', 'c');
expect(res).toMatchObject({ ok: false, status: 'TIMEOUT', jobId: JOB });
expect(res.message).toMatch(/background/);
expect(res.message).toMatch(/history/);
});
test('abort via signal stops polling and resolves ABORTED', async () => {
script(OKSTART, [running()]);
const ac = new AbortController();
vi.useFakeTimers();
let p!: Promise<any>;
await act(async () => { p = api.send('hi', 'c', undefined, { signal: ac.signal }); await vi.advanceTimersByTimeAsync(3000); });
const seen = polls;
expect(seen).toBeGreaterThan(0);
ac.abort();
await act(async () => { await vi.advanceTimersByTimeAsync(60000); });
const res = await p;
expect(res).toMatchObject({ ok: false, status: 'ABORTED' });
expect(polls).toBe(seen);
});
test('409 job_in_progress resolves JOB_IN_PROGRESS with jobId and exposes activeJob; resumeJob finishes it', async () => {
script({ status: 409, body: { ok: false, error: 'job_in_progress', status: 'JOB_IN_PROGRESS', message: 'Already working.', job_id: JOB } }, [DONE_OK]);
const res = await sendFor(10, 'hi', 'c');
expect(res).toMatchObject({ ok: false, status: 'JOB_IN_PROGRESS', message: 'Already working.', jobId: JOB });
expect(api.activeJob?.job_id).toBe(JOB);
const h0 = historyFetches;
vi.useFakeTimers();
let p!: Promise<any>;
await act(async () => { p = api.resumeJob(); await vi.advanceTimersByTimeAsync(2000); });
expect((await p).ok).toBe(true);
expect(historyFetches).toBeGreaterThan(h0);
expect(api.activeJob).toBeNull();
});
test('history.active_job populates activeJob on mount', async () => {
override = (url) => url.includes('action=history')
? resp({ body: { ok: true, messages: [], active_job: { job_id: JOB, status: 'running', phase: 'generating', elapsed_s: 3 } } })
: undefined;
act(() => root.unmount());
root = createRoot(container);
await act(async () => {
root.render(
React.createElement(EditorConfigProvider as any, { config: CFG },
React.createElement(SaveGuardProvider as any, null,
React.createElement(ProjectExtrasProvider as any, null, React.createElement(Probe)))),
);
});
expect(api.activeJob).toMatchObject({ job_id: JOB, phase: 'generating' });
});
test('start unknown_action falls back to legacy send once and is remembered', async () => {
let legacy = 0;
starts = []; polls = 0;
override = (url, init) => {
if (url.includes('action=start')) { starts.push(1); return resp({ status: 400, body: { ok: false, error: 'unknown_action' } }); }
if (url.includes('action=send')) { legacy++; return resp({ body: { ok: true, response: { type: 'ask', question: 'old' } } }); }
return undefined;
};
const r1 = await sendFor(10, 'hi', 'c');
expect(r1).toEqual({ ok: true, response: { type: 'ask', question: 'old' } });
const r2 = await sendFor(10, 'again', 'c');
expect(r2.ok).toBe(true);
expect(legacy).toBe(2);
expect(starts).toHaveLength(1);
});
test('legacy send fetch rejection becomes a friendly result', async () => {
override = (url) => {
if (url.includes('action=start')) return resp({ status: 400, body: { ok: false, error: 'unknown_action' } });
if (url.includes('action=send')) return resp({ raw: 'throw' });
return undefined;
};
const res = await sendFor(10, 'hi', 'c');
expect(res).toMatchObject({ ok: false, status: 'NETWORK', message: 'Could not reach the server. Check your connection and try again.' });
});
test.each([
[403, 'nonjson', /HTTP 403/, 'FORBIDDEN'],
[413, 'nonjson', /HTTP 413/, 'TOO_LARGE'],
[429, 'nonjson', /HTTP 429/, 'RATE_LIMITED'],
[503, 'nonjson', /HTTP 503/, 'SERVER_ERROR'],
[200, 'nonjson', /could not read/, 'BAD_RESPONSE'],
])('start HTTP %s with non-JSON body maps to a distinct message', async (status, raw, re, code) => {
script({ status: status as number, raw: raw as any }, [DONE_OK]);
const res = await sendFor(10, 'hi', 'c');
expect(res.ok).toBe(false);
expect(res.status).toBe(code);
expect(res.message).toMatch(re as RegExp);
});
test('session expiry (401 / csrf) tells the user to refresh and sign in', async () => {
script({ status: 403, body: { ok: false, error: 'csrf', message: 'Bad token' } }, [DONE_OK]);
let res = await sendFor(10, 'hi', 'c');
expect(res.status).toBe('AUTH_REQUIRED');
expect(res.message).toMatch(/Refresh the page and sign in/);
script({ status: 401, raw: 'nonjson' }, [DONE_OK]);
res = await sendFor(10, 'hi', 'c');
expect(res.status).toBe('AUTH_REQUIRED');
});
test('a sensible server message wins over the generic one', async () => {
script({ status: 400, body: { ok: false, error: 'message_too_long', message: 'Message is too long (max 4000 characters).' } }, [DONE_OK]);
const res = await sendFor(10, 'hi', 'c');
expect(res.message).toBe('Message is too long (max 4000 characters).');
});
test('unmount stops polling and does not throw', async () => {
script(OKSTART, [running()]);
vi.useFakeTimers();
let p!: Promise<any>;
await act(async () => { p = api.send('hi', 'c'); await vi.advanceTimersByTimeAsync(3000); });
const seen = polls;
expect(seen).toBeGreaterThan(0);
act(() => root.unmount());
root = createRoot(container); // so afterEach can unmount cleanly
await act(async () => { await vi.advanceTimersByTimeAsync(60000); });
expect((await p).status).toBe('ABORTED');
expect(polls).toBe(seen);
});
test('cancel posts job_id with CSRF header', async () => {
script(OKSTART, [DONE_OK]);
override = (url, init) => url.includes('action=cancel') ? resp({ body: { ok: true, status: 'cancelled' } }) : undefined;
const r = await api.cancel(JOB);
expect(r.ok).toBe(true);
const c = calls.find((x) => x.url.includes('action=cancel'))!;
expect(c.init.headers['X-CSRF-Token']).toBe('t');
expect(JSON.parse(c.init.body)).toEqual({ job_id: JOB });
});
});
describe('useSitesmith capabilities and wrappers', () => {
test('capabilities tolerated missing (old backend 400)', async () => {
override = (url) => url.includes('action=capabilities') ? resp({ status: 400, body: { ok: false, error: 'unknown_action' } }) : undefined;
await act(async () => { await api.refreshEntitlement(); });
expect(api.capabilities).toBeNull();
expect(api.summary).not.toBeNull();
});
test('capabilities populated when the backend provides them', async () => {
const caps = { ok: true, vision: { available: true, source: 'primary', message: '' }, features: { mockups: true, reference_screenshots: true, captions: true, self_review: false }, limits: { max_ref_images: 12, max_site_refs: 20, max_upload_bytes: 8388608, max_attach_images: 4 } };
override = (url) => url.includes('action=capabilities') ? resp({ body: caps }) : undefined;
// capabilities are fetched once; remount to fetch with the override in place
act(() => root.unmount());
root = createRoot(container);
await act(async () => {
root.render(
React.createElement(EditorConfigProvider as any, { config: CFG },
React.createElement(SaveGuardProvider as any, null,
React.createElement(ProjectExtrasProvider as any, null, React.createElement(Probe)))),
);
});
expect(api.capabilities?.features.mockups).toBe(true);
});
test('wrappers send CSRF on POSTs and never throw on network failure', async () => {
override = (url) => resp({ body: { ok: true, references: [], images: [], reference: { id: 1 }, assets: [] } });
expect((await api.listRefs()).ok).toBe(true);
expect((await api.listAssets()).ok).toBe(true);
await api.addReference({ url_label: 'x', what_i_like: 'y' });
await api.updateReference(1, { what_i_like: 'z' });
await api.deleteReference(1);
await api.deleteRefImage('img1');
const posts = calls.filter((c) => c.init?.method === 'POST' && /refs_|ref_image_delete/.test(c.url));
expect(posts).toHaveLength(4);
for (const p of posts) { expect(p.init.headers['X-CSRF-Token']).toBe('t'); expect(JSON.parse(p.init.body).site_id).toBe(7); }
override = () => resp({ raw: 'throw' });
expect(await api.listRefs()).toMatchObject({ ok: false, message: expect.stringMatching(/Could not reach/) });
expect(await api.deleteReference(1)).toMatchObject({ ok: false });
expect(api.refImageUrl('a b')).toBe('/api/sitesmith.php?action=ref_image&site_id=7&id=a%20b');
});
test('multipart upload sends CSRF but no manual Content-Type', async () => {
override = (url) => url.includes('action=ref_image_upload') ? resp({ body: { ok: true, image: { id: 'i1', kind: 'mockup', name: 'a.png', width: 1, height: 1, size: 3 } } }) : undefined;
const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' });
const r = await api.uploadRefImage(file, 'mockup');
expect(r.ok).toBe(true);
const c = calls.find((x) => x.url.includes('ref_image_upload'))!;
expect(c.init.method).toBe('POST');
expect(c.init.body).toBeInstanceOf(FormData);
expect(c.init.body.get('kind')).toBe('mockup');
expect(c.init.body.get('site_id')).toBe('7');
expect(c.init.headers['X-CSRF-Token']).toBe('t');
expect(Object.keys(c.init.headers).map((k) => k.toLowerCase())).not.toContain('content-type');
override = () => resp({ status: 413, raw: 'nonjson' });
expect(await api.uploadRefImage(file, 'mockup')).toMatchObject({ ok: false, message: expect.stringMatching(/413/) });
});
});
+424 -31
View File
@@ -8,67 +8,400 @@ function apiBase(apiUrl: string): string {
return apiUrl.replace(/site-builder\.php$/, 'sitesmith.php');
}
// ---------------------------------------------------------------- types
export interface SitesmithActiveJob {
job_id: string;
status: string;
phase: string;
elapsed_s?: number;
created_at?: number;
}
export interface SitesmithCapabilities {
vision: { available: boolean; source: 'primary' | 'vision_slot' | 'none'; message: string };
features: { mockups: boolean; reference_screenshots: boolean; captions: boolean; self_review: boolean };
limits: { max_ref_images: number; max_site_refs: number; max_upload_bytes: number; max_attach_images: number };
}
export interface SitesmithReference {
id: number;
url_label: string;
what_i_like: string;
screenshot_ids: string[];
created_at?: string;
updated_at?: string;
}
export interface SitesmithRefImage {
id: string;
kind: 'mockup' | 'screenshot';
name: string;
width: number | null;
height: number | null;
size: number;
created_at?: string;
}
export interface SitesmithAsset {
filename: string;
relUrl: string;
url: string;
width: number | null;
height: number | null;
caption: string | null;
alt: string | null;
}
export type SitesmithFailure = { ok: false; message: string; status?: string; error?: string };
export type SitesmithApiResult<T> = ({ ok: true } & T) | SitesmithFailure;
export interface SitesmithSendOpts {
intent?: 'regenerate_section';
mockupIds?: string[];
referenceIds?: number[];
useAssets?: boolean;
onProgress?: (phase: string) => void;
signal?: AbortSignal;
}
/** Extra statuses this hook can produce beyond SitesmithStatus (returned cast as SendResult). */
export type SitesmithHookStatus =
| 'NETWORK' | 'TIMEOUT' | 'ABORTED' | 'JOB_IN_PROGRESS' | 'LOST_CONNECTION' | 'AUTH_REQUIRED'
| 'FORBIDDEN' | 'TOO_LARGE' | 'SERVER_ERROR' | 'BAD_RESPONSE' | 'JOB_NOT_FOUND' | 'CANCELLED' | 'ERROR';
// ------------------------------------------------------------ constants
const POLL_START_MS = 1000;
const POLL_GROWTH = 1.4;
const POLL_CAP_MS = 5000;
const OVERALL_CAP_MS = 6 * 60 * 1000;
const MAX_POLL_FAILURES = 5;
export const MSG_NETWORK = 'Could not reach the server. Check your connection and try again.';
export const MSG_LOST = 'Lost connection while waiting; Sitesmith may still finish -- reopen Sitesmith in a minute.';
export const MSG_TIMEOUT = 'Sitesmith is taking longer than expected. It may still finish in the background -- reopen Sitesmith or check the history in a minute.';
const MSG_AUTH = 'Your session has expired. Refresh the page and sign in again, then retry.';
const AUTH_ERRORS = new Set(['auth_required', 'csrf', 'csrf_invalid', 'csrf_mismatch', 'not_authenticated', 'unauthorized']);
// -------------------------------------------------------------- helpers
type Parsed = { net: true } | { net: false; http: number; body: any | null };
async function request(url: string, init?: RequestInit): Promise<Parsed> {
let r: Response;
try {
r = await fetch(url, init);
} catch {
return { net: true };
}
const http = typeof r.status === 'number' ? r.status : 200;
let body: any | null = null;
try {
const j = await r.json();
body = j && typeof j === 'object' ? j : null;
} catch { body = null; }
return { net: false, http, body };
}
function sensibleMessage(body: any): string | null {
const m = body?.message;
if (typeof m !== 'string') return null;
const t = m.trim();
if (!t || t.length > 600 || /<\/?[a-z][^>]*>/i.test(t)) return null;
return t;
}
/** Map any non-success HTTP/body combination to a specific, plain-language failure. */
export function describeFailure(http: number, body: any | null): { status: SitesmithHookStatus | string; message: string } {
const err = typeof body?.error === 'string' ? body.error : '';
if (http === 401 || AUTH_ERRORS.has(err)) return { status: 'AUTH_REQUIRED', message: MSG_AUTH };
const server = sensibleMessage(body);
const byHttp = (): SitesmithHookStatus =>
http === 413 ? 'TOO_LARGE' : http === 403 ? 'FORBIDDEN' : http === 429 ? 'RATE_LIMITED' as any : http >= 500 ? 'SERVER_ERROR' : 'ERROR';
if (server) {
const st = typeof body?.status === 'string' && body.status ? body.status : byHttp();
return { status: st, message: server };
}
if (!body) {
if (http === 403) return { status: 'FORBIDDEN', message: 'The request was blocked (HTTP 403) by a security filter or proxy before it reached Sitesmith. Try again; if it keeps happening, contact support.' };
if (http === 413) return { status: 'TOO_LARGE', message: 'That request was too large for the server to accept (HTTP 413). Try a shorter message or a smaller image.' };
if (http === 429) return { status: 'RATE_LIMITED', message: 'Too many requests (HTTP 429). Wait a moment and try again.' };
if (http >= 500) return { status: 'SERVER_ERROR', message: `The server had a problem (HTTP ${http}). Try again in a minute.` };
return { status: 'BAD_RESPONSE', message: `The server sent a reply Sitesmith could not read (HTTP ${http}). Try again, and refresh the page if it persists.` };
}
// Parsed JSON refusal without a usable message.
if (http === 413) return { status: 'TOO_LARGE', message: 'That request was too large for the server to accept (HTTP 413). Try a shorter message or a smaller image.' };
if (http === 429) return { status: 'RATE_LIMITED', message: 'Too many requests (HTTP 429). Wait a moment and try again.' };
if (http >= 500) return { status: 'SERVER_ERROR', message: `The server had a problem (HTTP ${http}). Try again in a minute.` };
return { status: typeof body.status === 'string' && body.status ? body.status : byHttp(), message: `Sitesmith could not complete that request (HTTP ${http}${err ? `, ${err}` : ''}).` };
}
const failure = (status: string, message: string, extra?: Record<string, unknown>): SendResult =>
({ ok: false, status, message, ...(extra ?? {}) } as unknown as SendResult);
const isTransientPoll = (p: Parsed): boolean =>
p.net || p.body === null || p.http === 429 || p.http >= 500 || p.http === 408;
// ----------------------------------------------------------------- hook
export function useSitesmith(siteId: number) {
const { whpConfig } = useEditorConfig();
const extras = useProjectExtras();
// Set from the entitlement response once the backend says it understands `profile`.
const profileSupported = useRef(false);
// Becomes false once `start` answered unknown_action (old backend): use blocking `send`.
const startSupported = useRef(true);
const capsTried = useRef(false);
const alive = useRef(true);
const wakers = useRef<Set<() => void>>(new Set());
const resuming = useRef<Promise<SendResult> | null>(null);
const [summary, setSummary] = useState<SitesmithSummary | null>(null);
const [messages, setMessages] = useState<SitesmithMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeJob, setActiveJobState] = useState<SitesmithActiveJob | null>(null);
const [capabilities, setCapabilities] = useState<SitesmithCapabilities | null>(null);
useEffect(() => {
alive.current = true;
const w = wakers.current;
return () => {
alive.current = false;
w.forEach((f) => f());
w.clear();
};
}, []);
const setActiveJob = useCallback((j: SitesmithActiveJob | null) => { if (alive.current) setActiveJobState(j); }, []);
const fetchCapabilities = useCallback(async () => {
if (!whpConfig || capsTried.current) return;
capsTried.current = true;
const p = await request(`${apiBase(whpConfig.apiUrl)}?action=capabilities`, { credentials: 'include' });
if (!alive.current) return;
if (!p.net && p.http < 400 && p.body?.ok && (p.body.features || p.body.limits || p.body.vision)) {
setCapabilities({
vision: p.body.vision, features: p.body.features, limits: p.body.limits,
} as SitesmithCapabilities);
}
}, [whpConfig]);
const refreshEntitlement = useCallback(async () => {
if (!whpConfig) return;
try {
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=entitlement`, { credentials: 'include' });
const j = await r.json();
if (!alive.current) return;
if (j.ok) { setSummary(j.summary); profileSupported.current = j.profile_supported === true; }
else setError(j.error || 'Sitesmith entitlement unavailable');
} catch (e: any) { setError(String(e?.message ?? e)); }
}, [whpConfig]);
} catch (e: any) { if (alive.current) setError(String(e?.message ?? e)); }
void fetchCapabilities();
}, [whpConfig, fetchCapabilities]);
const fetchHistory = useCallback(async () => {
if (!whpConfig) { setLoading(false); return; }
try {
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=history&site_id=${siteId}`, { credentials: 'include' });
const j = await r.json();
if (j.ok) setMessages(j.messages);
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setLoading(false); }
}, [whpConfig, siteId]);
if (!alive.current) return;
if (j.ok) {
setMessages(j.messages);
if (j.active_job !== undefined) setActiveJob(j.active_job && j.active_job.job_id ? j.active_job : null);
}
} catch (e: any) { if (alive.current) setError(String(e?.message ?? e)); }
finally { if (alive.current) setLoading(false); }
}, [whpConfig, siteId, setActiveJob]);
useEffect(() => { void refreshEntitlement(); void fetchHistory(); }, [refreshEntitlement, fetchHistory]);
const send = useCallback(async (
userText: string,
canvasSummary: string,
target?: { node_id: string; display_name: string; tree_json: string },
opts?: { intent?: 'regenerate_section' },
const stopped = (signal?: AbortSignal) => !alive.current || !!signal?.aborted;
/** Timer that vitest fake timers can drive; resolves false when aborted/unmounted. */
const sleep = useCallback((ms: number, signal?: AbortSignal) => new Promise<boolean>((resolve) => {
if (!alive.current || signal?.aborted) { resolve(false); return; }
let done = false;
const finish = (v: boolean) => {
if (done) return;
done = true;
clearTimeout(t);
wakers.current.delete(wake);
signal?.removeEventListener('abort', wake);
resolve(v);
};
const wake = () => finish(false);
const t = setTimeout(() => finish(true), ms);
wakers.current.add(wake);
signal?.addEventListener('abort', wake);
}), []);
const finalize = useCallback(() => {
if (!alive.current) return;
setActiveJob(null);
void fetchHistory();
void refreshEntitlement();
}, [fetchHistory, refreshEntitlement, setActiveJob]);
/** Poll an existing job to completion. Never throws. */
const pollJob = useCallback(async (
jobId: string,
opts: { onProgress?: (phase: string) => void; signal?: AbortSignal } = {},
): Promise<SendResult> => {
if (!whpConfig) return { ok: false, status: 'BLOCKED', message: 'No WHP config' };
setMessages((m) => [...m, { role: 'user', content: userText, response_type: null, created_at: new Date().toISOString() }]);
// Read at send time (extras live in a ref), so a profile saved a moment ago is included.
const stored = normalizeProfile(extras.get<unknown>(PROFILE_EXTRAS_KEY, null));
const profile = stored && isProfileComplete(stored) ? stored : null;
// A backend that predates `profile` ignores the key, so until it says otherwise
// the business line also rides in the message.
const message = profile && !profileSupported.current ? `${profileToPrompt(profile)} ${userText}` : userText;
const body: Record<string, unknown> = { site_id: siteId, message, canvas_summary: canvasSummary };
if (target) body.target = target;
if (profile) body.profile = profile;
if (opts?.intent) body.intent = opts.intent;
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=send`, {
if (!whpConfig) return failure('BLOCKED', 'No WHP config');
const { signal } = opts;
const aborted = () => failure('ABORTED', 'Stopped waiting for Sitesmith. It may still finish in the background.', { jobId });
const t0 = Date.now();
let delay = POLL_START_MS;
let failures = 0;
let lastPhase = '';
const url = `${apiBase(whpConfig.apiUrl)}?action=poll&job_id=${encodeURIComponent(jobId)}`;
for (;;) {
if (!(await sleep(delay, signal))) return aborted();
const p = await request(url, { credentials: 'include', signal });
if (stopped(signal)) return aborted();
if (isTransientPoll(p)) {
failures++;
if (failures >= MAX_POLL_FAILURES) return failure('LOST_CONNECTION', MSG_LOST, { jobId });
const hint = !p.net && typeof p.body?.retry_after_ms === 'number' ? p.body.retry_after_ms : 0;
delay = Math.max(hint, Math.min(POLL_CAP_MS, delay * POLL_GROWTH));
} else {
const { http, body } = p as Extract<Parsed, { net: false }>;
failures = 0;
if (!body || body.ok !== true) {
if (http === 404 || body?.error === 'job_not_found') {
return failure('JOB_NOT_FOUND', sensibleMessage(body) ?? 'That Sitesmith job could not be found; it may have expired. Check the history.', { jobId });
}
const f = describeFailure(http, body);
return failure(f.status, f.message, { jobId });
}
const phase = typeof body.phase === 'string' ? body.phase : '';
if (phase && phase !== lastPhase) {
lastPhase = phase;
try { opts.onProgress?.(phase); } catch { /* UI callback must not break polling */ }
if (alive.current) setActiveJobState((j) => (j && j.job_id === jobId ? { ...j, phase, status: body.status ?? j.status, elapsed_s: body.elapsed_s } : j));
}
if (body.status && body.status !== 'running') {
const res = body.result;
if (res && typeof res === 'object') return res as SendResult;
if (body.status === 'cancelled') return failure('CANCELLED', 'The request was cancelled.', { jobId });
return failure('ERROR', 'Sitesmith finished without a result. Check the history.', { jobId });
}
delay = typeof body.retry_after_ms === 'number' && body.retry_after_ms > 0
? body.retry_after_ms
: Math.min(POLL_CAP_MS, delay * POLL_GROWTH);
}
if (Date.now() - t0 + delay > OVERALL_CAP_MS) {
// One last wait is pointless once the next poll would land past the cap.
return failure('TIMEOUT', MSG_TIMEOUT, { jobId });
}
}
}, [whpConfig, sleep]);
/** Old blocking `send` action (backends without `start`). Never throws. */
const legacySend = useCallback(async (body: Record<string, unknown>): Promise<SendResult> => {
if (!whpConfig) return failure('BLOCKED', 'No WHP config');
const p = await request(`${apiBase(whpConfig.apiUrl)}?action=send`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken },
body: JSON.stringify(body),
});
const j: SendResult = await r.json();
void fetchHistory();
void refreshEntitlement();
return j;
}, [whpConfig, siteId, extras, fetchHistory, refreshEntitlement]);
if (p.net) return failure('NETWORK', MSG_NETWORK);
if (p.body && (p.body.ok === true || (p.body.ok === false && sensibleMessage(p.body) && p.http < 400))) return p.body as SendResult;
const f = describeFailure(p.http, p.body);
return failure(f.status, f.message);
}, [whpConfig]);
const send = useCallback(async (
userText: string,
canvasSummary: string,
target?: { node_id: string; display_name: string; tree_json: string },
opts?: SitesmithSendOpts,
): Promise<SendResult> => {
if (!whpConfig) return failure('BLOCKED', 'No WHP config');
try {
setMessages((m) => [...m, { role: 'user', content: userText, response_type: null, created_at: new Date().toISOString() }]);
// Read at send time (extras live in a ref), so a profile saved a moment ago is included.
const stored = normalizeProfile(extras.get<unknown>(PROFILE_EXTRAS_KEY, null));
const profile = stored && isProfileComplete(stored) ? stored : null;
// A backend that predates `profile` ignores the key, so until it says otherwise
// the business line also rides in the message.
const message = profile && !profileSupported.current ? `${profileToPrompt(profile)} ${userText}` : userText;
const body: Record<string, unknown> = { site_id: siteId, message, canvas_summary: canvasSummary };
if (target) body.target = target;
if (profile) body.profile = profile;
if (opts?.intent) body.intent = opts.intent;
let result: SendResult;
let usedStart = false;
if (startSupported.current) {
const startBody: Record<string, unknown> = { ...body };
if (opts?.mockupIds?.length) startBody.mockup_ids = opts.mockupIds;
if (opts?.referenceIds?.length) startBody.reference_ids = opts.referenceIds;
if (opts?.useAssets !== undefined) startBody.use_assets = opts.useAssets;
const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken },
body: JSON.stringify(startBody),
signal: opts?.signal,
});
if (!alive.current) return failure('ABORTED', 'Stopped waiting for Sitesmith.');
if (opts?.signal?.aborted) return failure('ABORTED', 'Stopped waiting for Sitesmith.');
if (p.net) {
result = failure('NETWORK', MSG_NETWORK);
} else if (p.http === 400 && p.body?.error === 'unknown_action') {
startSupported.current = false;
result = await legacySend(body);
} else if (p.http === 409 || p.body?.error === 'job_in_progress') {
const jobId = typeof p.body?.job_id === 'string' ? p.body.job_id : undefined;
if (jobId) setActiveJob({ job_id: jobId, status: 'running', phase: 'generating' });
result = failure('JOB_IN_PROGRESS', sensibleMessage(p.body) ?? 'Sitesmith is already working on a request for this site. You can wait for it to finish.', { jobId });
} else if (p.body && p.body.ok === true && typeof p.body.job_id === 'string') {
usedStart = true;
const jobId = p.body.job_id as string;
setActiveJob({ job_id: jobId, status: 'running', phase: p.body.phase ?? 'queued', created_at: p.body.created_at });
if (typeof p.body.phase === 'string') { try { opts?.onProgress?.(p.body.phase); } catch { /* ignore */ } }
result = await pollJob(jobId, { onProgress: opts?.onProgress, signal: opts?.signal });
} else if (p.body && p.body.ok === false && p.http < 400 && p.body.status) {
// Entitlement / limit refusal: HTTP 200 {ok:false,status,message}
result = failure(p.body.status, sensibleMessage(p.body) ?? 'Sitesmith is not available right now.');
} else {
const f = describeFailure(p.http, p.body);
result = failure(f.status, f.message);
}
} else {
result = await legacySend(body);
}
void usedStart;
const st = (result as any).status;
// Keep the job resumable when we merely stopped waiting.
if (st !== 'ABORTED' && st !== 'TIMEOUT' && st !== 'LOST_CONNECTION' && st !== 'JOB_IN_PROGRESS') finalize();
else if (st !== 'JOB_IN_PROGRESS' && alive.current) { void fetchHistory(); }
return result;
} catch (e: any) {
return failure('ERROR', `Something went wrong while contacting Sitesmith (${String(e?.message ?? e)}). Please try again.`);
}
}, [whpConfig, siteId, extras, pollJob, legacySend, finalize, fetchHistory, setActiveJob]);
const resumeJob = useCallback((jobId?: string, opts?: { onProgress?: (phase: string) => void; signal?: AbortSignal }): Promise<SendResult> => {
const id = jobId ?? activeJob?.job_id;
if (!id) return Promise.resolve(failure('JOB_NOT_FOUND', 'There is no Sitesmith job to resume.'));
if (resuming.current) return resuming.current;
const pr = (async () => {
try {
const res = await pollJob(id, opts);
const st = (res as any).status;
if (st !== 'ABORTED' && st !== 'TIMEOUT' && st !== 'LOST_CONNECTION') finalize();
return res;
} catch (e: any) {
return failure('ERROR', `Something went wrong while contacting Sitesmith (${String(e?.message ?? e)}).`);
} finally { resuming.current = null; }
})();
resuming.current = pr;
return pr;
}, [activeJob, pollJob, finalize]);
const clearHistory = useCallback(async (): Promise<{ ok: boolean; cleared?: number; error?: string }> => {
if (!whpConfig) return { ok: false, error: 'No WHP config' };
@@ -80,12 +413,72 @@ export function useSitesmith(siteId: number) {
body: JSON.stringify({ site_id: siteId }),
});
const j = await r.json();
if (j.ok) setMessages([]);
if (j.ok && alive.current) setMessages([]);
return j;
} catch (e: any) {
return { ok: false, error: String(e?.message ?? e) };
}
}, [whpConfig, siteId]);
return { summary, messages, loading, error, send, refreshEntitlement, clearHistory };
// ------------------------------------------------ typed API wrappers
const call = useCallback(async <T,>(
action: string,
o: { post?: boolean; query?: string; json?: Record<string, unknown>; form?: FormData } = {},
): Promise<SitesmithApiResult<T>> => {
if (!whpConfig) return { ok: false, message: 'No WHP config' };
const url = `${apiBase(whpConfig.apiUrl)}?action=${action}${o.query ? `&${o.query}` : ''}`;
const init: RequestInit = { credentials: 'include' };
if (o.post) {
init.method = 'POST';
if (o.form) {
// Never set Content-Type: the browser must add the multipart boundary.
init.headers = { 'X-CSRF-Token': whpConfig.csrfToken };
init.body = o.form;
} else {
init.headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken };
init.body = JSON.stringify(o.json ?? {});
}
}
const p = await request(url, init);
if (p.net) return { ok: false, message: MSG_NETWORK, status: 'NETWORK' };
if (p.body && p.body.ok === true && p.http < 400) return p.body as SitesmithApiResult<T>;
const f = describeFailure(p.http, p.body);
return { ok: false, message: f.message, status: String(f.status), error: typeof p.body?.error === 'string' ? p.body.error : undefined };
}, [whpConfig]);
const cancel = useCallback(async (jobId?: string): Promise<SitesmithApiResult<{ status?: string }>> => {
const id = jobId ?? activeJob?.job_id;
if (!id) return { ok: false, message: 'There is no Sitesmith job to cancel.' };
return call<{ status?: string }>('cancel', { post: true, json: { job_id: id } });
}, [call, activeJob]);
const listRefs = useCallback(() =>
call<{ references: SitesmithReference[]; images: SitesmithRefImage[] }>('refs_list', { query: `site_id=${siteId}` }), [call, siteId]);
const addReference = useCallback((r: { url_label: string; what_i_like: string; screenshot_ids?: string[] }) =>
call<{ reference: SitesmithReference }>('refs_add', { post: true, json: { site_id: siteId, ...r } }), [call, siteId]);
const updateReference = useCallback((id: number, patch: { url_label?: string; what_i_like?: string; screenshot_ids?: string[] }) =>
call<{ reference: SitesmithReference }>('refs_update', { post: true, json: { site_id: siteId, id, ...patch } }), [call, siteId]);
const deleteReference = useCallback((id: number) =>
call<{}>('refs_delete', { post: true, json: { site_id: siteId, id } }), [call, siteId]);
const uploadRefImage = useCallback((file: File | Blob, kind: 'mockup' | 'screenshot') => {
const form = new FormData();
form.append('site_id', String(siteId));
form.append('kind', kind);
form.append('file', file);
return call<{ image: SitesmithRefImage }>('ref_image_upload', { post: true, form });
}, [call, siteId]);
const deleteRefImage = useCallback((id: string) =>
call<{}>('ref_image_delete', { post: true, json: { site_id: siteId, id } }), [call, siteId]);
const refImageUrl = useCallback((id: string): string =>
whpConfig ? `${apiBase(whpConfig.apiUrl)}?action=ref_image&site_id=${siteId}&id=${encodeURIComponent(id)}` : '', [whpConfig, siteId]);
const listAssets = useCallback(() =>
call<{ assets: SitesmithAsset[] }>('assets', { query: `site_id=${siteId}` }), [call, siteId]);
return {
summary, messages, loading, error, send, refreshEntitlement, clearHistory,
activeJob, resumeJob, cancel, capabilities,
listRefs, addReference, updateReference, deleteReference,
uploadRefImage, deleteRefImage, refImageUrl, listAssets,
};
}