"use client"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import type { StreamActionView } from "../views/streamAction"; import { Button } from "./ui/button"; type Props = { trigger: () => Promise; buttonLabel: string; runningLabel?: string; /** * Used to derive the accessible name on the log box and queue banner * (e.g. label="Sync" → log gets aria-label="Sync output"). Falls back to * buttonLabel if not provided so callers don't have to thread it twice. */ label?: string; cancelAction?: (id: string) => Promise<{ ok: boolean }>; /** * Rendered inline with the action button (after Cancel and the job-id * span). Used to attach a per-button QueueControl. */ extraControls?: React.ReactNode; /** * Disables the action button regardless of running state. Used by callers * that need a typed-confirmation gate before allowing the action. */ disabled?: boolean; /** * Called when a run SETTLES — the job's stream has been read to the end (or * the trigger refused before one existed). `started` is false for a refusal. * * WHY THIS IS NOT "ON CLICK". A caller gating a destructive button behind a * dry run has to arm it when the dry run has ANSWERED, not when it was * asked: the trigger returns as soon as the job is enqueued, so flipping a * flag there lets an operator press Preview and then Evict before a single * count has appeared — which is the whole thing the gate exists to stop. */ onSettled?: (started: boolean) => void; }; type StatusPoll = { status: string; queueKey?: string; queuePosition?: number; }; export function StreamActionLog({ trigger, buttonLabel, runningLabel = "Running…", label, cancelAction, extraControls, disabled = false, onSettled, }: Props) { const accessibleName = label ?? buttonLabel; const router = useRouter(); // A click before hydration is SILENTLY DISCARDED — the server-rendered button // has no handler yet, so nothing fires: no request, no job, no log, no error. // On a heavy page that is a long window (the 39-video channel page takes // seconds to hydrate even in production, minutes under a dev server), and it // is the whole of STATE.md's "the pilot's un-created job": clicking "Digest // channel" produced no job record and an empty log, with no dedupe guard in // runManagedFunction to explain it — because the action was never invoked at // all. Reproduced against the real corpus: the first click fires no POST, a // second click a few seconds later starts the job normally. // // Disabling until mounted makes the swallowed click impossible rather than // merely unlikely, and it turns the failure from "nothing happened" into a // button that is visibly not ready yet. It also fixes the same latent race for // every other action built on this component (sync, download, transcribe), // and makes e2e clicks WAIT for enablement instead of losing the click — see // the pre-hydration lost-click that made the charts specs flaky. const [hydrated, setHydrated] = useState(false); useEffect(() => setHydrated(true), []); const [running, setRunning] = useState(false); const [log, setLog] = useState(""); const [error, setError] = useState(null); // When the failure is really a neutral notice (info: true), render it as a // status rather than a red error. const [errorIsInfo, setErrorIsInfo] = useState(false); const [jobId, setJobId] = useState(null); const [cancelling, setCancelling] = useState(false); const [poll, setPoll] = useState(null); const preRef = useRef(null); const stickToBottomRef = useRef(true); // After every log update, only scroll to the bottom if the user hasn't // scrolled away. useLayoutEffect avoids a visible flicker between paint // and the scroll adjustment. useLayoutEffect(() => { const el = preRef.current; if (!el || !stickToBottomRef.current) return; el.scrollTop = el.scrollHeight; }, [log]); const handleScroll = useCallback(() => { const el = preRef.current; if (!el) return; const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; stickToBottomRef.current = distFromBottom < 24; }, []); useEffect(() => { if (running) stickToBottomRef.current = true; }, [running]); // While a job is queued or running, poll the job log endpoint just to // surface queue/position info (the actual log content streams over the // ReadableStream). When the job becomes terminal, stop polling. useEffect(() => { if (!jobId || !running) return; let cancelled = false; let timer: ReturnType | null = null; async function tick() { try { const res = await fetch( `/api/jobs/${encodeURIComponent(jobId!)}/log?from=999999999`, { cache: "no-store" }, ); if (!res.ok) return; const data = (await res.json()) as StatusPoll; if (cancelled) return; setPoll(data); if (data.status === "queued" || data.status === "running") { timer = setTimeout(tick, 1000); } } catch { if (!cancelled) timer = setTimeout(tick, 2000); } } tick(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [jobId, running]); async function handleClick() { setError(null); setErrorIsInfo(false); setLog(""); setJobId(null); setPoll(null); stickToBottomRef.current = true; setRunning(true); let started = false; try { const result = await trigger(); if (!result.ok) { setError(result.error); setErrorIsInfo(result.info === true); return; } started = true; setJobId(result.jobId); const reader = result.stream.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; if (value) setLog((prev) => prev + value); } } catch (e) { setError((e as Error).message); } finally { setRunning(false); onSettled?.(started); // A run that actually started (success, mid-stream error, or cancel) // may have mutated server-derived data: a saved shard config, channel // counts, bucket lists, failed-transcription lists. Re-fetch the server // components so the page reflects it without a manual reload. The // streamed log and any typed inputs are client state and survive // router.refresh(). if (started) router.refresh(); } } async function handleCancel() { if (!jobId || !cancelAction) return; setCancelling(true); try { await cancelAction(jobId); } finally { setCancelling(false); } } return (
{running && jobId && cancelAction && ( )} {jobId && ( job {jobId} )} {extraControls}
{error && (
{error}
)} {running && poll?.status === "queued" && poll.queueKey && (
Queued in {poll.queueKey} {typeof poll.queuePosition === "number" && poll.queuePosition > 0 ? `, position ${poll.queuePosition}` : ""}
)} {(log || running) && (
          {log || "Waiting for output…"}
        
)}
); }