Sitesmith modal: live job status, cancel, resume, attachments, Sites I like, asset awareness, self-review toggle, design in the request

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 10:39:47 -08:00
1 parent f0b3dd1dba
commit 79159dcb28
5 files changed
+366 -48

No files matched your search

+5 -1
View File
@@ -167,6 +167,7 @@ export function useSitesmith(siteId: number) {
const [error, setError] = useState<string | null>(null);
const [activeJob, setActiveJobState] = useState<SitesmithActiveJob | null>(null);
const [capabilities, setCapabilities] = useState<SitesmithCapabilities | null>(null);
const [capabilitiesFailed, setCapabilitiesFailed] = useState(false);
useEffect(() => {
alive.current = true;
@@ -189,6 +190,9 @@ export function useSitesmith(siteId: number) {
setCapabilities({
vision: p.body.vision, features: p.body.features, limits: p.body.limits,
} as SitesmithCapabilities);
} else {
// Older backend without `capabilities`: image features are unavailable, text generation is unaffected.
setCapabilitiesFailed(true);
}
}, [whpConfig]);
@@ -483,7 +487,7 @@ export function useSitesmith(siteId: number) {
return {
summary, messages, loading, error, send, refreshEntitlement, clearHistory,
activeJob, resumeJob, cancel, capabilities,
activeJob, resumeJob, cancel, capabilities, capabilitiesFailed,
listRefs, addReference, updateReference, deleteReference,
uploadRefImage, deleteRefImage, refImageUrl, listAssets,
};
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect, useState } from 'react';
import { btn } from './Section';
export const PHASE_LABEL: Record<string, string> = {
queued: 'Waiting to start',
preparing: 'Preparing your request',
captioning: 'Looking at your images',
generating: 'Designing your page',
retrying: 'Trying again to get a valid result',
finalizing: 'Finishing up',
done: 'Done',
review: 'Reviewing the finished page',
};
const ORDER = ['queued', 'preparing', 'captioning', 'generating', 'finalizing'];
interface Props {
phase: string;
/** Seconds since the job began, if the server knows (a resumed job); else counted from mount. */
startedAgoS?: number;
onCancel?: () => void;
cancelling?: boolean;
resumed?: boolean;
}
/** Live status line for a running Sitesmith job: phase, elapsed time, steps, and a Cancel button. */
export const JobStatus: React.FC<Props> = ({ phase, startedAgoS = 0, onCancel, cancelling, resumed }) => {
const [t0] = useState(() => Date.now() - startedAgoS * 1000);
const [elapsed, setElapsed] = useState(startedAgoS);
useEffect(() => {
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);
return (
<div role="status" aria-live="polite" data-testid="ss-job-status" data-phase={phase} style={{ padding: '10px 4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 14 }}>
<i className="fa fa-spinner fa-spin" aria-hidden style={{ color: '#8b5cf6' }} />
<span style={{ color: '#e4e4e7', fontWeight: 500 }} data-testid="ss-phase-label">{label}…</span>
<span style={{ color: '#71717a', fontSize: 12 }}>{elapsed}s</span>
<span style={{ flex: 1 }} />
{onCancel && <button type="button" style={btn} disabled={cancelling} onClick={onCancel} data-testid="ss-cancel">Cancel</button>}
</div>
{idx >= 0 && (
<div aria-hidden style={{ display: 'flex', gap: 4, marginTop: 8 }}>
{ORDER.map((p, i) => <span key={p} style={{ flex: 1, height: 3, borderRadius: 2, background: i <= idx ? '#8b5cf6' : '#27272a' }} />)}
</div>
)}
<div style={{ fontSize: 12, color: '#a1a1aa', marginTop: 6 }}>
{resumed ? 'Picked up a request that was already running. ' : ''}
You can close this window: Sitesmith keeps working and the result will be waiting when you come back.
</div>
</div>
);
};
@@ -51,6 +51,8 @@ async function mount(withTarget: boolean, profile: unknown = PROFILE, initialSta
if (profile) h.act(() => extras.set('sitesmithProfile', profile));
await flush();
}
/** start returns at once; the first poll is 1 s later (real timers). */
const flushPoll = () => h.act(async () => { await new Promise((r) => setTimeout(r, 1300)); });
const regenBtn = () => [...document.querySelectorAll('button')].find((b) => b.textContent?.includes('Regenerate this section')) as HTMLButtonElement | undefined;
beforeEach(() => {
@@ -70,7 +72,11 @@ beforeEach(() => {
};
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => {
const json = (v: unknown) => ({ json: async () => v });
if (url.includes('action=send')) { sendBodies.push(JSON.parse(init.body)); return json(sendReply); }
if (url.includes('action=start')) { sendBodies.push(JSON.parse(init.body)); return json({ ok: true, job_id: 'j1', status: 'running', phase: 'queued' }); }
if (url.includes('action=poll')) return json({ ok: true, job_id: 'j1', status: 'done', phase: 'done', result: sendReply });
if (url.includes('action=capabilities')) return json({ ok: true, vision: { available: false, source: 'none', message: 'No vision' }, features: { mockups: false, reference_screenshots: false, captions: false, self_review: false }, limits: {} });
if (url.includes('action=refs_list')) return json({ ok: true, references: [], images: [] });
if (url.includes('action=assets')) return json({ ok: true, assets: [] });
if (url.includes('action=entitlement')) return json({ ok: true, summary: { enabled: true, status: entitlementStatus, monthly_used: 0, monthly_cap: 10, bonus_credits: 0 } });
return json({ ok: true, messages: [] });
}));
@@ -82,7 +88,7 @@ describe('SitesmithModal regenerate', () => {
await mount(true);
expect(h.container.textContent).toContain('Original heading');
h.act(() => { regenBtn()!.click(); });
await flush();
await flushPoll();
expect(sendBodies).toHaveLength(1);
expect(sendBodies[0].intent).toBe('regenerate_section');
@@ -108,7 +114,7 @@ describe('SitesmithModal regenerate', () => {
sendReply = { ok: true, response: { type: 'patch', message: 'x', ops: [] } };
await mount(true);
h.act(() => { regenBtn()!.click(); });
await flush();
await flushPoll();
expect(h.container.textContent).toContain('Original heading');
expect(document.querySelector('[role="alert"]')?.textContent).toMatch(/did not return/i);
expect(onClose).not.toHaveBeenCalled();
+293 -39
View File
@@ -1,5 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { usePages } from '../../state/PageContext';
import { useNotices } from '../../state/NoticeContext';
import { useSitesmithModal } from '../../state/SitesmithContext';
import {
@@ -9,14 +11,26 @@ import {
import { applyRegeneratedSection, isProfileComplete, profileSummary } from '../../utils/sitesmith-profile';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { useSitesmith } from '../../hooks/useSitesmith';
import { useApplyAiResponse } from '../../utils/apply-ai-response';
import { useApplyAiResponse, repairForCanvas } from '../../utils/apply-ai-response';
import { summarizeAdjustments, type ContrastAdjustment } from '../../utils/contrast-repair';
import { buildDesignPayload } from '../../utils/sitesmith-design';
import { composeReviewHtml } from '../../utils/compose-review-html';
import { captureHtml, resolveAssetUrls } from '../../utils/self-review-capture';
import { runSelfReview } from './selfReview';
import { useSitesmithRefs } from './useSitesmithRefs';
import { Section, warnBox, errBox as errBoxStyle, note as noteStyle } from './Section';
import { AttachmentTray } from './AttachmentTray';
import { SitesILikePanel } from './SitesILikePanel';
import { AssetAwareness } from './AssetAwareness';
import { JobStatus } from './JobStatus';
import type { SitesmithAsset } from '../../hooks/useSitesmith';
import type { SendResult } from '../../types/sitesmith';
import { summarizeCanvas } from '../../utils/canvas-summary';
import { SitesmithTarget } from '../../state/SitesmithContext';
import { UpgradeBanner } from './UpgradeBanner';
import { ScopeConfirmDialog } from './ScopeConfirmDialog';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { WorkingIndicator } from './WorkingIndicator';
import { SitesmithResponse } from '../../types/sitesmith';
import { Modal } from '../../ui/Modal';
@@ -34,10 +48,35 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const { notify } = useNotices();
const { open: reopen } = useSitesmithModal();
const profile = useSitesmithProfile();
const { summary, messages, send, loading, clearHistory, error: entitlementError } = useSitesmith(siteId);
const ss = useSitesmith(siteId);
const { summary, messages, send, loading, clearHistory, error: entitlementError, activeJob, resumeJob, cancel, capabilities, capabilitiesFailed } = ss;
const apply = useApplyAiResponse();
const { design } = useSiteDesign();
const pageCtx = usePages();
const [busy, setBusy] = useState(false);
const [pendingReplace, setPendingReplace] = useState<SitesmithResponse | null>(null);
const [phase, setPhase] = useState('queued');
const [cancelling, setCancelling] = useState(false);
const [resumed, setResumed] = useState(false);
const [reviewing, setReviewing] = useState(false);
const [notes, setNotes] = useState<string[]>([]);
const [adjust, setAdjust] = useState<{ list: ContrastAdjustment[]; notChecked: number } | null>(null);
const [reviewNote, setReviewNote] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [pendingReplace, setPendingReplace] = useState<{ resp: SitesmithResponse; review: boolean } | null>(null);
const [open, setOpen] = useState<Record<string, boolean>>({});
const [selMockups, setSelMockups] = useState<string[]>([]);
const [selRefs, setSelRefs] = useState<number[]>([]);
const [assets, setAssets] = useState<SitesmithAsset[]>([]);
const [assetsLoaded, setAssetsLoaded] = useState(false);
const [assetsError, setAssetsError] = useState<string | null>(null);
const [excluded, setExcluded] = useState<Set<string>>(new Set());
const [selfReviewOn, setSelfReviewOn] = useState<boolean>(() => {
try { return window.localStorage.getItem('sitesmith.selfReview') !== 'off'; } catch { return true; }
});
const setSelfReview = (v: boolean) => {
setSelfReviewOn(v);
try { window.localStorage.setItem('sitesmith.selfReview', v ? 'on' : 'off'); } catch { /* storage unavailable */ }
};
const [error, setError] = useState<string | null>(null);
const [confirmClearChat, setConfirmClearChat] = useState(false);
const clearChatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -58,7 +97,8 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
&& (query.node('ROOT').get().data.nodes ?? []).length === 0,
);
const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
const canChat = !!summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
const running = busy || !!activeJob;
const wizardFired = useRef(false);
useEffect(() => {
@@ -73,40 +113,178 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const panel: React.CSSProperties = { background: '#0f0f17', border: '1px solid #27272a', borderRadius: 12, width: 'min(720px, 90vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' };
const header: React.CSSProperties = { display: 'flex', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid #27272a', gap: 8 };
const body: React.CSSProperties = { flex: 1, padding: '14px 18px', overflowY: 'auto', display: 'flex', flexDirection: 'column' };
const footer: React.CSSProperties = { padding: '12px 18px', borderTop: '1px solid #27272a' };
const footer: React.CSSProperties = { padding: '12px 18px', borderTop: '1px solid #27272a', maxHeight: '48vh', overflowY: 'auto' };
const closeBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: 'none', fontSize: 18, cursor: 'pointer' };
const clearBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer', marginRight: 8 };
const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '8px 12px', borderRadius: 6, marginBottom: 10, fontSize: 13 };
const handleSend = async (text: string) => {
setBusy(true); setError(null);
try {
const canvas = summarizeCanvas(query.getSerializedNodes());
const result = await send(text, canvas, targetPayload);
if (!result.ok) { setError(result.message || 'Failed'); return; }
if (result.response.type === 'replace' && result.response.scope === 'site') {
setPendingReplace(result.response);
return;
}
const applied = await apply(result.response, target?.nodeId);
if (!applied.ok) setError(applied.message || 'Apply failed');
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setBusy(false); }
};
const targetPayload = target
? { node_id: target.nodeId, display_name: target.displayName, tree_json: target.treeJson }
: undefined;
// ---- what the request carries
const visionOk = !!capabilities?.vision?.available;
const features = capabilities?.features;
const limits = capabilities?.limits;
const capsUnknown = !capabilities && !capabilitiesFailed;
const visionReason: string | null = capsUnknown
? 'Checking whether image features are available…'
: visionOk ? null
: (capabilities?.vision?.message
|| 'Image features are not available on this server, so Sitesmith cannot look at pictures yet. Describing what you want in words works as usual.');
const imagesOn = visionOk && features?.mockups !== false;
const refsApi = useSitesmithRefs(ss, !!cfg.whpConfig && !!canChat, limits?.max_upload_bytes);
const refsOn = !!cfg.whpConfig && !!canChat;
const selectedRefObjs = refsApi.references.filter((r) => selRefs.includes(r.id));
const refImageCount = visionOk ? selectedRefObjs.reduce((n, r) => n + r.screenshot_ids.length, 0) : 0;
const allAssetNames = assets.map((a) => a.filename);
const includedAssets = new Set(allAssetNames.filter((f) => !excluded.has(f)));
const useAssetsFlag = !(assetsLoaded && assets.length > 0 && includedAssets.size === 0);
const assetFilter = assetsLoaded && excluded.size > 0 && includedAssets.size > 0 ? [...includedAssets] : undefined;
const loadAssets = async () => {
const r = await ss.listAssets();
if (r.ok) { setAssets(r.assets ?? []); setAssetsError(null); } else setAssetsError(r.message);
setAssetsLoaded(true);
};
useEffect(() => { if (cfg.whpConfig && canChat && !assetsLoaded) void loadAssets(); }); // eslint-disable-line react-hooks/exhaustive-deps
/** A browser-level failure text ("NetworkError when attempting to fetch resource") never reaches the user. */
const friendly = (m: string | undefined | null): string => {
const t = (m ?? '').trim();
if (!t) return 'Something went wrong. Please try again.';
if (/NetworkError|Failed to fetch|Load failed|network request failed|AbortError/i.test(t)) return 'Could not reach the server. Check your connection and try again.';
return t;
};
const sendOpts = () => ({
design: buildDesignPayload(design),
mockupIds: imagesOn ? selMockups : [],
referenceIds: selRefs,
useAssets: useAssetsFlag,
assetFilenames: assetFilter,
});
// ---- self-review (one round, after a normal send has been applied)
const assetUrlMap = () => {
const urls: Record<string, string> = {};
for (const a of assets) {
try {
const u = new URL(a.url, window.location.href);
if (u.origin === window.location.origin) urls[a.relUrl || `assets/${a.filename}`] = u.pathname + u.search;
} catch { /* skip */ }
}
return urls;
};
const doSelfReview = async () => {
setReviewing(true); setPhase('review'); setReviewNote(null);
const urls = assetUrlMap();
try {
const out = await runSelfReview({
composeHtml: () => composeReviewHtml({
serialized: query.serialize(),
headerState: pageCtx.headerPage.craftState,
footerState: pageCtx.footerPage.craftState,
title: pageCtx.pages.find((p) => p.id === pageCtx.activePageId)?.name ?? cfg.whpConfig?.siteName ?? 'Page',
design,
}),
assetUrls: urls,
allowUrls: Object.values(urls),
resolveAssetUrls,
capture: (html, o) => captureHtml(html, o),
upload: async (file) => {
const r = await ss.uploadRefImage(file, 'mockup');
return r.ok ? { ok: true, id: r.image.id } : { ok: false, message: r.message };
},
review: (msg, imageId) => send(msg, summarizeCanvas(query.getSerializedNodes()), undefined, {
design: buildDesignPayload(design), mockupIds: [imageId], useAssets: false,
onProgress: setPhase, signal: abortRef.current?.signal,
}),
applyResult: async (res) => {
if (!res.ok) return { ok: false, message: res.message };
const a = await apply(res.response);
return { ok: a.ok, message: a.message, summary: a.adjustments?.length ? summarizeAdjustments(a.adjustments, a.notChecked) : undefined };
},
removeImage: (id) => ss.deleteRefImage(id),
});
setReviewNote(out.note);
} catch {
setReviewNote('Self-review skipped: something went wrong. Your page was built as normal.');
} finally { setReviewing(false); }
};
// ---- one pipeline for send, resume and confirm
const applyResponse = async (resp: SitesmithResponse, review: boolean) => {
const applied = await apply(resp, target?.nodeId);
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) {
await doSelfReview();
}
};
const handleResult = async (res: SendResult, review: boolean) => {
if (!res.ok) {
const st = String((res as { status?: string }).status ?? '');
if (st === 'ABORTED') return;
if (st === 'CANCELLED') { setNotes(['Cancelled. Nothing was changed on your page.']); return; }
setError(friendly(res.message));
return;
}
setNotes(res.notes ?? []);
if (res.response.type === 'replace' && res.response.scope === 'site') {
setPendingReplace({ resp: res.response, review });
return;
}
await applyResponse(res.response, review);
};
const runJob = async (start: (o: { onProgress: (p: string) => void; signal: AbortSignal }) => Promise<SendResult>, review: boolean) => {
setBusy(true); setError(null); setNotes([]); setAdjust(null); setReviewNote(null); setPhase('queued'); setCancelling(false);
const ac = new AbortController();
abortRef.current = ac;
try {
const res = await start({ onProgress: setPhase, signal: ac.signal });
await handleResult(res, review);
} catch (e: any) { setError(friendly(String(e?.message ?? e))); }
finally { setBusy(false); setCancelling(false); abortRef.current = null; }
};
const handleSend = async (text: string) => {
const canvas = summarizeCanvas(query.getSerializedNodes());
const opts = sendOpts();
await runJob((o) => send(text, canvas, targetPayload, { ...opts, ...o }), true);
setSelMockups([]); // an attached picture is for that request; sites I like stay selected
};
const handleCancel = async () => {
setCancelling(true);
const r = await cancel();
if (!r.ok) { setCancelling(false); setError(friendly(r.message)); }
// The poll in flight returns CANCELLED; nothing else to do here.
};
// Resume a job that was running when the modal (or page) was last closed.
const resumedRef = useRef(false);
useEffect(() => {
if (!activeJob) { resumedRef.current = false; return; }
if (resumedRef.current || busy || !canChat) return;
resumedRef.current = true;
setResumed(true);
setPhase(activeJob.phase || 'generating');
void runJob((o) => resumeJob(activeJob.job_id, o), true);
}); // eslint-disable-line react-hooks/exhaustive-deps
/** One click: rewrite the targeted section for this business, as a single undo step. */
const regenerate = async () => {
if (!target || busy) return;
setBusy(true); setError(null);
try {
const canvas = summarizeCanvas(query.getSerializedNodes());
const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section' });
if (!result.ok) { setError(result.message || 'Failed'); return; }
const r = applyRegeneratedSection(actions, query, result.response, target.nodeId);
const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section', design: buildDesignPayload(design), useAssets: useAssetsFlag, assetFilenames: assetFilter });
if (!result.ok) { setError(friendly(result.message)); return; }
const r = applyRegeneratedSection(actions, query, repairForCanvas(result.response, design, query).response, target.nodeId);
if (!r.ok) { setError(r.message || 'Apply failed'); return; }
notify({
kind: 'success',
@@ -116,11 +294,11 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
timeoutMs: 8000,
});
onClose();
} catch (e: any) { setError(String(e?.message ?? e)); }
} catch (e: any) { setError(friendly(String(e?.message ?? e))); }
finally { setBusy(false); }
};
const regenerateClick = () => {
const regenerateClick = () => {
if (isProfileComplete(profile)) void regenerate();
else openProfileWizard({ onDone: () => void regenerate() });
};
@@ -133,9 +311,10 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const confirmReplace = async () => {
if (!pendingReplace) return;
const r = await apply(pendingReplace);
const { resp, review } = pendingReplace;
setPendingReplace(null);
if (!r.ok) setError(r.message || 'Apply failed');
setBusy(true);
try { await applyResponse(resp, review); } finally { setBusy(false); }
};
const cancelClearChat = () => {
@@ -233,26 +412,101 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
<i className="fa fa-refresh" aria-hidden /> Regenerate this section
</button>
)}
{error && <div role="alert" style={errBox}>{error}</div>}
{error && <div role="alert" style={errBox} data-testid="ss-error">{error}</div>}
{notes.map((n, i) => <div key={i} role="note" style={{ ...warnBox, marginBottom: 8 }} data-testid="ss-note">{n}</div>)}
{adjust && adjust.list.length > 0 && (
<details style={{ ...noteStyle, border: '1px solid #27272a', borderRadius: 6, padding: '6px 10px', marginBottom: 8 }} data-testid="ss-adjust">
<summary style={{ cursor: 'pointer', color: '#d4d4d8' }}>{summarizeAdjustments(adjust.list, adjust.notChecked)}</summary>
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{adjust.list.map((a, i) => (
<li key={i}>
{a.kind === 'text-colour'
? `${a.node}${a.text ? ` "${a.text}"` : ''}: text ${a.from} became ${a.to} (contrast ${a.ratioBefore}:1 to ${a.ratioAfter}:1)`
: `${a.node}: ${a.to} (was ${a.from})`}
</li>
))}
</ul>
</details>
)}
{reviewNote && <div role="note" style={{ ...noteStyle, marginBottom: 8 }} data-testid="ss-review-note">{reviewNote}</div>}
{loading
? <div style={{ color: '#71717a', textAlign: 'center', padding: 30 }}>Loading…</div>
: <MessageList messages={messages} />}
</div>
<div style={footer}>
{busy ? (
<WorkingIndicator />
{canChat && cfg.whpConfig && (
<div style={{ marginBottom: 8 }} data-testid="ss-context">
<Section id="mockups" title="Mockup or reference picture" icon="fa-picture-o" badge={selMockups.length ? `${selMockups.length} attached` : undefined} open={!!open.mockups} onToggle={() => setOpen((o) => ({ ...o, mockups: !o.mockups }))}>
<AttachmentTray
images={refsApi.images}
selected={selMockups}
onSelectedChange={setSelMockups}
maxAttach={limits?.max_attach_images ?? 4}
referenceImageCount={refImageCount}
imageUrl={ss.refImageUrl}
onUpload={async (f) => { const r = await refsApi.uploadImage(f, 'mockup'); return r; }}
onRemove={refsApi.removeImage}
unavailableReason={imagesOn ? null : (visionReason ?? 'Image features are not available.')}
disabled={running}
/>
</Section>
<Section id="sites" title="Sites I like" icon="fa-heart-o" badge={selRefs.length ? `${selRefs.length} selected` : refsApi.references.length ? `${refsApi.references.length} saved` : undefined} open={!!open.sites} onToggle={() => setOpen((o) => ({ ...o, sites: !o.sites }))}>
<SitesILikePanel
references={refsApi.references}
images={refsApi.images}
selected={selRefs}
onSelectedChange={setSelRefs}
imageUrl={ss.refImageUrl}
loadError={refsApi.loadError}
addRef={refsApi.addRef}
updateRef={(id, u, l, sh) => refsApi.updateRef(id, u, l, sh)}
deleteRef={refsApi.deleteRef}
uploadShot={(f) => refsApi.uploadImage(f, 'screenshot')}
removeImage={refsApi.removeImage}
maxRefs={limits?.max_site_refs ?? 20}
imagesUnavailableReason={imagesOn ? null : (visionReason ?? 'Image features are not available.')}
disabled={running}
/>
</Section>
<Section id="assets" title="Your uploaded images" icon="fa-image" badge={assetsLoaded && assets.length ? `${includedAssets.size} of ${assets.length} used` : undefined} open={!!open.assets} onToggle={() => setOpen((o) => ({ ...o, assets: !o.assets }))}>
<AssetAwareness
assets={assets}
loaded={assetsLoaded}
error={assetsError}
included={includedAssets}
onToggle={(f) => setExcluded((x) => { const n = new Set(x); if (n.has(f)) n.delete(f); else n.add(f); return n; })}
onAll={(on) => setExcluded(on ? new Set() : new Set(allAssetNames))}
thumbUrl={(a) => a.url}
visionAvailable={visionOk}
disabled={running}
/>
</Section>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, fontSize: 13, color: '#d4d4d8', padding: '2px 4px' }} data-testid="ss-selfreview-toggle">
<input type="checkbox" checked={selfReviewOn && visionOk} disabled={!visionOk || running} onChange={(e) => setSelfReview(e.target.checked)} style={{ marginTop: 3 }} />
<span>
Have Sitesmith look at the finished page and fix problems (one review, makes one more request)
{!visionOk && <span style={{ display: 'block', color: '#a1a1aa', fontSize: 12 }}>Needs image features, which are not available here.</span>}
</span>
</label>
</div>
)}
{running ? (
<JobStatus phase={phase} startedAgoS={resumed ? activeJob?.elapsed_s : 0} resumed={resumed} cancelling={cancelling} onCancel={reviewing ? undefined : handleCancel} />
) : (
<ChatInput
disabled={!canChat}
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'}
onSend={handleSend}
initialValue={prefill ?? undefined}
/>
<>
{!canChat && summary && <div style={{ ...warnBox, marginBottom: 8 }} data-testid="ss-disabled-note">Sitesmith is not available on your plan right now, so the message box is turned off. See the note above for what to do.</div>}
<ChatInput
disabled={!canChat}
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'}
onSend={handleSend}
initialValue={prefill ?? undefined}
/>
</>
)}
</div>
<ScopeConfirmDialog
open={!!pendingReplace}
pendingMessage={pendingReplace && 'message' in pendingReplace ? (pendingReplace as any).message : undefined}
pendingMessage={pendingReplace && 'message' in pendingReplace.resp ? (pendingReplace.resp as any).message : undefined}
onConfirm={confirmReplace}
onCancel={() => setPendingReplace(null)}
/>
+3 -5
View File
@@ -3,11 +3,9 @@ import type { CaptureResult } from '../../utils/self-review-capture';
/** Instruction for the one follow-up job. The render rides in the mockup slot, so say plainly what it is. */
export const REVIEW_MESSAGE =
'The attached image is a screenshot of the page as it is built right now. It is NOT a mockup to copy. ' +
'Review it like a careful designer: look for text that is hard to read, cramped or uneven spacing, misaligned or ' +
'broken layout, images that are cropped badly, missing icons, and sections that look generic. ' +
'Fix only real problems with a small patch; if the page already looks right, answer with a short "ask"-free patch with no ops and say it looks good. ' +
'Do not redesign the page and do not change the wording.';
'Review the attached screenshot of my page as it is built now (it is a render of the current page, not a mockup to copy). ' +
'Fix only real problems with a small patch: hard-to-read text, cramped or uneven spacing, broken or misaligned layout, badly cropped images, missing icons. ' +
'If it already looks right, reply with a patch that has no ops and say so. Do not redesign the page or change the wording.';
export interface SelfReviewDeps {
/** Published-style HTML of the current page (untrusted; the capture sanitises it). */