);
}
function LiveSessionRow({
session,
now,
name,
run,
onChanged,
}: {
session: RemoteSessionState;
now: number;
name: string | null;
run: CookieBotRun | undefined;
onChanged: () => void;
}) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const [isStopping, setIsStopping] = useState(false);
const elapsed = sessionElapsedSeconds(session, now);
const phase = sessionPhaseLabel(t, session);
const tone = sessionTone(session);
const closeReason = sessionCloseReason(t, session);
// Only once the backend has actually written a counter. Until then the bar
// sat at zero for the whole run and read as "nothing is happening".
const countersKnown = run ? hasRunCounters(run) : false;
const total = run?.sites_total ?? 0;
const visited = run?.sites_visited ?? 0;
const progress =
countersKnown && total > 0 ? Math.min(1, visited / total) : null;
// A night longer than one session's cap is split into chunks, and the run row
// is the only place that can say which one is running. `chunk_index` counts
// chunks STARTED — the server bumps it as it launches each one and treats 0
// as "never got going" — so it already reads as a 1-based position and must
// not be incremented again.
const chunks =
run && run.chunks_total > 1 && run.chunk_index > 0
? t("cookieBot.live.chunk", {
index: Math.min(run.chunk_index, run.chunks_total),
total: run.chunks_total,
})
: null;
const stop = useCallback(async () => {
setIsStopping(true);
try {
if (session.run_id) {
await cancelCookieBotRun(session.run_id);
} else {
await stopRemoteSession(session.session_id);
}
showSuccessToast(t("cookieBot.running.stopped"));
onChanged();
} catch (error) {
showErrorToast(translateBackendError(t, error));
} finally {
setIsStopping(false);
}
}, [session.run_id, session.session_id, onChanged, t]);
return (
{name ?? t("cookieBot.live.unnamedSession")}
{/* The phase swaps in place: the words change, the row does not move.
The slot is a fixed width so the elapsed clock beside it never
shifts, and the entering label starts at 0.55 rather than 0 — if
the animation never runs, the single most important live signal on
the screen is still legible. */}
{phase}
{elapsed === null ? (
—
{t("cookieBot.live.notStartedYet")}
) : (
formatElapsed(elapsed)
)}