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:
1 parent
f0b3dd1dba
commit
79159dcb28
5 files changed
+366
-48
No files matched your search
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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). */
|
||||
|
||||
Reference in new issue
Block a user