import React, { useEffect, useState } from 'react'; import { LoaderCircleIcon, RadarIcon } from 'lucide-react'; import { useCopilot } from '../../../context/copilot.context'; import { getScanRunStatus, listBrands, } from '../../../service/visibility/visibility.service'; import { StatusCard } from './StatusCard'; /** How often the card asks whether the scan is still running. */ const SCAN_POLL_INTERVAL_MS = 10_000; /** How long the card says "starting" before it trusts an idle answer. A scan * is dispatched to a worker, so the first status reads can arrive before the * run has registered itself. */ const SCAN_STARTUP_GRACE_MS = 20_000; /** After this long the card stops polling rather than spinning forever. */ const SCAN_POLL_TIMEOUT_MS = 30 * 60_000; /** The three things the card can be showing. */ type ScanPhase = 'starting' | 'running' | 'done'; /** * How long the scan has been going. * * @param startedAt - ISO stamp the run started at. * @param nowMs - The current time. * @return Something like "2 min in", or an empty string. */ function elapsedLabel(startedAt: string | null, nowMs: number): string { if (!startedAt) return ''; const started: number = Date.parse(startedAt); if (Number.isNaN(started)) return ''; const minutes: number = Math.max(0, Math.round((nowMs - started) / 60000)); return minutes < 1 ? 'just started' : `${minutes} min in`; } /** * The in-chat visibility scan card. A scan runs for minutes on a worker, so a * merchant who asked for one in the conversation is told there that it * started, kept informed while it runs, and offered the result where they are * standing - rather than being left with a sentence and no way to know if * anything happened. The brand is resolved here rather than passed in, so the * card works in the docked panel too, on a page that knows no brand. * * @param props - Component props. * @param props.onOpenReport - Show the finished scan. * @return The card. */ export function ScanCard({ onOpenReport, }: { onOpenReport: (brandId: string) => void; }): React.ReactElement { const copilot = useCopilot(); const token: string = copilot.token; const clientId: string = copilot.clientId; const [startedAt, setStartedAt] = useState(null); const [brandId, setBrandId] = useState(''); const [phase, setPhase] = useState('starting'); const [mountedAt] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { if (!token || !clientId) return undefined; let cancelled = false; let brand = ''; const check = async (): Promise => { try { if (!brand) { const brands = await listBrands(clientId, token); brand = brands.brands?.[0]?.brand_id ?? ''; if (cancelled) return; setBrandId(brand); } if (!brand) return; const status = await getScanRunStatus(clientId, token, brand); if (cancelled) return; setNowMs(Date.now()); setStartedAt(status.started_at ?? null); if (status.running) { setPhase('running'); return; } // An idle answer only means "finished" once the run has had time to // register; before that it means the worker has not picked it up yet. if (Date.now() - mountedAt > SCAN_STARTUP_GRACE_MS) setPhase('done'); } catch { // The agent is unreachable for a moment: the card keeps whatever it // last knew and tries again on the next tick. } }; void check(); const timer: number = window.setInterval(() => { if (Date.now() - mountedAt > SCAN_POLL_TIMEOUT_MS) return; void check(); }, SCAN_POLL_INTERVAL_MS); return () => { cancelled = true; window.clearInterval(timer); }; }, [token, clientId, mountedAt]); if (phase === 'done') { return ( onOpenReport(brandId), }} /> ); } const isStarting: boolean = phase === 'starting'; const elapsed: string = elapsedLabel(startedAt, nowMs); return (

{isStarting ? 'Scan starting' : 'Scan running'}

{isStarting ? 'Handing it to the workers now.' : `Asking every tracked prompt on each AI engine${elapsed ? ` - ${elapsed}` : ''}.`}

takes a few minutes
); }