const { useState, useMemo, useEffect, useRef } = React;
function App() {
const [tab, setTab] = useState('research');
// search rows (manual keyword / competitor search)
const [rows, setRows] = useState([
{ id: 1, query: '', competitorId: null, count: 20,
filters: { minLikes: '', minDailyLikes: '', orderBy: '', bsPlatform: '', bsMinViews: '', bsMinLikes: '', bsMinEngagement: '', bsSort: '', foreplayUrl: '', brandsearchUrl: '' },
platforms: { foreplay: true, adspy: false, brandsearch: true, metaads: false } },
]);
const [competitors, setCompetitors] = useState([]);
const [directions, setDirections] = useState([]);
const directionsRef = useRef([]);
// pipeline stages
const [researchAds, setResearchAds] = useState([]);
const [filteredAds, setFilteredAds] = useState([]);
const [remodeledAds, setRemodeledAds] = useState([]);
const [autoRemodelAds, setAutoRemodelAds] = useState([]);
const [remodelScripts, setRemodelScripts] = useState({}); // { [adId]: [{tag, body}] | 'pending' | 'error' }
const remodelPollRef = useRef(null);
const [convertedAds, setConvertedAds] = useState([]);
const [autoresearchActive, setAutoresearchActive] = useState(false); // true if last Research view should show winners-only badge
const [selResearch, setSelResearch] = useState(new Set());
const [selFilter, setSelFilter] = useState(new Set());
const [selRemodel, setSelRemodel] = useState(new Set());
// autoresearch controls
const [arSelected, setArSelected] = useState(new Set());
const [arTarget, setArTarget] = useState('1');
const [arNumDirections, setArNumDirections] = useState('5'); // cap on directions when no explicit picks
const [arPickedDirections, setArPickedDirections] = useState(new Set()); // empty = take first arNumDirections
const [arRunning, setArRunning] = useState(false);
const [arProgress, setArProgress] = useState(null); // { totalScored, winners, directionsSatisfied, directionsTotal, crawlState }
const [arSources, setArSources] = useState({ foreplay: true, adspy: false, brandsearch: true, metaads: false });
// Platform-level credit balances surfaced in the top bar. Foreplay is live;
// the other three are visual placeholders until their backends land.
const [platformCredits, setPlatformCredits] = useState({
foreplay: null, adspy: null, brandsearch: null, metaads: null,
});
const arPollRef = useRef(null);
// ---- Auto-Remodel state -----------------------------------------------
// The orchestrator's directions picker is shared with manual autoresearch
// — both pull from the same non-competitor pool. We keep it in App so the
// panel + the autoresearch panel can stay independent components without
// losing user picks when one re-renders.
const [arOrchPickedDirections, setArOrchPickedDirections] = useState(new Set());
const toggleArOrchPickedDirection = (id) =>
setArOrchPickedDirections(s => {
const n = new Set(s);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
const clearArOrchPickedDirections = () => setArOrchPickedDirections(new Set());
const [arRunId, setArRunId] = useState(null);
const [arStage, setArStage] = useState(null);
const [arLastEvent, setArLastEvent] = useState(null);
const [arPickedIds, setArPickedIds] = useState([]);
// Refs that mirror the orchestrator state — the SSE callback below is
// installed once via useEffect([]), so any state value referenced inside
// its closure is captured at mount-time and goes stale. Refs let the
// handler read the *current* value when an event arrives.
const arRunIdRef = useRef(null);
const arStageRef = useRef(null);
const arPickedIdsRef = useRef([]);
const arTargetAdsRef = useRef(0);
const arAcceptedAdIdsRef = useRef(new Set());
useEffect(() => { arRunIdRef.current = arRunId; }, [arRunId]);
useEffect(() => { arStageRef.current = arStage; }, [arStage]);
useEffect(() => { arPickedIdsRef.current = arPickedIds; }, [arPickedIds]);
useEffect(() => { arTargetAdsRef.current = arTargetAds; }, [arTargetAds]);
// Live progress counters — fed by SSE so the panel meta line + progress
// bar tick exactly like the autoresearch panel does. ``arNVariants`` is
// captured at Run time so we know the variants_total denominator.
const [arDirectionsDone, setArDirectionsDone] = useState(0);
const [arDirectionsTotal, setArDirectionsTotal] = useState(0);
const [arAdsFound, setArAdsFound] = useState(0);
const [arTargetAds, setArTargetAds] = useState(0);
const [arVariantsDone, setArVariantsDone] = useState(0);
const [arNVariants, setArNVariants] = useState(1);
const [arVariantsTotalOverride, setArVariantsTotalOverride] = useState(null);
const arRunningOrch = !!arRunId && arStage !== 'done' && arStage !== 'failed';
// Platform toggles for the Auto-Remodel panel. Kept separate from
// ``arSources`` (which the manual autoresearch panel owns) so the user
// can configure each panel independently.
const [arOrchSources, setArOrchSources] = useState({
foreplay: true, adspy: false, brandsearch: true, metaads: false,
});
const toggleArOrchSource = (key) => setArOrchSources(s => ({ ...s, [key]: !s[key] }));
const selectAllArOrchSources = () => {
const LIVE = new Set(['foreplay', 'adspy', 'brandsearch']);
setArOrchSources(s => {
const next = { ...s };
const anyLiveOff = [...LIVE].some(k => !s[k]);
[...LIVE].forEach(k => { next[k] = anyLiveOff ? true : false; });
return next;
});
};
const toggleArPickedDirection = (id) =>
setArPickedDirections(s => {
const n = new Set(s);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
const clearArPickedDirections = () => setArPickedDirections(new Set());
const toggleArSource = (key) => setArSources(s => ({ ...s, [key]: !s[key] }));
const selectAllArSources = () => {
const LIVE = new Set(['foreplay', 'adspy', 'brandsearch']);
setArSources(s => {
const next = { ...s };
const anyLiveOff = [...LIVE].some(k => !s[k]);
[...LIVE].forEach(k => { next[k] = anyLiveOff ? true : false; });
return next;
});
};
const [tweaks, setTweaks] = useState({ theme: 'light', density: 'comfortable', cardStyle: 'standard', accent: 'teal' });
const [tweaksOpen, setTweaksOpen] = useState(false);
const [toast, setToast] = useState(null);
const [searching, setSearching] = useState(false);
const [filterBusy, setFilterBusy] = useState(false);
const [remodelBusy, setRemodelBusy] = useState(false);
const [expandedAd, setExpandedAd] = useState(null);
const AUTO_REMODEL_RESTORE_LIMIT_KEY = 'resilia.autoRemodel.lastTargetAds';
// edit-mode host protocol
useEffect(() => {
const onMsg = (e) => {
if (e.data?.type === '__activate_edit_mode') setTweaksOpen(true);
if (e.data?.type === '__deactivate_edit_mode') setTweaksOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
useEffect(() => { document.documentElement.setAttribute('data-theme', tweaks.theme); }, [tweaks.theme]);
useEffect(() => { document.documentElement.setAttribute('data-accent', tweaks.accent); }, [tweaks.accent]);
useEffect(() => { directionsRef.current = directions; }, [directions]);
// Load competitors + directions + any cached session on mount
useEffect(() => {
(async () => {
let loadedDirections = [];
try {
const [comps, dirs] = await Promise.all([
RESILIA_API.listCompetitors(),
RESILIA_API.listDirections(),
]);
loadedDirections = dirs || [];
directionsRef.current = loadedDirections;
setCompetitors(comps);
setDirections(loadedDirections);
} catch (e) {
showToast('Failed to load directions: ' + e.message, 'Error');
}
// Prime the Foreplay credit badge in the top bar. Other platforms stay
// null until their backends land and wire a matching summary field.
try {
const s = await RESILIA_API.summary();
if (s && typeof s.credits_remaining === 'number') {
setPlatformCredits(pc => ({ ...pc, foreplay: s.credits_remaining }));
}
} catch (e) {
console.warn('initial summary fetch failed', e);
}
// Restore last search from Postgres cache
try {
const cached = await RESILIA_API.getCache('last_search_results');
const cachedAds = cached && cached.payload && Array.isArray(cached.payload.ads) ? cached.payload.ads : [];
if (cachedAds.length) {
const ads = cachedAds.map(a => ({ ...RESILIA_ADAPT.adaptSearchAd(a, 'foreplay'), source: 'search' }));
setResearchAds(prev => [...prev.filter(a => a.source === 'autoresearch'), ...ads]);
}
} catch (e) {
console.warn('restore search cache failed', e);
}
// Restore last autoresearch winners
try {
const perDir = parseInt(arTarget, 10) || 1;
const winners = await RESILIA_API.autoresearchWinners(200, perDir);
if (Array.isArray(winners) && winners.length) {
const dirMap = directionMetaMap(loadedDirections);
const ads = [];
const seen = new Set();
for (const row of winners) {
if (seen.has(row.ad_id)) continue;
seen.add(row.ad_id);
const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row);
attachDirectionMeta(adapted, dirMap);
ads.push({ ...adapted, source: 'autoresearch' });
}
setResearchAds(prev => [...prev.filter(a => a.source !== 'autoresearch'), ...ads]);
setAutoresearchActive(true);
}
} catch (e) {
console.warn('restore autoresearch cache failed', e);
}
try {
const auto = await RESILIA_API.getAutoRemodelState();
let restoredAutoRemodel = false;
if (auto && auto.status === 'ok' && auto.state) {
syncAutoRemodelState(auto.state);
if (Array.isArray(auto.state.picked_ad_ids) && auto.state.picked_ad_ids.length) {
const liveLimit = Math.max(
1,
Math.min(20, parseInt(auto.state.target_ads ?? auto.state.count ?? auto.state.picked_ad_ids.length, 10) || 5),
);
const pickedIds = auto.state.picked_ad_ids.slice(0, liveLimit);
const ads = await hydratePickedAds(pickedIds, loadedDirections);
if (ads.length) {
setAutoRemodelAds(ads);
setRemodelScripts(prev => {
const next = { ...prev };
ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; });
return next;
});
pollRemodelJobs(pickedIds);
setTab('autoRemodel');
restoredAutoRemodel = true;
}
}
}
if (!restoredAutoRemodel) {
const jobs = await RESILIA_API.listRemodelJobs({});
const autoJobs = Array.isArray(jobs)
? jobs.filter(j => j.source === 'auto_remodel' && j.ad_id)
: [];
const restoredIds = [];
const seenAutoAds = new Set();
const readyAutoJobs = autoJobs.filter(j => j.status === 'done' && j.response_text);
const pendingAutoJobs = autoJobs.filter(j => !(j.status === 'done' && j.response_text));
[
...readyAutoJobs.slice().sort((a, b) => Number(b.id || 0) - Number(a.id || 0)),
...pendingAutoJobs.slice().sort((a, b) => Number(b.id || 0) - Number(a.id || 0)),
]
.forEach(j => {
if (seenAutoAds.has(j.ad_id)) return;
seenAutoAds.add(j.ad_id);
restoredIds.push(j.ad_id);
});
if (restoredIds.length) {
const restoreLimit = Math.max(
1,
Math.min(20, parseInt(localStorage.getItem(AUTO_REMODEL_RESTORE_LIMIT_KEY), 10) || 5),
);
const ids = restoredIds.slice(0, restoreLimit);
let ads = await hydratePickedAds(ids, loadedDirections);
const hydratedIds = new Set(ads.map(a => a.id));
const fallbackAds = ids
.filter(id => !hydratedIds.has(id))
.map(id => {
const j = autoJobs.find(job => job.ad_id === id) || {};
return {
id,
brand: j.ad_id || id,
platform: 'foreplay',
copy: '',
score: null,
reasoning: null,
daysRunning: 0,
directionLabel: 'Auto-Remodel',
directionSearchLabel: 'Auto-Remodel',
transcript: [],
transcriptRaw: null,
source: 'auto_remodel',
};
});
ads = [...ads, ...fallbackAds];
setAutoRemodelAds(ads);
setArPickedIds(ids);
arPickedIdsRef.current = ids;
setArStage('done');
setArTargetAds(restoreLimit);
const idSet = new Set(ids);
const restoredJobsForIds = autoJobs.filter(j => idSet.has(j.ad_id));
const restoredDone = restoredJobsForIds.filter(j => j.status === 'done' || j.status === 'failed').length;
setArVariantsDone(restoredDone);
setArVariantsTotalOverride(restoredJobsForIds.length || null);
setArLastEvent({ stage: 'done', picked_ad_ids: ids, picked_count: ids.length, target_ads: restoreLimit });
setRemodelScripts(prev => {
const next = { ...prev };
ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; });
return next;
});
pollRemodelJobs(ids);
setTab('autoRemodel');
}
}
} catch (e) {
console.warn('restore auto-remodel state failed', e);
}
})();
return () => {
if (arPollRef.current) clearInterval(arPollRef.current);
if (remodelPollRef.current) clearInterval(remodelPollRef.current);
};
}, []);
const showToast = (msg, badge) => {
setToast({ msg, badge });
setTimeout(() => setToast(null), 3200);
};
const directionMetaMap = (items) => Object.fromEntries((items || []).map(d => {
const keywords = Array.isArray(d.keywords) ? d.keywords.filter(Boolean) : [];
const label = d.description || d.display_name || d.brand_name || d.id;
const searchLabel = keywords.length
? keywords.slice(0, 4).join(', ')
: (d.brand_name || d.display_name || label || d.id);
return [d.id, { label, searchLabel }];
}));
const attachDirectionMeta = (ad, metaMap) => {
const meta = metaMap[ad.directionId];
ad.directionLabel = meta?.label || ad.directionId || null;
ad.directionSearchLabel = meta?.searchLabel || ad.directionLabel || null;
return ad;
};
const syncAutoRemodelState = (state) => {
if (!state) return;
setArRunId(state.run_id || null);
setArStage(state.stage || null);
setArLastEvent({ ...state, run_id: state.run_id, stage: state.stage });
if (typeof state.directions_done === 'number') setArDirectionsDone(state.directions_done);
if (typeof state.directions_total === 'number') setArDirectionsTotal(state.directions_total);
if (typeof state.ads_found === 'number') setArAdsFound(state.ads_found);
if (typeof state.target_ads === 'number') setArTargetAds(state.target_ads);
if (typeof state.n_variants === 'number') setArNVariants(state.n_variants);
if (Array.isArray(state.picked_ad_ids)) setArPickedIds(state.picked_ad_ids);
};
const searchFiltersPayload = (filters = {}) => {
const out = {};
const minLikes = parseInt(filters.minLikes, 10);
const minDailyLikes = parseInt(filters.minDailyLikes, 10);
const bsMinViews = parseInt(filters.bsMinViews, 10);
const bsMinLikes = parseInt(filters.bsMinLikes, 10);
const bsMinEngagement = parseFloat(filters.bsMinEngagement);
if (Number.isFinite(minLikes) && minLikes > 0) out.adspy_min_likes = minLikes;
if (Number.isFinite(minDailyLikes) && minDailyLikes > 0) out.adspy_min_daily_likes = minDailyLikes;
if (filters.orderBy) out.adspy_order = filters.orderBy;
if (filters.bsPlatform) out.brandsearch_platforms = [filters.bsPlatform];
if (Number.isFinite(bsMinViews) && bsMinViews > 0) out.brandsearch_min_views = bsMinViews;
if (Number.isFinite(bsMinLikes) && bsMinLikes > 0) out.brandsearch_min_likes = bsMinLikes;
if (Number.isFinite(bsMinEngagement) && bsMinEngagement > 0) out.brandsearch_min_engagement_rate = bsMinEngagement;
if (filters.bsSort) out.brandsearch_sort = filters.bsSort;
return Object.keys(out).length ? out : null;
};
const searchSourceUrlsPayload = (filters = {}) => {
const out = {};
const foreplayUrl = (filters.foreplayUrl || '').trim();
const brandsearchUrl = (filters.brandsearchUrl || '').trim();
if (foreplayUrl) out.foreplay = foreplayUrl;
if (brandsearchUrl) out.brandsearch = brandsearchUrl;
return Object.keys(out).length ? out : null;
};
const expandAd = (ad) => {
setExpandedAd(ad);
if (!ad || ad.transcriptRaw || ad.platform !== 'brandsearch') return;
RESILIA_API.fetchTranscript(ad.id).then(detail => {
if (!detail || !detail.transcript) return;
const patch = (item) => {
if (item.id !== ad.id) return item;
const adapted = RESILIA_ADAPT.adaptSearchAd({
...(item.raw || {}),
ad_id: item.id,
brand: item.brand,
platform: item.publisherPlatform,
source_platform: item.platform,
score: item.score,
reasoning: item.reasoning,
running_days: item.daysRunning,
creative_url: item.creativeUrl,
thumbnail_url: item.thumbnailUrl,
transcript: detail.transcript,
raw: { ...(item.raw || {}), ...(detail.raw || {}) },
metrics: item.raw?.metrics || detail.raw?.metrics || {},
}, item.platform);
adapted.directionId = item.directionId;
adapted.directionLabel = item.directionLabel;
adapted.directionSearchLabel = item.directionSearchLabel;
return { ...item, ...adapted, source: item.source };
};
setResearchAds(prev => prev.map(patch));
setFilteredAds(prev => prev.map(patch));
setRemodeledAds(prev => prev.map(patch));
setAutoRemodelAds(prev => prev.map(patch));
setExpandedAd(prev => prev && prev.id === ad.id ? patch(prev) : prev);
}).catch(e => console.warn('brandsearch transcript hydration failed', e));
};
// ---- Manual search ------------------------------------------------------
const runSearch = async () => {
setSearching(true);
try {
const results = [];
for (const row of rows) {
const sourceUrls = searchSourceUrlsPayload(row.filters);
if (!row.query && !row.competitorId && !sourceUrls) continue;
const platforms = Object.keys(row.platforms).filter(k => row.platforms[k]);
const payload = {
query: row.query,
competitorId: row.competitorId,
count: Math.min(row.count, 100),
platforms,
filters: searchFiltersPayload(row.filters),
sourceUrls,
};
try {
const resp = await RESILIA_API.search(payload);
const ads = (resp.ads || []).map(a => ({ ...RESILIA_ADAPT.adaptSearchAd(a, 'foreplay'), source: 'search' }));
results.push(...ads);
if (resp && typeof resp.credits_remaining === 'number') {
setPlatformCredits(pc => ({ ...pc, foreplay: resp.credits_remaining }));
}
if (resp && Array.isArray(resp.warnings) && resp.warnings.length) {
const labels = resp.warnings.map(w => w.source).filter(Boolean).join(', ');
showToast(`Search completed with ${labels || resp.warnings.length} warning(s).`, 'Warn');
}
} catch (e) {
showToast(`Search failed for ${row.query || row.competitorId}: ${e.message}`, 'Error');
}
}
// dedupe by ad id across rows
const seen = new Set();
const deduped = [];
for (const a of results) {
if (seen.has(a.id)) continue;
seen.add(a.id);
deduped.push(a);
}
// Keep existing autoresearch ads; only replace the search slice.
setResearchAds(prev => [...prev.filter(a => a.source === 'autoresearch'), ...deduped]);
if (deduped.length === 0) {
showToast('No ads returned. Check your query or competitor.', 'Search');
} else {
showToast(`${deduped.length} ads fetched (scored).`, 'Search');
}
} finally {
setSearching(false);
}
};
// ---- Autoresearch -------------------------------------------------------
const pollWinners = async () => {
try {
// Checkpoint-aware: only returns winners evaluated since the last
// /api/autoresearch/checkpoint call, so previous runs never leak in.
const perDir = parseInt(arTarget, 10) || 1;
const rows = await RESILIA_API.autoresearchWinners(500, perDir);
const dirMap = directionMetaMap(directions);
const seen = new Set();
const ads = [];
for (const row of rows) {
if (seen.has(row.ad_id)) continue;
seen.add(row.ad_id);
const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row);
attachDirectionMeta(adapted, dirMap);
ads.push({ ...adapted, source: 'autoresearch' });
}
// Replace only the autoresearch slice; search-source ads stay put.
setResearchAds(prev => [...prev.filter(a => a.source !== 'autoresearch'), ...ads]);
const summary = await RESILIA_API.summary();
if (summary) {
const runTotal = summary.run_directions_total || 0;
setArProgress({
totalScored: summary.total_scored,
winners: ads.length,
totalWinners: summary.winners,
// Prefer the per-run counter while a crawl is active so the bar
// tracks "directions finished this run", not lifetime DB state.
directionsSatisfied: runTotal > 0 ? summary.run_directions_done : summary.directions_satisfied,
directionsTotal: runTotal > 0 ? runTotal : summary.directions_total,
crawlState: summary.crawl_state,
creditsRemaining: summary.credits_remaining,
});
if (typeof summary.credits_remaining === 'number') {
setPlatformCredits(pc => ({ ...pc, foreplay: summary.credits_remaining }));
}
}
if (summary && summary.crawl_state === 'idle') {
setArRunning(false);
if (arPollRef.current) { clearInterval(arPollRef.current); arPollRef.current = null; }
showToast(`Autoresearch complete. ${ads.length} winners.`, 'Autoresearch');
}
} catch (e) {
console.warn('poll winners failed', e);
}
};
const runAutoresearch = async () => {
const randomPool = arSources.brandsearch
? directions
: directions.filter(d => d.type !== 'competitor');
if (randomPool.length === 0) {
showToast('No directions configured for the selected sources.', 'Autoresearch');
return;
}
// Direction selection: explicit checkbox picks win; otherwise randomly
// sample arNumDirections (default 5) from the non-competitor pool.
// Random instead of slice-from-top so repeat runs cover different ground.
let ids;
if (arPickedDirections.size > 0) {
ids = directions.filter(d => arPickedDirections.has(d.id)).map(d => d.id);
} else {
const cap = Math.max(1, Math.min(randomPool.length, parseInt(arNumDirections, 10) || 5));
const shuffled = randomPool.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
ids = shuffled.slice(0, cap).map(d => d.id);
}
if (ids.length === 0) {
showToast('No directions resolved — check the picker or the cap.', 'Autoresearch');
return;
}
const sources = Object.keys(arSources).filter(k => arSources[k]);
if (sources.length === 0) {
showToast('Pick at least one source (Foreplay, AdSpy, …).', 'Autoresearch');
return;
}
setArSelected(new Set(ids));
setAutoresearchActive(true);
setArProgress({ crawlState: 'starting', winners: 0, totalScored: 0, directionsSatisfied: 0, directionsTotal: ids.length * sources.length });
// Drop previous autoresearch results from the UI so stale cards don't
// linger before the first poll returns.
setResearchAds(prev => prev.filter(a => a.source !== 'autoresearch'));
try {
// Mark a fresh checkpoint on the backend — winners from prior runs
// won't show up in /api/autoresearch/winners anymore.
await RESILIA_API.setAutoresearchCheckpoint();
await RESILIA_API.startAutoresearch({
directionIds: ids,
perDirectionTarget: arTarget ? parseInt(arTarget, 10) : null,
platforms: sources,
});
setArRunning(true);
showToast(`Autoresearch started for ${ids.length} direction(s) across ${sources.length} source(s).`, 'Autoresearch');
if (arPollRef.current) clearInterval(arPollRef.current);
arPollRef.current = setInterval(pollWinners, 4000);
pollWinners();
} catch (e) {
showToast('Autoresearch failed to start: ' + e.message, 'Error');
}
};
const stopAutoresearch = async () => {
try {
await RESILIA_API.stopAutoresearch();
setArRunning(false);
if (arPollRef.current) { clearInterval(arPollRef.current); arPollRef.current = null; }
showToast('Autoresearch stopping…', 'Autoresearch');
} catch (e) {
showToast('Failed to stop: ' + e.message, 'Error');
}
};
// ---- Auto-Remodel orchestrator ----------------------------------------
// Once the orchestrator picks ads, hydrate the swipe rows for them and
// push them through the existing Research → Filter → Remodel views as the
// pipeline transitions stages. This reuses the same panels the manual
// flow uses (no duplicate UI to maintain).
const hydratePickedAds = async (adIds, directionSource = directionsRef.current) => {
if (!adIds || adIds.length === 0) return [];
const dirMap = directionMetaMap(directionSource);
const getSwipeWithRetry = async (id) => {
for (let attempt = 0; attempt < 6; attempt += 1) {
const swipe = await RESILIA_API.getSwipe(id);
if (swipe) return swipe;
await new Promise(resolve => setTimeout(resolve, 500));
}
return null;
};
const rows = await Promise.all(adIds.map(async (id) => {
try {
const swipe = await getSwipeWithRetry(id);
if (!swipe) return null;
let row = swipe;
if (!row.transcript) {
const detail = await RESILIA_API.fetchTranscript(id);
if (detail && detail.transcript) {
row = { ...row, transcript: detail.transcript, raw: { ...(row.raw || {}), ...(detail.raw || {}) } };
}
}
// ``adaptEvaluatedAd`` expects evaluated_ads-shaped rows; swipe rows
// have the same column names for everything we need (ad_id, brand,
// platform, transcript, score, reasoning, running_days, raw, …).
const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row);
attachDirectionMeta(adapted, dirMap);
return { ...adapted, source: 'auto_remodel' };
} catch (e) {
console.warn('hydratePickedAds failed for', id, e);
return null;
}
}));
return rows.filter(Boolean);
};
useEffect(() => {
const ids = arPickedIds || [];
if (!ids.length || !['picked', 'filtering', 'filter_done', 'remodeling', 'done'].includes(arStage)) return;
let cancelled = false;
(async () => {
const ads = await hydratePickedAds(ids);
if (cancelled || ads.length === 0) return;
setAutoRemodelAds(prev => {
const seen = new Set(prev.map(a => a.id));
const missing = ads.filter(a => !seen.has(a.id));
return missing.length ? [...prev, ...missing] : prev;
});
setRemodelScripts(prev => {
const next = { ...prev };
ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; });
return next;
});
pollRemodelJobs(ids);
setTab('autoRemodel');
})();
return () => { cancelled = true; };
// arPickedIds is an array from SSE/state restore; use its contents as the trigger.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [arStage, arPickedIds.join('|')]);
// SSE subscription. The same channel powers the existing autoresearch
// panel's live progress; we layer Auto-Remodel on top with no new
// transport. Returned cleanup closes the EventSource on unmount / HMR.
useEffect(() => {
const close = RESILIA_API.subscribe((ev) => {
if (!ev || !ev.type) return;
if (ev.type === 'auto_remodel_event') {
// Late subscribers latch onto whichever run is broadcasting — keep
// the first run_id we see for this session unless dismissed.
setArRunId(prev => prev || ev.run_id);
if (arRunIdRef.current && ev.run_id && ev.run_id !== arRunIdRef.current) return;
setArStage(ev.stage || null);
setArLastEvent(ev);
// Crawl-side progress counters from the orchestrator.
if (typeof ev.directions_done === 'number') setArDirectionsDone(ev.directions_done);
if (typeof ev.directions_total === 'number') setArDirectionsTotal(ev.directions_total);
if (typeof ev.ads_found === 'number') {
const target = arTargetAdsRef.current || ev.target_ads || 0;
setArAdsFound(target > 0 ? Math.min(ev.ads_found, target) : ev.ads_found);
}
if (typeof ev.target_ads === 'number') setArTargetAds(ev.target_ads);
if (Array.isArray(ev.picked_ad_ids) && ev.picked_ad_ids.length) {
const target = ev.target_ads || arTargetAdsRef.current || ev.picked_ad_ids.length;
const cappedIds = ev.picked_ad_ids.slice(0, Math.max(1, Math.min(20, target)));
arPickedIdsRef.current = cappedIds;
setArPickedIds(cappedIds);
}
// Pipeline now skips the Filter step entirely (Sonnet pre-approves
// every pick at score ≥ 7.5). Stages we react to:
// * picked (with non-empty picks)
// → hydrate ads, switch to Remodel tab, push to
// remodeledAds with pending variant slots, start
// polling.
// * picked (empty picks)
// → orchestrator's fresh-run filter found nothing
// new. Stay on the current tab; toast explains.
// * remodeling → no-op (just a status update).
// * done/failed → final poll + toast.
(async () => {
if (ev.stage === 'picked' && Array.isArray(ev.picked_ad_ids)) {
if (ev.picked_ad_ids.length === 0) {
// Critical: never switch tabs on empty picks. The user clicked
// Run expecting ads — switching to a blank Remodel view feels
// like the app broke. Stay put; the orchestrator will fire
// ``done`` next anyway.
showToast(
'Auto-Remodel: no fresh ads found this run. Try different directions or wait for new winners to surface.',
'Auto-Remodel',
);
return;
}
const ads = await hydratePickedAds(ev.picked_ad_ids);
setAutoRemodelAds(ads);
setRemodelScripts(prev => {
const next = { ...prev };
ads.forEach(a => { next[a.id] = 'pending'; });
return next;
});
if (remodelPollRef.current) clearInterval(remodelPollRef.current);
const adIds = ads.map(a => a.id);
remodelPollRef.current = setInterval(() => pollRemodelJobs(adIds), 3000);
pollRemodelJobs(adIds);
// Now that the picks for THIS run are known, jump to the
// Remodel tab so the user lands on the variants as they stream
// in. Doing it here (instead of on Run click) means the user
// sees their starting tab during the crawl wait.
setTab('autoRemodel');
showToast(
`Auto-Remodel: ${ads.length} ad${ads.length === 1 ? '' : 's'} picked — generating variants.`,
'Auto-Remodel',
);
} else if (ev.stage === 'done') {
// Suppress the redundant "done" toast when we already toasted
// about an empty pick — same run, same news.
if ((ev.picked ?? 0) > 0) {
showToast(
`Auto-Remodel done — ${ev.completed ?? 0}/${ev.jobs_created ?? 0} jobs ok`,
ev.failed ? 'Warn' : 'Auto-Remodel',
);
}
if (arPickedIdsRef.current.length) pollRemodelJobs(arPickedIdsRef.current);
} else if (ev.stage === 'failed') {
showToast(`Auto-Remodel failed: ${ev.error || 'unknown'}`, 'Error');
}
})();
} else if (ev.type === 'remodel_completed' || ev.type === 'remodel_failed' || ev.type === 'remodel_edited') {
// Variant-level updates: refresh just the affected ad's scripts via
// the existing poll helper (handles the parsing + status logic).
if (ev.ad_id && arPickedIdsRef.current.includes(ev.ad_id)) {
pollRemodelJobs([ev.ad_id]);
// Each remodel_completed/_failed represents one variant finishing
// (in any state) — tick the panel's variant counter.
if (ev.type === 'remodel_completed' || ev.type === 'remodel_failed') {
setArVariantsDone(n => n + 1);
}
}
} else if (ev.type === 'remodel_promoted') {
if (ev.ad_id && arPickedIdsRef.current.includes(ev.ad_id)) {
pollRemodelJobs([ev.ad_id]);
}
showToast(`Promoted to Video Queue (job ${ev.job_id})`, 'Promote');
} else if (ev.type === 'evaluated_deleted') {
showToast(`Bulk delete — ${ev.count} ad(s) removed`, 'Delete');
} else if (ev.type === 'ad_scored' && ev.passes === true) {
// The crawler fires ``ad_scored`` for every ad it evaluates — when
// ``passes`` is true the ad just qualified. Stream qualifying ads
// into the Research grid live so the user sees winners landing as
// the orchestrator finds them, instead of waiting for the entire
// crawl to finish before anything appears. Gated to active
// Auto-Remodel runs so we don't conflict with the autoresearch
// panel's own polling-based flow.
const stageNow = arStageRef.current;
const active = !!arRunIdRef.current && stageNow !== 'done' && stageNow !== 'failed';
if (!active) return;
const target = arTargetAdsRef.current || 5;
if (arAcceptedAdIdsRef.current.has(ev.ad_id)) return;
if (arAcceptedAdIdsRef.current.size >= target) return;
arAcceptedAdIdsRef.current.add(ev.ad_id);
setArAdsFound(Math.min(arAcceptedAdIdsRef.current.size, target));
const dirMap = directionMetaMap(directionsRef.current);
const synth = {
ad_id: ev.ad_id,
direction_id: ev.direction_id,
score: ev.score,
passes: true,
reasoning: ev.reasoning,
transcript: ev.transcript_preview,
brand: ev.brand,
platform: ev.platform,
source_platform: ev.source_platform,
running_days: ev.running_days,
creative_url: ev.creative_url,
thumbnail_url: ev.thumbnail_url,
link_url: ev.link_url,
metrics: ev.metrics || {},
raw: { source_platform: ev.source_platform, metrics: ev.metrics || {} },
};
const adapted = RESILIA_ADAPT.adaptEvaluatedAd(synth);
attachDirectionMeta(adapted, dirMap);
const ad = { ...adapted, source: 'autoresearch' };
RESILIA_API.fetchTranscript(ev.ad_id).then(detail => {
if (!detail || !detail.transcript) return;
const patchTranscript = (item) => {
if (item.id !== ev.ad_id) return item;
const patched = RESILIA_ADAPT.adaptEvaluatedAd({
...synth,
transcript: detail.transcript,
raw: { ...(synth.raw || {}), ...(detail.raw || {}) },
});
patched.directionLabel = item.directionLabel;
patched.directionSearchLabel = item.directionSearchLabel;
return { ...item, ...patched, source: item.source };
};
setResearchAds(prev => prev.map(patchTranscript));
}).catch(e => console.warn('auto-remodel transcript hydration failed', e));
// Push only to the Research grid. The Auto-Remodel panel shows
// progress; rendering cards there as well duplicates the ads below.
setResearchAds(prev => {
if (prev.some(a => a.id === ad.id)) return prev;
const current = prev.filter(a => a.source === 'autoresearch');
if (current.length >= target) return prev;
return [...prev, ad];
});
setAutoresearchActive(true);
}
});
return close;
// We intentionally don't include arRunId/arPickedIds in deps — re-creating
// the SSE connection on every state change would thrash the server. The
// closure reads the latest values via the setState callbacks.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startAutoRemodel = async ({ mode, directionId, directionIds, count, nVariants, platforms }) => {
try {
const out = await RESILIA_API.runAutoRemodel({ mode, directionId, directionIds, count, nVariants, platforms });
if (out.status === 'already_running') {
showToast('Auto-Remodel is already running.', 'Auto-Remodel');
return;
}
// Fresh run — clear stale state. Stay on whatever tab the user is on;
// the SSE handler will switch to Remodel once the orchestrator emits
// the ``picked`` event (i.e. after the crawl finishes and the picks
// for THIS run are known). Stop the manual-flow remodel poller too,
// otherwise it could overwrite the orchestrator's state.
if (remodelPollRef.current) {
clearInterval(remodelPollRef.current);
remodelPollRef.current = null;
}
setArRunId(out.run_id);
setArStage('queued');
setArLastEvent(null);
setArPickedIds([]);
arPickedIdsRef.current = [];
arAcceptedAdIdsRef.current = new Set();
setArDirectionsDone(0);
setArDirectionsTotal(0);
setArAdsFound(0);
setArTargetAds(Math.max(1, Math.min(20, parseInt(count, 10) || 5)));
localStorage.setItem(
AUTO_REMODEL_RESTORE_LIMIT_KEY,
String(Math.max(1, Math.min(20, parseInt(count, 10) || 5))),
);
setArVariantsDone(0);
setArNVariants(Math.max(1, parseInt(nVariants, 10) || 1));
setArVariantsTotalOverride(null);
setAutoRemodelAds([]);
setRemodelScripts(prev => {
const next = { ...prev };
autoRemodelAds.forEach(ad => { delete next[ad.id]; });
arPickedIds.forEach(id => { delete next[id]; });
return next;
});
setTab('autoRemodel');
// Clear the autoresearch slice so qualifying ads from a prior run
// don't linger behind the new ones streaming in.
setResearchAds(prev => prev.filter(a => a.source !== 'autoresearch'));
const snapshot = await RESILIA_API.getAutoRemodelState(out.run_id);
if (snapshot && snapshot.status === 'ok' && snapshot.state) {
syncAutoRemodelState(snapshot.state);
}
showToast(`Auto-Remodel started (${out.run_id}) — staying here until picks land.`, 'Auto-Remodel');
} catch (e) {
showToast('Failed to start Auto-Remodel: ' + e.message, 'Error');
}
};
const regenerateRemodelJob = async (jobId) => {
try {
const out = await RESILIA_API.retryRemodelJob(jobId);
const adId = Object.keys(remodelScripts).find(id => {
const e = remodelScripts[id];
return e && Array.isArray(e.variants) && e.variants.some(v => v.jobId === jobId);
});
if (adId) {
setRemodelScripts(prev => ({ ...prev, [adId]: { status: 'pending', variants: [] } }));
if (remodelPollRef.current) clearInterval(remodelPollRef.current);
remodelPollRef.current = setInterval(() => pollRemodelJobs([adId]), 3000);
pollRemodelJobs([adId]);
}
showToast(`Regeneration started (${out.run_id || jobId}).`, 'Remodel');
} catch (e) {
showToast('Regenerate failed: ' + e.message, 'Error');
}
};
const discardRemodelJob = async (jobId) => {
if (!confirm('Discard this remodel variant?')) return;
try {
await RESILIA_API.deleteRemodelJob(jobId);
setRemodelScripts(prev => {
const next = { ...prev };
for (const adId of Object.keys(next)) {
const entry = next[adId];
if (!entry || !Array.isArray(entry.variants)) continue;
const variants = entry.variants.filter(v => v.jobId !== jobId);
next[adId] = { ...entry, variants, status: variants.length ? entry.status : 'error' };
}
return next;
});
showToast('Variant discarded.', 'Remodel');
} catch (e) {
showToast('Discard failed: ' + e.message, 'Error');
}
};
const promoteRemodelJob = async (jobId) => {
try {
const out = await RESILIA_API.promoteRemodelJob(jobId);
const job = out.job || out;
const ad = remodeledAds.find(a => a.id === job.ad_id) || autoRemodelAds.find(a => a.id === job.ad_id);
if (ad) {
setConvertedAds(prev => prev.some(a => a.id === ad.id) ? prev : [...prev, ad]);
}
if (job.ad_id) pollRemodelJobs([job.ad_id]);
showToast(`Promoted to Video Queue (job ${job.id || jobId}).`, 'Promote');
return job;
} catch (e) {
showToast('Promote failed: ' + e.message, 'Error');
return null;
}
};
const promoteAllAutoRemodelJobs = async () => {
const jobIds = [];
autoRemodelAds.forEach(ad => {
const entry = remodelScripts[ad.id];
if (!entry || !Array.isArray(entry.variants)) return;
entry.variants.forEach(v => {
if (v.jobId) jobIds.push(v.jobId);
});
});
if (!jobIds.length) {
showToast('No Auto-Remodel scripts are ready.', 'Promote');
return;
}
try {
const promoted = [];
for (const jobId of jobIds) {
const out = await RESILIA_API.promoteRemodelJob(jobId);
const job = out.job || out;
if (job) promoted.push(job);
}
const promotedAdIds = new Set(promoted.map(j => j.ad_id).filter(Boolean));
setConvertedAds(prev => {
const seen = new Set(prev.map(a => a.id));
const next = [...prev];
autoRemodelAds.forEach(ad => {
if (promotedAdIds.has(ad.id) && !seen.has(ad.id)) next.push(ad);
});
return next;
});
if (promotedAdIds.size) pollRemodelJobs([...promotedAdIds]);
showToast(`${promoted.length} Auto-Remodel script${promoted.length === 1 ? '' : 's'} promoted to Video Queue.`, 'Promote');
} catch (e) {
showToast('Promote all failed: ' + e.message, 'Error');
}
};
// ---- Research-tab bulk delete -----------------------------------------
const bulkDeleteResearch = async () => {
const ids = [...selResearch];
if (!ids.length) return;
if (!confirm(`Delete ${ids.length} evaluated ad row(s)?\n\nQualified winners and swipe file are NOT touched.`)) return;
try {
const out = await RESILIA_API.bulkDeleteEvaluated(ids);
setResearchAds(prev => prev.filter(a => !selResearch.has(a.id)));
setSelResearch(new Set());
showToast(`${out.removed} ad(s) removed.`, 'Delete');
} catch (e) {
showToast('Bulk delete failed: ' + e.message, 'Error');
}
};
// ---- Pipeline transitions ----------------------------------------------
const toggleSel = (setter) => (id) => {
setter(prev => {
const n = new Set(prev);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
};
const ensureAdsInSwipe = async (picked) => {
// Filter/Remodel backends read transcripts from swipe_file, so ads
// coming straight from search need to be persisted first. Autoresearch
// ads are already in swipe_file via the promote-winners flow.
for (const ad of picked) {
try {
await RESILIA_API.addToSwipe({
adId: ad.id,
raw: ad.raw || {},
directionId: ad.directionId || null,
});
} catch (e) {
console.warn('addToSwipe failed for', ad.id, e);
}
}
};
const passToFilter = async () => {
const picked = researchAds.filter(a => selResearch.has(a.id));
if (picked.length === 0) return;
setFilterBusy(true);
try {
await ensureAdsInSwipe(picked);
await RESILIA_API.runFilter({ adIds: picked.map(a => a.id) });
// Optimistically seed the Filter tab with the picked ads; real
// verdicts arrive async — UI can refresh via SSE in a follow-up.
setFilteredAds(picked);
setSelResearch(new Set());
setTab('filter');
showToast(`Filter started on ${picked.length} ads.`, 'Filter');
} catch (e) {
showToast('Filter failed: ' + e.message, 'Error');
} finally {
setFilterBusy(false);
}
};
const passWithoutFilter = async () => {
const picked = researchAds.filter(a => selResearch.has(a.id));
if (picked.length === 0) return;
try {
await ensureAdsInSwipe(picked);
await startRemodelAndPoll(picked);
setRemodeledAds(picked);
setSelResearch(new Set());
setTab('remodel');
showToast(`${picked.length} ads passed without filtering. Remodeling…`, 'Bypass');
} catch (e) {
showToast('Pass failed: ' + e.message, 'Error');
}
};
// Extract just the REMODELED SCRIPT prose from the remodeler output — skip
// the analysis header and any surrounding metadata. Each paragraph becomes
// one untagged line so the UI renders it as a readable transcript rather
// than a HOOK/PROBLEM/SOLUTION breakdown.
const parseRemodelScript = (text) => {
if (!text) return [];
const scriptMatch = text.match(/###\s*REMODELED\s+SCRIPT\s*\n([\s\S]*?)(?=\n###\s|$)/i);
const body = scriptMatch ? scriptMatch[1].trim() : text.trim();
const paras = body.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
return paras.map(p => ({
tag: '',
body: p.replace(/^\*\*[^*]+\*\*\s*/, '').replace(/\n+/g, ' '),
}));
};
// ``editRemodelScript`` now takes a job id directly (RemodelView passes
// the jobId of the specific variant being edited). Each ad can have
// multiple variants in flight, so an adId-only lookup would be ambiguous.
const editRemodelScript = async (jobId, newText) => {
if (!jobId) {
showToast('No remodel job to edit yet — wait for it to finish.', 'Remodel');
return;
}
try {
await RESILIA_API.updateRemodelScript({ jobId, responseText: newText });
// Refresh by re-polling the affected ad — keeps the variants array in
// sync with the DB without a bespoke patch path.
const adId = Object.keys(remodelScripts).find(id => {
const e = remodelScripts[id];
return e && Array.isArray(e.variants) && e.variants.some(v => v.jobId === jobId);
});
if (adId) pollRemodelJobs([adId]);
showToast('Edit saved.', 'Remodel');
} catch (e) {
showToast('Save failed: ' + e.message, 'Error');
throw e;
}
};
// Each ad can spawn N variants (one Sonnet call per variant). We track
// them as ``{ status, variants: [{ jobId, variantIdx, raw, lines }] }``
// so the Remodel view can render every finished variant as its own
// editable card. Polling stops only when every variant for every ad has
// reached done/failed.
const pollRemodelJobs = async (adIds) => {
try {
const perAd = await Promise.all(
adIds.map(id => RESILIA_API.listRemodelJobs({ adId: id })),
);
setRemodelScripts(prev => {
const next = { ...prev };
let stillPending = 0;
adIds.forEach((id, i) => {
const jobs = perAd[i] || [];
const done = jobs.filter(j => j.status === 'done' && j.response_text);
const failed = jobs.filter(j => j.status === 'failed');
const inFlight = jobs.length - done.length - failed.length;
if (done.length > 0) {
next[id] = {
status: 'ready',
variants: done
.slice()
.sort((a, b) => (a.variant_idx || 0) - (b.variant_idx || 0))
.map(j => ({
jobId: j.id,
variantIdx: j.variant_idx || 0,
raw: j.response_text,
lines: parseRemodelScript(j.response_text),
promotedAt: j.promoted_at || null,
})),
};
if (inFlight > 0) stillPending++;
} else if (failed.length === jobs.length && jobs.length > 0) {
next[id] = { status: 'error', variants: [] };
} else {
next[id] = { status: 'pending', variants: [] };
stillPending++;
}
});
if (stillPending === 0 && remodelPollRef.current) {
clearInterval(remodelPollRef.current);
remodelPollRef.current = null;
}
return next;
});
} catch (e) {
console.warn('poll remodel jobs failed', e);
}
};
const startRemodelAndPoll = async (picked) => {
const adIds = picked.map(a => a.id);
setRemodelScripts(prev => {
const next = { ...prev };
adIds.forEach(id => { next[id] = 'pending'; });
return next;
});
await RESILIA_API.runRemodel({ adIds, nVariants: 1 });
if (remodelPollRef.current) clearInterval(remodelPollRef.current);
remodelPollRef.current = setInterval(() => pollRemodelJobs(adIds), 3000);
pollRemodelJobs(adIds);
};
const runRemodel = async () => {
const picked = filteredAds.filter(a => selFilter.has(a.id));
if (picked.length === 0) return;
setRemodelBusy(true);
try {
await ensureAdsInSwipe(picked);
await startRemodelAndPoll(picked);
setRemodeledAds(picked);
setSelFilter(new Set());
setTab('remodel');
showToast(`Remodel started on ${picked.length} ads (1 variant each).`, 'Remodel');
} catch (e) {
showToast('Remodel failed: ' + e.message, 'Error');
} finally {
setRemodelBusy(false);
}
};
const convert = () => {
const picked = remodeledAds.filter(a => selRemodel.has(a.id));
setConvertedAds(picked);
setSelRemodel(new Set());
setTab('video');
showToast(`${picked.length} ads queued for video conversion.`, 'Video');
};
const counts = {
research: researchAds.length,
autoRemodel: autoRemodelAds.length || arPickedIds.length,
filter: filteredAds.length,
remodel: remodeledAds.length,
video: convertedAds.length,
};
// group by platform
const groupByPlatform = (ads) => {
const g = { foreplay: [], adspy: [], brandsearch: [], metaads: [] };
ads.forEach(a => g[a.platform]?.push(a));
return g;
};
return (
{tab === 'research' && (
d.type !== 'competitor')}
arPickedDirections={arPickedDirections}
onToggleArPickedDirection={toggleArPickedDirection}
onClearArPickedDirections={clearArPickedDirections}
onRunAutoresearch={runAutoresearch}
onStopAutoresearch={stopAutoresearch}
/>
)}
{tab === 'filter' && (
)}
{tab === 'autoRemodel' && (
)}
{tab === 'remodel' && (
)}
{tab === 'video' && (
)}
{tab === 'research' && (
setSelResearch(new Set())}
onSelectAll={() => setSelResearch(new Set(researchAds.map(a => a.id)))}
/>
)}
{tab === 'filter' && (
setSelFilter(new Set())}
onSelectAll={() => setSelFilter(new Set(filteredAds.map(a => a.id)))}
/>
)}
{tab === 'remodel' && (
setSelRemodel(new Set())}
onSelectAll={() => setSelRemodel(new Set(remodeledAds.map(a => a.id)))}
/>
)}
setExpandedAd(null)} />
{toast && (
{toast.badge}
{toast.msg}
)}
{!tweaksOpen && (
setTweaksOpen(true)} title="Tweaks">
)}
{tweaksOpen && (
{
setTweaks(next);
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: next }, '*');
}}
onClose={() => setTweaksOpen(false)}
/>
)}
);
}
function ResearchView({
rows, setRows, competitors,
ads, searching, sel, onToggle, onSearch, onExpandAd, density, minimal, groupByPlatform,
arRunning, arProgress, arSources, onToggleArSource, onSelectAllArSources,
arTarget, setArTarget, onRunAutoresearch, onStopAutoresearch,
arNumDirections, setArNumDirections,
directions, arPickedDirections, onToggleArPickedDirection, onClearArPickedDirections,
}) {
const updateRow = (id, patch) => setRows(rows.map(r => r.id === id ? patch : r));
const removeRow = (id) => setRows(rows.filter(r => r.id !== id));
const addRow = () => setRows([...rows, {
id: Date.now(), query: '', competitorId: null, count: 20,
filters: { minLikes: '', minDailyLikes: '', orderBy: '', bsPlatform: '', bsMinViews: '', bsMinLikes: '', bsMinEngagement: '', bsSort: '', foreplayUrl: '', brandsearchUrl: '' },
platforms: { foreplay: true, adspy: false, brandsearch: true, metaads: false },
}]);
const searchAds = ads.filter(a => a.source !== 'autoresearch');
const autoAds = ads.filter(a => a.source === 'autoresearch');
const searchGrouped = groupByPlatform(searchAds);
const autoGrouped = groupByPlatform(autoAds);
const searchPlatforms = Object.keys(searchGrouped).filter(p => searchGrouped[p].length > 0);
const autoPlatforms = Object.keys(autoGrouped).filter(p => autoGrouped[p].length > 0);
return (
<>
Search
Multi-row keyword or competitor-scoped search · Foreplay + AdSpy + BrandSearch live
{searching ? <> Searching…> : <> Search>}
{rows.map(r => (
updateRow(r.id, patch)}
onRemove={() => removeRow(r.id)}
canRemove={rows.length > 1}
/>
))}
Add row
{searchPlatforms.length > 0 && searchPlatforms.map(p => (
))}
{autoPlatforms.length > 0 && autoPlatforms.map(p => (
))}
{searchPlatforms.length === 0 && autoPlatforms.length === 0 && (
No results yet.
Run a search above or pick autoresearch directions and press Run Autoresearch.
)}
>
);
}
function PipelineView({ title, subtitle, ads, sel, onToggle, onExpandAd, density, minimal, groupByPlatform, emptyTitle, emptyBody }) {
const grouped = groupByPlatform(ads);
const activePlatforms = Object.keys(grouped).filter(p => grouped[p].length > 0);
return (
<>
{title}
{subtitle}
{activePlatforms.length === 0 ? (
) : (
activePlatforms.map(p => (
))
)}
>
);
}
function AutoRemodelWorkspace({
ads,
scripts,
directionOptions,
running,
stage,
lastEvent,
adsFound,
targetAds,
variantsDone,
variantsTotal,
sources,
onToggleSource,
onSelectAllSources,
onRun,
onEditScript,
onRegenerate,
onDiscard,
onPromote,
onPromoteAll,
}) {
const readyJobs = ads.flatMap(ad => {
const entry = scripts[ad.id];
if (!entry || !Array.isArray(entry.variants)) return [];
return entry.variants.filter(v => v.jobId).map(v => v.jobId);
});
return (
<>
Auto-Remodel
Autonomous search, scoring, filter audit, and script generation.
>
);
}
function AutoRemodelResultsView({
ads,
scripts,
readyJobs,
running,
stage,
onEditScript,
onRegenerate,
onDiscard,
onPromote,
onPromoteAll,
}) {
const canPromoteAll = readyJobs.length > 0;
const sortedAds = ads.slice().sort((a, b) => {
const entryA = scripts[a.id];
const entryB = scripts[b.id];
const readyA = entryA && Array.isArray(entryA.variants) && entryA.variants.length > 0;
const readyB = entryB && Array.isArray(entryB.variants) && entryB.variants.length > 0;
if (readyA !== readyB) return readyA ? -1 : 1;
const hasTranscriptA = !!(a.transcriptRaw || (Array.isArray(a.transcript) && a.transcript.length));
const hasTranscriptB = !!(b.transcriptRaw || (Array.isArray(b.transcript) && b.transcript.length));
if (hasTranscriptA !== hasTranscriptB) return hasTranscriptA ? -1 : 1;
return 0;
});
return (
Auto-Remodel Results
{ads.length} ad{ads.length === 1 ? '' : 's'}
Promote All to Video Queue
{ads.length === 0 ? (
No Auto-Remodel results yet
{running ? 'The run is working. Results will appear here as soon as top ads are picked.' : stage === 'failed' ? 'The last run failed.' : 'Configure Auto-Remodel above and run it.'}
) : (
{sortedAds.map(ad => (
))}
)}
);
}
function AutoRemodelResultCard({ ad, entry, onEditScript, onRegenerate, onDiscard, onPromote }) {
const variants = entry && Array.isArray(entry.variants) ? entry.variants : [];
const status = entry && entry.status ? entry.status : (entry === 'error' ? 'error' : 'pending');
const transcriptText = ad.transcriptRaw
|| (Array.isArray(ad.transcript)
? ad.transcript.map(seg => seg.body || seg.t || '').filter(Boolean).join('\n')
: (ad.transcript || ''));
const directionWords = ad.directionSearchLabel || ad.directionLabel;
const statusLabel = variants.length > 0
? `${variants.length} remodeled script${variants.length === 1 ? '' : 's'}`
: status === 'error'
? 'Generation failed'
: 'Generating scripts';
return (
{ad.brand || ad.id}
{ad.id}
0 ? 'ready' : 'pending'}`}>{statusLabel}
{typeof ad.score === 'number' && {ad.score.toFixed(1)} }
{ad.daysRunning ? {ad.daysRunning}d running : null}
{directionWords ? (
{directionWords}
) : null}
{ad.platform ? {ad.platform} : null}
{(transcriptText || ad.reasoning) && (
Original transcript, Sonnet score, and reasoning
{typeof ad.score === 'number' && Sonnet score: {ad.score.toFixed(1)}
}
{ad.reasoning && {ad.reasoning}
}
{transcriptText && {transcriptText} }
)}
{status === 'pending' && variants.length === 0 && (
Generating scripts
Claude Sonnet is still working on this ad.
)}
{status === 'error' && variants.length === 0 && (
Generation failed
Regenerate from the job action when a failed variant is available.
)}
{variants.map(v => (
onEditScript(v.jobId, text)}
onRegenerate={() => onRegenerate(v.jobId)}
onDiscard={() => onDiscard(v.jobId)}
onPromote={() => onPromote(v.jobId)}
/>
))}
);
}
function AutoRemodelMediaPreview({ ad, transcriptText }) {
const [playing, setPlaying] = React.useState(false);
const p = PLATFORMS[ad.platform] || PLATFORMS.foreplay;
const metricChips = RESILIA_ADAPT.metricChips(ad);
const hasVideo = !!ad.creativeUrl;
const hasThumbnail = !!ad.thumbnailUrl;
const showVideoPreview = hasVideo && !hasThumbnail && !playing;
const scoreText = typeof ad.score === 'number'
? (ad.score > 10 ? Math.round(ad.score) : ad.score.toFixed(1))
: null;
const sourceCopy = ad.copy || (transcriptText ? transcriptText.slice(0, 220) : '');
const directionWords = ad.directionSearchLabel || ad.directionLabel;
return (
{playing && hasVideo ? (
) : (
<>
{showVideoPreview && (
{
const video = e.currentTarget;
try { e.currentTarget.currentTime = 0.15; } catch { /* preview seek can fail on some streams */ }
try { video.play(); } catch { /* muted autoplay can still be blocked in rare cases */ }
}}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
)}
{p.label}
hasVideo && setPlaying(true)}
title={hasVideo ? 'Play original video' : 'No video creative available'}
disabled={!hasVideo}
>
{ad.duration && {ad.duration} }
{scoreText !== null && (
{scoreText}
)}
>
)}
Original ad
{hasVideo ? video : thumbnail }
{directionWords && (
{directionWords}
)}
{sourceCopy &&
{sourceCopy}
}
{scoreText !== null && {scoreText} }
{metricChips.map(({ label, value, title, className }) => (
{label} {value}
))}
{ad.daysRunning ? {ad.daysRunning}d running : null}
{ad.directionLabel && ad.directionLabel !== directionWords ? {ad.directionLabel} : null}
);
}
function AutoRemodelScriptEditor({ variant, onSave, onRegenerate, onDiscard, onPromote }) {
const [draft, setDraft] = React.useState(variant.raw || '');
const [expanded, setExpanded] = React.useState(false);
const [editing, setEditing] = React.useState(false);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => { setDraft(variant.raw || ''); }, [variant.raw, variant.jobId]);
const startEdit = () => {
setExpanded(true);
setEditing(true);
};
const save = async () => {
setSaving(true);
try {
await onSave(draft);
setEditing(false);
} finally {
setSaving(false);
}
};
const cancelEdit = () => {
setDraft(variant.raw || '');
setEditing(false);
};
return (
setExpanded(v => !v)}
title={expanded ? 'Hide remodeled script' : 'Show remodeled script'}
aria-expanded={expanded}
>
Remodeled
{variant.promotedAt &&
Promoted {String(variant.promotedAt).slice(0, 16)} }
{!editing && (
Edit
)}
{!editing && (
Regenerate
)}
{!editing && (
Discard
)}
{!editing && (
{variant.promotedAt ? 'Promoted' : 'Promote to Video Queue'}
)}
{editing && (
<>
Cancel
{saving ? 'Saving…' : 'Save'}
>
)}
{expanded && (editing ? (
);
}
function RemodelView({ ads, sel, onToggle, scripts, onEditScript, onRegenerate, onDiscard, onPromote }) {
// ``scripts[ad.id]`` is one of:
// { status: 'ready', variants: [{ jobId, variantIdx, raw, lines }] }
// { status: 'pending', variants: [] }
// { status: 'error', variants: [] }
// Each variant renders as its own editable TranscriptCard so multi-variant
// Auto-Remodel runs surface every Sonnet output. Backwards-compatible with
// the old single-entry shape so manual flows that haven't re-polled yet
// still render.
const cardsForAd = (ad) => {
const entry = scripts[ad.id];
let status = 'pending';
let variants = [];
if (entry && Array.isArray(entry.variants)) {
status = entry.status || 'pending';
variants = entry.variants;
} else if (entry && typeof entry === 'object' && entry.jobId) {
status = 'ready';
variants = [{ jobId: entry.jobId, variantIdx: 0, raw: entry.raw, lines: entry.lines }];
} else if (entry === 'error') {
status = 'error';
} else if (Array.isArray(entry) && entry.length) {
status = 'ready';
variants = [{ jobId: null, variantIdx: 0, raw: '', lines: entry }];
}
if (status === 'pending' || (status === 'ready' && variants.length === 0)) {
return [(
onToggle(ad.id)}
/>
)];
}
if (status === 'error') {
return [(
onToggle(ad.id)}
/>
)];
}
const multi = variants.length > 1;
return variants.map(v => {
// Decorate the brand label so users can tell variants apart.
const adForCard = multi ? { ...ad, brand: `${ad.brand} · variant ${v.variantIdx + 1}` } : ad;
const editable = !!v.jobId;
return (
onToggle(ad.id)}
editable={editable}
rawText={editable ? v.raw : ''}
onSave={editable ? (newText) => onEditScript(v.jobId, newText) : null}
onRegenerate={editable ? () => onRegenerate(v.jobId) : null}
onDiscard={editable ? () => onDiscard(v.jobId) : null}
onPromote={editable ? () => onPromote(v.jobId) : null}
promotedAt={v.promotedAt}
/>
);
});
};
const isAutoRemodel = ads.some(a => a.source === 'auto_remodel');
return (
<>
{isAutoRemodel ? 'Auto-Remodel Results' : 'Remodel'}
Review final scripts, edit inline, regenerate, discard, or promote to the video queue.
{ads.length === 0 ? (
Nothing remodeled yet
Select ads in Filter and press Run Remodel, Pass without Filtering from Research, or kick off Auto-Remodel.
) : (
ads.map(ad => (
{cardsForAd(ad)}
))
)}
>
);
}
function VideoView({ ads }) {
return (
<>
Video-Conversion
Queued for the video-editing pipeline (backend in development).
{ads.length === 0 ? (
Queue is empty
Select remodeled scripts in Remodel and press Convert to Video.
) : (
Queued
{ads.length} jobs
ETA ~{ads.length * 90}s
{ads.map(ad => (
{ad.brand}
{ad.id} · remodeled · awaiting editor
Pending
))}
)}
>
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );