commit ac744ed118f3389b5edce6193272fa2e75e5443e
parent 6779e4e3789b805fb9767ed10737cd0031ab6761
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 11 Aug 2026 19:31:50 -0400
Carry in the uncommitted lane-card work from main
This is the working state of the main checkout at 6779e4e, committed here
unchanged so the branch has the baseline the lane-detail plan builds on:
the four lane cards (LaneCard/LaneDeck/LaneRail/laneState/LaneActionButton),
the sweep controls folded into them, and the digest/backfill chips already
removed from the pipeline band.
Not this branch's work — see the following commit for that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat:
15 files changed, 858 insertions(+), 491 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -3,6 +3,7 @@
## [Unreleased]
- **Cleanup now says what is holding the audio it can't reclaim, and what to run to get it back.** The page led with one number — how much space you can free right now — and said nothing about the rest of the disk. The sweep skips videos for four different reasons and reported them only as a line in a job log after the fact, with no bytes attached and nothing ranked. A **sieve** now runs down the page: all the audio on disk enters at the top, each gate siphons off its share (no transcript yet, the keep-latest window, do-not-clean pins, awaiting diarization), and the remainder steps down to the reclaimable figure the page already led with. A video leaves at the *first* gate it hits, exactly as the sweep's own cascade does, so the five figures are an attribution and never overlap — a pinned, undiarized video is counted once, under the pin. Below it, the **release ledger** ranks the channels holding the most, split by what it costs to get the space back: a run of a lane that is already weeks deep, or a setting that frees it the moment it changes. "Not counted" sits in the second group and is not called a hold — excluding a channel hides its bytes from the total, it never protected them, which makes it the fastest win on the page.
- **Some of that audio is held forever, and nothing anywhere said so.** The cleanup guard fires on `diarization.enabled` alone, but the diarize lane will never produce a sidecar for a video that is over `maxAudioHours`, has no diarizable input, or belongs to a channel whose diarization models were never configured — the kind reports itself disabled and no job is ever queued. Those videos were held from cleaning permanently, and running *Diarize speakers* until the end of time would not have moved the number. The sieve now draws that slice hatched inside gate ④ with its own figure and says it in words: *will not clear on its own*. Deciding what to do about it — raise the cap, configure the models, or let the sweep past the guard — is deliberately left to you: audio is the one input in this pipeline that cannot be regenerated. Channel is the honest granularity throughout; per-video byte sizes exist nowhere outside the report, and inventing them would have cost a corpus walk on every render.
+- **Every lane now has one card, and the card shows both of its switches.** Transcription, downloads, digest and backfill each get a card on the dashboard *and* in the monitor widget: the lane's name, its state, its figure, and every control it has, in one place. A hairline under the name reads left → right as feed → gate → lane, so a *held* lane — sweep armed, gate shut — draws as a lit feed running into a break, which is the state that used to look identical to "wedged". The figures that belong to a lane (digest coverage, backfill reachable / needs-media) moved onto their own card, out of the instrument row several elements away from the buttons that move them. The widget gains what it never had: starting and stopping a sweep, and any digest control at all.
- **A channel page now opens on the channel's whole lifecycle instead of a viewport of video rows.** The first screen is a single transit line — Playlist → Download → Transcode → Transcribe → Digest → Backfill — where each station carries its count and its coverage, and the gap between two stations carries the shortfall, because the gap *is* the work. Only the largest shortfall is emphasised, so the bottleneck is the thing your eye lands on. Work the lane can actually do today sits on the line; everything that left it (needs cookies, deleted, untranscribable, waiting on a transcript, needs media re-acquired) hangs below it in muted type, on a different axis, so the two can never be read as one number. Below the line: one brand-coloured **next action** that disappears when there is nothing to do, a strip of anomaly chips that says "All clear" rather than vanishing, and the stage panels — **one at a time**, chosen by a tab strip and carried in the URL as `?stage=`, so a stage is a link you can share. With no stage selected you get an overview listing every stage's status at once. Previously the page stacked a full-height video browser above eight simultaneously-expanded stage panels, which put the channel's actual pipeline a full screen and a collapsed `<details>` out of reach.
- **The video browser has moved to its own page and no longer builds a DOM node per video.** `/channels/<slug>/videos` is a two-pane workspace: a virtualized list on the left, the selected video's detail on the right, both always visible. The largest channel has ~11,000 videos and the old list rendered every one of them into the page; it now renders the twenty or so you can see. Filters, search and `?video=` selection work exactly as before. The old collapse toggles are gone — they existed only because the pane was squeezed onto a page it shared with everything else, and a page of its own removes the reason for them.
- **Command bookmarks are gone.** Saving a job under a label and re-launching it from the strip above **Jobs** / **Active jobs**, and the `/jobs/bookmarks` page that managed those labels, have all been removed. The feature was used for three days after it shipped and never again: every job it could re-launch is still one click away from its own channel-page control, and **Retry** — which re-runs a finished job straight from the descriptor stored with it — covers re-running something you have already run. Retry and **Retry all failed** are untouched and keep working exactly as before, including on jobs old enough to have been evicted from memory. Bucket jobs still re-derive their work from the channel's current state on every re-run rather than replaying a stale list. Your existing `transcripts/.bookmarks/bookmarks.json` is left on disk as a record; nothing reads it any more, and you can delete it whenever you like.
diff --git a/editor/app/components/dashboard/PipelineBand.tsx b/editor/app/components/dashboard/PipelineBand.tsx
@@ -6,10 +6,7 @@ import type { ActiveJobsPayload } from "../../jobs/active/buildActiveJobs";
import type { WorkersPayload } from "../../workers/components/WorkersView";
import type { WidgetSyncPayload } from "../../api/widget/sync/route";
import { ActiveJobsLive } from "../../jobs/components/ActiveJobsLive";
-import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton";
-import { PauseDownloadsButton } from "../../jobs/components/PauseDownloadsButton";
-import { DigestSweepControls } from "../../jobs/components/DigestSweepControls";
-import { BackfillSweepControls } from "../../jobs/components/BackfillSweepControls";
+import { LaneDeck } from "../lanes/LaneDeck";
import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions";
import { fmtTime } from "../../widget/lib/relativeTime";
import { formatBytes } from "yt-dlp-transcript-common/lib/format";
@@ -48,27 +45,10 @@ export function PipelineBand({
// now get separate instruments with their own wording.
const disk = jobs?.disk ?? null;
const diskLow = disk?.low ?? false;
- const digest = sync?.digest ?? null;
- // THE DENOMINATOR IS ELIGIBLE VIDEOS, NOT EVERY VIDEO DIRECTORY. `videos`
- // counts every directory in the corpus, ~1,700 of which have no transcript or
- // are marked untranscribable and so can never carry a digest — against that,
- // this bar could not reach 100% however long the sweep ran. `eligible` comes
- // from the digest operation's own work list (transcribed, not untranscribable,
- // not waiting on transcription), so it is the same population the sweep
- // actually walks.
- //
- // It falls back to `videos` while null, which is the state until every
- // channel's snapshot has been regenerated — see the sync payload. That keeps
- // the bar reading exactly as it did before rather than blank.
- const digestDenominator =
- digest === null ? 0 : (digest.eligible ?? digest.videos);
- // Deliberately not rounded up. At 0.13% a "1%" would be a lie of the kind
- // that makes an 80-day backfill look nearly begun.
- const digestPct =
- digest && digestDenominator > 0
- ? (digest.digested / digestDenominator) * 100
- : null;
- const backfill = sync?.backfill ?? null;
+ // The digest and backfill figures used to be two more instruments up here,
+ // several elements away from the buttons that moved them. They now sit inside
+ // their own lane cards — state, figure and both switches in one place — which
+ // is the whole point of LaneDeck.
const lastSyncText =
sync == null
@@ -186,87 +166,19 @@ export function PipelineBand({
}
/>
)}
- {backfill?.anyKind && (
- <Instrument
- dotClass={
- backfill.sweeping
- ? "bg-success"
- : backfill.enabled
- ? "bg-success/40"
- : "bg-muted-foreground/40"
- }
- label={
- <>
- {"\u21ba backfill "}
- <span className="font-medium">
- {backfill.reachable.toLocaleString()}
- </span>
- {" reachable"}
- {backfill.needsMedia > 0 && (
- <span className="text-muted-foreground">
- {" "}
- (+{backfill.needsMedia.toLocaleString()} need media)
- </span>
- )}
- {!backfill.enabled && (
- <span className="text-muted-foreground"> · lane off</span>
- )}
- {backfill.enabled && backfill.sweeping && " · sweeping"}
- </>
- }
- />
- )}
- {digest && (
- <Instrument
- dotClass={
- digest.paused
- ? "bg-destructive"
- : digest.sweeping
- ? "bg-success"
- : "bg-muted-foreground/40"
- }
- label={
- <>
- ◆ digests{" "}
- <span className="font-medium">
- {digest.digested.toLocaleString()}
- </span>
- {digestPct !== null && (
- <span className="text-muted-foreground">
- {" "}
- ({digestPct < 1 ? digestPct.toFixed(2) : digestPct.toFixed(1)}%
- of {digest.videos.toLocaleString()})
- </span>
- )}
- {digest.paused && (
- <span className="text-destructive"> · paused</span>
- )}
- {!digest.paused && digest.sweeping && " · sweeping"}
- </>
- }
- />
- )}
</div>
</div>
+ <LaneDeck
+ workers={workers}
+ sync={sync}
+ onWorkersChange={onWorkersChange}
+ onSynced={onSynced}
+ />
+
+ {/* What is left is not a lane: a one-shot sweep of every channel, and
+ the way in for a channel that does not exist yet. */}
<div className="flex flex-wrap items-center gap-2">
- <PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} />
- <PauseDownloadsButton
- paused={downloadsPaused}
- onChange={onWorkersChange}
- />
- <DigestSweepControls
- sweeping={digest?.sweeping ?? false}
- paused={digest?.paused ?? false}
- onChange={onSynced}
- />
- {backfill?.anyKind && (
- <BackfillSweepControls
- sweeping={backfill.sweeping}
- laneEnabled={backfill.enabled}
- onChange={onSynced}
- />
- )}
<SyncAllButton onSynced={onSynced} />
<Link
href="/channels/new"
diff --git a/editor/app/components/lanes/LaneActionButton.tsx b/editor/app/components/lanes/LaneActionButton.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useEffect, useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+// THE button. Six components used to carry a copy of everything below — the same
+// two class strings, the same hydration guard, the same run() and the same
+// inline error span — and copies of a hydration guard are exactly the kind of
+// thing that goes missing in one place.
+//
+// `ariaLabel` is explicit and required because these labels are a CONTRACT: the
+// e2e suite selects several of these buttons by accessible name, and in the
+// dense (rail) frame the visible label collapses to a glyph, at which point the
+// aria-label is the only name left.
+
+export type LaneActionResult = { ok: boolean; error?: string } | void;
+
+export function LaneActionButton({
+ label,
+ glyph,
+ ariaLabel,
+ title,
+ variant,
+ disabled,
+ dense,
+ action,
+ onChange,
+}: {
+ label: string;
+ // What the label collapses to in a dense frame. Falls back to the first
+ // character, which is never what you want — pass one.
+ glyph?: string;
+ ariaLabel: string;
+ title?: string;
+ // `active` = this control is the one holding/stopping something right now
+ // (Resume, Stop): warning-filled, so a held lane is visible from across the
+ // room. `idle` = the quiet outline.
+ variant: "active" | "idle";
+ disabled?: boolean;
+ dense?: boolean;
+ action: () => Promise<LaneActionResult>;
+ onChange?: () => void | Promise<void>;
+}) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ const router = useRouter();
+ // Disabled until hydrated. A click on a server-rendered button before React
+ // attaches fires NOTHING — no request, no job, no error — which is the
+ // recorded root cause of the digest pilot's "un-created job".
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+
+ function run() {
+ setError(null);
+ startTransition(async () => {
+ const result = await action();
+ if (result && !result.ok) setError(result.error ?? "Failed.");
+ if (onChange) await onChange();
+ else router.refresh();
+ });
+ }
+
+ const busy = pending || !mounted;
+ const tone =
+ variant === "active"
+ ? "bg-warning text-warning-foreground font-medium hover:bg-warning/90"
+ : "border border-border hover:bg-muted";
+ const size = dense
+ ? "size-6 rounded-md text-xs leading-none"
+ : "px-3 py-1.5 rounded-md text-sm";
+
+ return (
+ <>
+ <button
+ type="button"
+ disabled={busy || disabled}
+ aria-label={ariaLabel}
+ onClick={run}
+ title={title ?? label}
+ className={`inline-flex items-center justify-center ${size} ${tone} disabled:opacity-50`}
+ >
+ {dense ? (glyph ?? label.slice(0, 1)) : label}
+ </button>
+ {error && (
+ <span role="alert" className="text-xs text-destructive">
+ {error}
+ </span>
+ )}
+ </>
+ );
+}
diff --git a/editor/app/components/lanes/LaneCard.tsx b/editor/app/components/lanes/LaneCard.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useSectionFrame } from "../../widget/components/WidgetSection";
+import { LaneRail } from "./LaneRail";
+import {
+ LANE_DOT,
+ LANE_TEXT,
+ LANE_WORD,
+ type LaneState,
+} from "./laneState";
+import { LaneActionButton, type LaneActionResult } from "./LaneActionButton";
+
+// One lane: its name, its state, its figure and BOTH its switches, in one place.
+//
+// The card exists because those four things used to live in four places — a
+// status word in a grey span after the buttons, the numbers it referred to in
+// chips several elements away (the dashboard) or in a strip that could be
+// dragged to another corner entirely (the widget). An operator asking "is the
+// backfill actually doing anything" had to assemble the answer.
+//
+// RESTRAINT: the surface stays `bg-card` in every state. The rail says it and
+// the dot says it; a third tinted background would be the accessory to remove.
+
+export type LaneControl = {
+ key: string;
+ label: string;
+ glyph: string;
+ ariaLabel: string;
+ title?: string;
+ variant: "active" | "idle";
+ disabled?: boolean;
+ action: () => Promise<LaneActionResult>;
+ onChange?: () => void | Promise<void>;
+};
+
+export function LaneCard({
+ name,
+ state,
+ figure,
+ note,
+ controls,
+}: {
+ name: string;
+ state: LaneState;
+ // The lane's own number, in the house's tabular mono. For a lane with no sweep
+ // this is the sentence saying so.
+ figure: ReactNode;
+ // Anything the card must keep saying in words — the backfill lane's "the sweep
+ // is holding", which the rail now also draws.
+ note?: ReactNode;
+ controls: LaneControl[];
+}) {
+ // The widget provides this per cell; outside the widget the default is
+ // {scroll:false, dense:false}, so the dashboard needs no provider.
+ const { dense } = useSectionFrame();
+
+ const head = (
+ <span className="flex items-center gap-1.5 text-xs">
+ <span
+ className={`inline-block h-2 w-2 shrink-0 rounded-full ${LANE_DOT[state]}`}
+ />
+ <span className={LANE_TEXT[state]}>{LANE_WORD[state]}</span>
+ </span>
+ );
+
+ if (dense) {
+ // A rail cell has room for one line. The rail becomes the left EDGE — same
+ // reading, turned ninety degrees — and the buttons collapse to glyphs, whose
+ // aria-labels carry the meaning they always did.
+ return (
+ <div
+ role="group"
+ aria-label={`${name.toLowerCase()} lane`}
+ className="flex items-center gap-1.5 text-xs"
+ >
+ <span
+ aria-hidden="true"
+ className={`h-4 w-0.5 shrink-0 ${
+ state === "running"
+ ? "bg-info"
+ : state === "holding"
+ ? "bg-warning"
+ : "bg-border"
+ }`}
+ />
+ <span className="font-mono uppercase tracking-[0.14em] text-muted-foreground">
+ {name}
+ </span>
+ {head}
+ <span className="font-mono tabular-nums text-muted-foreground">
+ {figure}
+ </span>
+ {/* Kept even here. The rail-as-edge cannot draw a break, so in the one
+ frame where the drawing says least, the words are all there is. */}
+ {note}
+ {controls.map(({ key, ...c }) => (
+ <LaneActionButton key={key} dense {...c} />
+ ))}
+ </div>
+ );
+ }
+
+ return (
+ <div
+ role="group"
+ aria-label={`${name.toLowerCase()} lane`}
+ className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2.5"
+ >
+ <div className="flex items-baseline justify-between gap-2">
+ <h3 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ {name}
+ </h3>
+ {head}
+ </div>
+ <LaneRail state={state} />
+ <p className="font-mono text-xs tabular-nums text-muted-foreground">
+ {figure}
+ </p>
+ {note}
+ {controls.length > 0 && (
+ <div className="flex flex-wrap items-center gap-1.5">
+ {controls.map(({ key, ...c }) => (
+ <LaneActionButton key={key} {...c} />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx
@@ -0,0 +1,377 @@
+"use client";
+
+import type { WidgetSyncPayload } from "../../api/widget/sync/route";
+import type { WorkersPayload } from "../../workers/components/WorkersView";
+import { useSectionFrame } from "../../widget/components/WidgetSection";
+import { LaneCard, type LaneControl } from "./LaneCard";
+import { deriveLaneState, formatCount } from "./laneState";
+import {
+ pauseAllWorkersAction,
+ resumeAllWorkersAction,
+} from "../../workers/actions";
+import {
+ pauseBackfillAction,
+ pauseDigestsAction,
+ pauseDownloadsAction,
+ resumeBackfillAction,
+ resumeDigestsAction,
+ resumeDownloadsAction,
+ startBackfillSweepAction,
+ startDigestSweepAction,
+ stopBackfillSweepAction,
+ stopDigestSweepAction,
+} from "../../jobs/actions";
+
+// The four lanes, and the ONLY place lane → server-action wiring lives. Rendered
+// identically by the dashboard band and the monitor widget's controls section,
+// which is what stops the two surfaces from growing different ideas about what
+// a lane can do (the widget could not start or stop a sweep at all, and had no
+// digest control whatsoever).
+//
+// Each lane arrives on the wire in its own shape — transcription and downloads
+// have a pause and no sweep, digest has `sweeping` + `paused`, backfill has
+// `sweeping` + `enabled` (an inverted pause) — and this is where all four are
+// normalised onto { gateHeld, feedRunning }. See laneState.ts for why those two
+// are the axes.
+
+export function LaneDeck({
+ workers,
+ sync,
+ onWorkersChange,
+ onSynced,
+}: {
+ workers: WorkersPayload | null;
+ sync: WidgetSyncPayload | null;
+ onWorkersChange: () => void | Promise<void>;
+ onSynced: () => void | Promise<void>;
+}) {
+ const { dense } = useSectionFrame();
+
+ const paused = workers?.paused ?? false;
+ const downloadsPaused = workers?.downloadsPaused ?? false;
+ const busy = (workers?.workers ?? []).filter((w) => w.busy).length;
+ const digest = sync?.digest ?? null;
+ const backfill = sync?.backfill ?? null;
+
+ // THE DENOMINATOR IS ELIGIBLE VIDEOS, NOT EVERY VIDEO DIRECTORY. `videos`
+ // counts every directory in the corpus, ~1,700 of which have no transcript or
+ // are marked untranscribable and so can never carry a digest — against that,
+ // this figure could not reach 100% however long the sweep ran. `eligible`
+ // comes from the digest operation's own work list (transcribed, not
+ // untranscribable, not waiting on transcription), so it is the same population
+ // the sweep actually walks.
+ //
+ // It falls back to `videos` while null, which is the state until every
+ // channel's snapshot has been regenerated — see the sync payload.
+ const digestDenominator =
+ digest === null ? 0 : (digest.eligible ?? digest.videos);
+ // Deliberately not rounded up. At 0.13% a "1%" would be a lie of the kind that
+ // makes an 80-day backfill look nearly begun.
+ const digestPct =
+ digest && digestDenominator > 0
+ ? (digest.digested / digestDenominator) * 100
+ : null;
+
+ // ── Transcription ─────────────────────────────────────────────────────────
+ // Global pause/resume for transcription workers. Pausing stops handing out new
+ // work AND gracefully stops in-flight parakeet jobs after the current segment
+ // (cached for resume); other engines run their in-flight file to completion.
+ const transcription = (
+ <LaneCard
+ name="Transcription"
+ state={deriveLaneState({ gateHeld: paused, activeCount: busy })}
+ figure="no sweep — work arrives from jobs"
+ controls={[
+ paused
+ ? {
+ key: "transcription",
+ label: "Resume Transcriptions",
+ glyph: "▶",
+ ariaLabel: "Resume Transcriptions",
+ variant: "active",
+ action: resumeAllWorkersAction,
+ onChange: onWorkersChange,
+ }
+ : {
+ key: "transcription",
+ label: "Pause Transcriptions",
+ glyph: "❙❙",
+ ariaLabel: "Pause Transcriptions",
+ title:
+ "Stop taking new work; in-flight parakeet jobs finish the current segment, cache it, and resume on the next run",
+ variant: "idle",
+ action: pauseAllWorkersAction,
+ onChange: onWorkersChange,
+ },
+ ]}
+ />
+ );
+
+ // ── Downloads ─────────────────────────────────────────────────────────────
+ // Persisted in settings.json (downloadsPaused — survives a restart). Pausing
+ // gates the auto-download runner on its next loop iteration and makes manual
+ // download-bearing pipeline actions return a "Downloads are paused" notice;
+ // store-playlist/enumeration stay allowed.
+ const downloads = (
+ <LaneCard
+ name="Downloads"
+ state={deriveLaneState({ gateHeld: downloadsPaused })}
+ figure="no sweep — work arrives from jobs"
+ controls={[
+ downloadsPaused
+ ? {
+ key: "downloads",
+ label: "Resume Downloads",
+ glyph: "▶",
+ ariaLabel: "Resume Downloads",
+ variant: "active",
+ action: resumeDownloadsAction,
+ onChange: onWorkersChange,
+ }
+ : {
+ key: "downloads",
+ label: "Pause Downloads",
+ glyph: "❙❙",
+ ariaLabel: "Pause Downloads",
+ title:
+ "Stop new downloads: the auto-download runner idles and manual sync/download actions are blocked until resumed. Enumeration (store-playlist) still runs.",
+ variant: "idle",
+ action: pauseDownloadsAction,
+ onChange: onWorkersChange,
+ },
+ ]}
+ />
+ );
+
+ // ── Digest ────────────────────────────────────────────────────────────────
+ // TWO CONTROLS, AND THEY ARE NOT THE SAME CONTROL — conflating them is how an
+ // operator loses a week of GPU time:
+ //
+ // Sweep on/off — is there a corpus-wide backfill at all. Persisted, so a
+ // server restart resumes it (editor/instrumentation.ts).
+ // Pause/resume — hold a running sweep at zero throughput without ending it.
+ // The batch's limit() returns 0, which makes the pool idle-WAIT rather
+ // than finish, so resuming costs nothing and re-derives nothing.
+ //
+ // Stopping the sweep drains rather than cancels: the channel in flight
+ // finishes instead of losing a part-generated video.
+ const digestSweeping = digest?.sweeping ?? false;
+ const digestPaused = digest?.paused ?? false;
+ const digestControls: LaneControl[] = [
+ digestSweeping
+ ? {
+ key: "digest-sweep",
+ label: "Stop Digest Sweep",
+ glyph: "■",
+ ariaLabel: "stop digest sweep",
+ title:
+ "Stop the corpus-wide digest backfill. The channel in flight finishes first; nothing already generated is lost.",
+ variant: "active",
+ action: stopDigestSweepAction,
+ onChange: onSynced,
+ }
+ : {
+ key: "digest-sweep",
+ label: "Start Digest Sweep",
+ glyph: "⟳",
+ ariaLabel: "start digest sweep",
+ title:
+ "Start the corpus-wide digest backfill: every channel in turn, heaviest first by remaining audio-hours. Survives a restart.",
+ variant: "idle",
+ action: startDigestSweepAction,
+ onChange: onSynced,
+ },
+ ];
+ // The pause only appears once there is something to hold — or once it is
+ // already holding, which is the state you have to be able to get out of.
+ if (digestSweeping || digestPaused) {
+ digestControls.push(
+ digestPaused
+ ? {
+ key: "digest-gate",
+ label: "Resume Digests",
+ glyph: "▶",
+ ariaLabel: "resume digests",
+ title:
+ "Resume digest generation. The running job picks up where it left off — it was holding, not stopped.",
+ variant: "active",
+ action: resumeDigestsAction,
+ onChange: onSynced,
+ }
+ : {
+ key: "digest-gate",
+ label: "Pause Digests",
+ glyph: "❙❙",
+ ariaLabel: "pause digests",
+ title:
+ "Hold digest generation without ending the sweep. The running job idles at zero and resumes instantly.",
+ variant: "idle",
+ action: pauseDigestsAction,
+ onChange: onSynced,
+ },
+ );
+ }
+
+ const digestLane = (
+ <LaneCard
+ name="Digest"
+ state={deriveLaneState({
+ gateHeld: digestPaused,
+ feedRunning: digestSweeping,
+ })}
+ figure={
+ digest === null ? (
+ "—"
+ ) : (
+ <>
+ {formatCount(digest.digested)} digested
+ {digestPct !== null && (
+ <>
+ {" · "}
+ {digestPct < 1 ? digestPct.toFixed(2) : digestPct.toFixed(1)}% of{" "}
+ {digestDenominator.toLocaleString()}
+ </>
+ )}
+ </>
+ )
+ }
+ controls={digestControls}
+ />
+ );
+
+ // ── Backfill ──────────────────────────────────────────────────────────────
+ // Arm / disarm the corpus-wide backfill sweep, and hold it without ending it.
+ //
+ // TWO CONTROLS, AND THIS REVISED AN EARLIER DECISION. The backfill lane used
+ // to carry only the sweep, on the reasoning that at the default weight the
+ // lane is ALREADY idle-only — standing aside whenever transcription works — so
+ // a manual pause looked redundant.
+ //
+ // That covered the wrong hazard. Idle-only yielding handles "get out of the
+ // transcription lane's way"; it does nothing for "this is a desktop someone is
+ // sitting at, and diarization pins four cores for hours." Backfill work is
+ // CPU-bound and long — a diarization pass over the corpus runs for days — so
+ // the operator needs a hold for reasons the scheduler cannot see.
+ //
+ // The pause writes THE SAME FIELD the Settings checkbox writes
+ // (settings.backfill.enabled) rather than a new `backfillPaused` flag. One
+ // field, several places to set it, and they cannot drift — which is why
+ // backfill.spec asserts the settings field through this button's label rather
+ // than just watching the label flip.
+ //
+ // Stopping the SWEEP, by contrast, drains rather than cancels: the video in
+ // flight finishes instead of being thrown away, and the stop reaches the
+ // per-channel job the sweep is waiting on rather than meaning "after this
+ // channel". Pause when you want it back; stop when you don't.
+ const backfillSweeping = backfill?.sweeping ?? false;
+ const laneEnabled = backfill?.enabled ?? false;
+ const backfillAvailable = backfill?.anyKind ?? false;
+ const backfillControls: LaneControl[] = backfillAvailable
+ ? [
+ backfillSweeping
+ ? {
+ key: "backfill-sweep",
+ label: "Stop Backfill Sweep",
+ glyph: "■",
+ ariaLabel: "stop backfill sweep",
+ title:
+ "Stop the corpus-wide backfill. The channel in flight finishes first; nothing already written is lost.",
+ variant: "active",
+ action: stopBackfillSweepAction,
+ onChange: onSynced,
+ }
+ : {
+ key: "backfill-sweep",
+ label: "Start Backfill Sweep",
+ glyph: "⟳",
+ ariaLabel: "start backfill sweep",
+ title:
+ "Start the corpus-wide backfill: every channel in turn, heaviest first by reachable work. Survives a restart.",
+ variant: "idle",
+ action: () => startBackfillSweepAction(),
+ onChange: onSynced,
+ },
+ laneEnabled
+ ? {
+ key: "backfill-gate",
+ label: "Pause Backfill",
+ glyph: "❙❙",
+ ariaLabel: "pause backfill",
+ title:
+ "Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart.",
+ variant: "idle",
+ action: pauseBackfillAction,
+ onChange: onSynced,
+ }
+ : {
+ key: "backfill-gate",
+ label: "Resume Backfill",
+ glyph: "▶",
+ ariaLabel: "resume backfill",
+ title:
+ "Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped.",
+ variant: "active",
+ action: resumeBackfillAction,
+ onChange: onSynced,
+ },
+ ]
+ : [];
+
+ const backfillLane = (
+ <LaneCard
+ name="Backfill"
+ // `anyKind` false is UNAVAILABLE, not idle: with no backfill feature
+ // registered there is nothing to hold, and an empty work list because a
+ // feature is switched off must not read as "all caught up".
+ state={deriveLaneState({
+ available: backfillAvailable,
+ gateHeld: !laneEnabled,
+ feedRunning: backfillSweeping,
+ })}
+ figure={
+ backfill === null || !backfillAvailable ? (
+ "no backfill feature is on"
+ ) : (
+ <>
+ {formatCount(backfill.reachable)} reachable
+ {/* A SEPARATE FIGURE, never summed into the one beside it: on the
+ measured corpus these are 835 and ~76,270, and one total would
+ report the work as untouched forever. */}
+ {backfill.needsMedia > 0 && (
+ <> · {formatCount(backfill.needsMedia)} need media</>
+ )}
+ </>
+ )
+ }
+ note={
+ backfillSweeping && !laneEnabled ? (
+ // Kept in words as well as in the rail. A sweep armed with the lane
+ // off holds at a zero limit rather than doing work, and "wedged" is
+ // what that looks like to anyone who does not already know.
+ <span
+ aria-label="backfill lane off"
+ className="text-xs text-muted-foreground"
+ >
+ the sweep is holding
+ </span>
+ ) : undefined
+ }
+ controls={backfillControls}
+ />
+ );
+
+ return (
+ <div
+ className={
+ dense
+ ? "flex flex-wrap items-center gap-x-3 gap-y-1"
+ : "grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4"
+ }
+ >
+ {transcription}
+ {downloads}
+ {digestLane}
+ {backfillLane}
+ </div>
+ );
+}
diff --git a/editor/app/components/lanes/LaneRail.tsx b/editor/app/components/lanes/LaneRail.tsx
@@ -0,0 +1,43 @@
+import type { LaneState } from "./laneState";
+
+// The signature element: a hairline that reads left → right as FEED → GATE →
+// LANE. It exists to say the thing the status word cannot — *where* the flow
+// stops. A held lane is not a stopped one, and the difference is a break at the
+// gate with the feed still lit behind it.
+//
+// Continuous with the channel page's transit line rather than imported from
+// anywhere: same tokens, same dot vocabulary, same single animation.
+//
+// aria-hidden by construction. The dot and the state word beside it carry the
+// meaning; this is the drawing of it.
+export function LaneRail({ state }: { state: LaneState }) {
+ return (
+ <div aria-hidden="true" className="flex h-2 items-center">
+ {state === "running" && (
+ <>
+ <span className="h-0.5 flex-1 bg-info" />
+ {/* The gate, open: the pulse is sitting in it. */}
+ <span className="relative flex h-2 w-0.5 items-center justify-center bg-info">
+ <span className="absolute h-1.5 w-1.5 rounded-full bg-info animate-pulse motion-reduce:animate-none" />
+ </span>
+ <span className="h-0.5 flex-[2] bg-info" />
+ </>
+ )}
+ {state === "holding" && (
+ <>
+ {/* Feed still running… */}
+ <span className="h-0.5 flex-1 bg-warning" />
+ {/* …into a shut gate… */}
+ <span className="h-2 w-0.5 bg-warning" />
+ {/* …and the break is the point: nothing is moving past it. */}
+ <span className="w-2" />
+ <span className="h-0 flex-[2] border-t border-dashed border-border" />
+ </>
+ )}
+ {state === "idle" && (
+ <span className="h-0 flex-1 border-t border-dashed border-border" />
+ )}
+ {state === "unavailable" && <span className="h-px flex-1 bg-border" />}
+ </div>
+ );
+}
diff --git a/editor/app/components/lanes/laneState.ts b/editor/app/components/lanes/laneState.ts
@@ -0,0 +1,79 @@
+import {
+ STATION_DOT,
+ STATION_TEXT,
+ formatCount,
+} from "../../channels/[slug]/components/flow/tone";
+
+// A lane's state, and the one derivation every surface reads it from.
+//
+// THE POINT OF THIS TYPE IS THAT A LANE HAS TWO SWITCHES, NOT ONE. There is the
+// SWEEP — the producer that feeds the lane work — and the GATE — the pause that
+// decides whether the lane consumes it. They are independent, and the state
+// nobody had a word for is the combination "sweep armed, gate shut": the lane is
+// not stopped and it is not working. That is `holding`, and it is the whole
+// reason this is four states rather than a boolean.
+//
+// The four lanes model themselves four different ways on the wire (transcription
+// and downloads have a pause and no sweep; digest has `sweeping` + `paused`;
+// backfill has `sweeping` + `enabled`, an INVERTED pause). Normalising happens
+// once, in LaneDeck, and everything downstream reads only this.
+export type LaneState = "running" | "holding" | "idle" | "unavailable";
+
+export function deriveLaneState({
+ available = true,
+ gateHeld,
+ feedRunning = false,
+ activeCount = 0,
+}: {
+ // The lane exists at all. `false` is not "idle": with no backfill feature
+ // registered there is nothing to hold, and an idle-looking lane would read as
+ // "all caught up" when the truth is "switched off".
+ available?: boolean;
+ gateHeld: boolean;
+ // A corpus-wide sweep is armed. Absent for the lanes fed by jobs instead.
+ feedRunning?: boolean;
+ // Work in flight right now, for lanes with no sweep of their own.
+ activeCount?: number;
+}): LaneState {
+ if (!available) return "unavailable";
+ // The gate decides first. A sweep armed behind a shut gate is HOLDING, not
+ // running — the batch's limit() returns 0 and the pool idle-waits, which looks
+ // exactly like wedged unless something says otherwise.
+ if (gateHeld) return "holding";
+ if (feedRunning || activeCount > 0) return "running";
+ return "idle";
+}
+
+// No new palette. These map onto the station tones the channel line already
+// uses (see flow/tone.ts): four theme families × light/dark means a bespoke hue
+// here would be wrong in eight palettes at once. Deliberately NOT a second copy
+// of those maps — flow/OverviewPanel already made one, and three would be a
+// guarantee they drift.
+export const LANE_DOT: Record<LaneState, string> = {
+ // The travelling pulse, paired with motion-reduce as everything animated here
+ // is.
+ running: `${STATION_DOT.running} motion-reduce:animate-none`,
+ holding: STATION_DOT.attention,
+ idle: STATION_DOT.neutral,
+ unavailable: STATION_DOT.neutral,
+};
+
+export const LANE_TEXT: Record<LaneState, string> = {
+ running: STATION_TEXT.running,
+ holding: STATION_TEXT.attention,
+ idle: STATION_TEXT.neutral,
+ unavailable: STATION_TEXT.neutral,
+};
+
+// The word beside the dot. This — not the rail — is what assistive tech reads,
+// so it has to stand on its own.
+export const LANE_WORD: Record<LaneState, string> = {
+ running: "Running",
+ holding: "Holding",
+ idle: "Idle",
+ unavailable: "Off",
+};
+
+// Re-exported so a lane figure formats its counts the same way a station does,
+// including the "—, never 0" rule: a zero would claim a measurement nobody took.
+export { formatCount };
diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts
@@ -288,11 +288,12 @@ export async function stopBackfillSweepAction(): Promise<BackfillSweepResult> {
//
// So: one field, two places to set it, and they cannot drift.
//
-// This REVISES the "one button, not two" note in BackfillSweepControls.tsx.
-// That reasoning was that idle-only weighting made a manual hold unnecessary —
-// true for standing aside from transcription, but it gives an operator no way
-// to stop a long CPU-bound backfill for reasons of their own (the machine is a
-// desktop someone is using). See that file's header for the revised rationale.
+// This REVISES an earlier "one button, not two" decision. That reasoning was
+// that idle-only weighting made a manual hold unnecessary — true for standing
+// aside from transcription, but it gives an operator no way to stop a long
+// CPU-bound backfill for reasons of their own (the machine is a desktop someone
+// is using). See the backfill section of app/components/lanes/LaneDeck.tsx for
+// the revised rationale.
//
// Needs no boot hook: the flag is consulted at dispatch, not applied to a live
// pool, and it is already persisted in settings.json.
diff --git a/editor/app/jobs/components/BackfillSweepControls.tsx b/editor/app/jobs/components/BackfillSweepControls.tsx
@@ -1,115 +0,0 @@
-"use client";
-
-import { useEffect, useState, useTransition } from "react";
-import { useRouter } from "next/navigation";
-import {
- startBackfillSweepAction,
- stopBackfillSweepAction,
-} from "../actions";
-import { PauseBackfillButton } from "./PauseBackfillButton";
-
-// Arm / disarm the corpus-wide backfill sweep, and hold it without ending it.
-//
-// TWO BUTTONS, AND THIS REVISES AN EARLIER DECISION. This component used to
-// carry one button and a note explaining why: at the default weight the lane is
-// ALREADY idle-only, standing aside whenever transcription works, so a manual
-// pause looked redundant, and a second control was thought to invite exactly the
-// confusion DigestSweepControls' pair exists to prevent.
-//
-// That reasoning covered the wrong hazard. Idle-only yielding handles "get out
-// of the transcription lane's way"; it does nothing for "this is a desktop
-// someone is sitting at, and diarization pins four cores for hours." Backfill
-// work is CPU-bound and long — a diarization pass over the corpus runs for
-// days — so the operator needs a hold for reasons the scheduler cannot see. The
-// hold already existed; it was only reachable from the Settings page, which is
-// a strange place to look for a control over a job you are watching run.
-//
-// So the pause is surfaced here, and it writes THE SAME FIELD the Settings
-// checkbox writes (settings.backfill.enabled) rather than a new `backfillPaused`
-// flag. One field, several places to set it, and they cannot drift. See
-// pauseBackfillAction in ../actions for why that is the right field.
-//
-// The pause button itself now lives in ./PauseBackfillButton — the monitor
-// widget's controls row wants it too, and one implementation is what keeps the
-// two surfaces honest. Everything below is the SWEEP.
-//
-// Stopping the SWEEP, by contrast, drains rather than cancels: the video in
-// flight finishes instead of being thrown away, and the stop reaches the
-// per-channel job the sweep is waiting on rather than meaning "after this
-// channel". Pause when you want it back; stop when you don't.
-export function BackfillSweepControls({
- sweeping,
- laneEnabled,
- onChange,
-}: {
- sweeping: boolean;
- // settings.backfill.enabled. A sweep can be armed with the lane off — the
- // batch then holds at a zero limit rather than doing work — so say so instead
- // of letting it look wedged.
- laneEnabled: boolean;
- onChange?: () => void | Promise<void>;
-}) {
- const [pending, startTransition] = useTransition();
- const [error, setError] = useState<string | null>(null);
- const router = useRouter();
- // Disabled until hydrated. A click on a server-rendered button before React
- // attaches fires NOTHING — no request, no job, no error — which is the
- // recorded root cause of the digest pilot's "un-created job".
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
-
- function run(fn: () => Promise<{ ok: boolean; error?: string }>) {
- setError(null);
- startTransition(async () => {
- const result = await fn();
- if (!result.ok) setError(result.error ?? "Failed.");
- if (onChange) await onChange();
- else router.refresh();
- });
- }
-
- const disabled = pending || !mounted;
-
- return (
- <div className="flex items-center gap-2">
- <button
- type="button"
- disabled={disabled}
- aria-label={sweeping ? "stop backfill sweep" : "start backfill sweep"}
- onClick={() =>
- run(
- sweeping
- ? stopBackfillSweepAction
- : () => startBackfillSweepAction(),
- )
- }
- title={
- sweeping
- ? "Stop the corpus-wide backfill. The channel in flight finishes first; nothing already written is lost."
- : "Start the corpus-wide backfill: every channel in turn, heaviest first by reachable work. Survives a restart."
- }
- className={
- sweeping
- ? "px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50"
- : "px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50"
- }
- >
- {sweeping ? "Stop Backfill Sweep" : "Start Backfill Sweep"}
- </button>
- <PauseBackfillButton paused={!laneEnabled} onChange={onChange} />
- {sweeping && !laneEnabled && (
- <span
- aria-label="backfill lane off"
- className="text-xs text-muted-foreground"
- >
- the sweep is holding
- </span>
- )}
- {error && (
- <span role="alert" className="text-xs text-destructive">
- {error}
- </span>
- )}
- </div>
- );
-}
diff --git a/editor/app/jobs/components/DigestSweepControls.tsx b/editor/app/jobs/components/DigestSweepControls.tsx
@@ -1,104 +0,0 @@
-"use client";
-
-import { useEffect, useState, useTransition } from "react";
-import { useRouter } from "next/navigation";
-import {
- pauseDigestsAction,
- resumeDigestsAction,
- startDigestSweepAction,
- stopDigestSweepAction,
-} from "../actions";
-
-// The two digest controls, side by side, because they are NOT the same control
-// and conflating them is how an operator loses a week of GPU time:
-//
-// Sweep on/off — is there a corpus-wide backfill at all. Persisted, so a
-// server restart resumes it (editor/instrumentation.ts).
-// Pause/resume — hold a running sweep at zero throughput without ending it.
-// The batch's limit() returns 0, which makes the pool idle-WAIT rather than
-// finish, so resuming costs nothing and re-derives nothing.
-//
-// Stopping the sweep drains rather than cancels: the channel in flight finishes
-// instead of losing a part-generated video.
-export function DigestSweepControls({
- sweeping,
- paused,
- onChange,
-}: {
- sweeping: boolean;
- paused: boolean;
- onChange?: () => void | Promise<void>;
-}) {
- const [pending, startTransition] = useTransition();
- const [error, setError] = useState<string | null>(null);
- const router = useRouter();
- // Disabled until hydrated. A click on a server-rendered button before React
- // attaches fires nothing at all — no request, no job, no error — which is the
- // recorded root cause of the digest pilot's "un-created job".
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
-
- function run(fn: () => Promise<{ ok: boolean; error?: string }>) {
- setError(null);
- startTransition(async () => {
- const result = await fn();
- if (!result.ok) setError(result.error ?? "Failed.");
- if (onChange) await onChange();
- else router.refresh();
- });
- }
-
- const disabled = pending || !mounted;
-
- return (
- <div className="flex items-center gap-2">
- <button
- type="button"
- disabled={disabled}
- aria-label={sweeping ? "stop digest sweep" : "start digest sweep"}
- onClick={() =>
- run(sweeping ? stopDigestSweepAction : startDigestSweepAction)
- }
- title={
- sweeping
- ? "Stop the corpus-wide digest backfill. The channel in flight finishes first; nothing already generated is lost."
- : "Start the corpus-wide digest backfill: every channel in turn, heaviest first by remaining audio-hours. Survives a restart."
- }
- className={
- sweeping
- ? "px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50"
- : "px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50"
- }
- >
- {sweeping ? "Stop Digest Sweep" : "Start Digest Sweep"}
- </button>
- {(sweeping || paused) && (
- <button
- type="button"
- disabled={disabled}
- aria-label={paused ? "resume digests" : "pause digests"}
- onClick={() =>
- run(paused ? resumeDigestsAction : pauseDigestsAction)
- }
- title={
- paused
- ? "Resume digest generation. The running job picks up where it left off — it was holding, not stopped."
- : "Hold digest generation without ending the sweep. The running job idles at zero and resumes instantly."
- }
- className={
- paused
- ? "px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50"
- : "px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50"
- }
- >
- {paused ? "Resume Digests" : "Pause Digests"}
- </button>
- )}
- {error && (
- <span role="alert" className="text-xs text-destructive">
- {error}
- </span>
- )}
- </div>
- );
-}
diff --git a/editor/app/jobs/components/PauseBackfillButton.tsx b/editor/app/jobs/components/PauseBackfillButton.tsx
@@ -1,16 +1,10 @@
"use client";
-import { useEffect, useState, useTransition } from "react";
-import { useRouter } from "next/navigation";
+import { LaneActionButton } from "../../components/lanes/LaneActionButton";
import { pauseBackfillAction, resumeBackfillAction } from "../actions";
// Hold the backfill lane without ending anything, in the prop shape the other
-// two pause buttons use ({ paused, disabled?, onChange? }) so all three can sit
-// side by side wherever work is being watched — the dashboard cockpit, and now
-// the monitor widget's controls row.
-//
-// Extracted from BackfillSweepControls, which still renders it: one
-// implementation, so the dashboard and the widget cannot drift.
+// two pause buttons use ({ paused, disabled?, onChange? }).
//
// It is a REAL pause, not a stop. backfillBatch's limit() re-reads
// settings.backfill.enabled at dispatch (~3s idle poll) and returns 0, so the
@@ -19,6 +13,10 @@ import { pauseBackfillAction, resumeBackfillAction } from "../actions";
// rather than a second `backfillPaused` flag, so the two cannot disagree — which
// is why backfill.spec asserts the settings field through this button's label
// rather than just the label flipping.
+//
+// A thin wrapper over the shared lane button: the dashboard and the widget both
+// reach this gate through LaneDeck, and this keeps a direct call site available
+// without a second implementation.
export function PauseBackfillButton({
paused,
disabled,
@@ -29,51 +27,26 @@ export function PauseBackfillButton({
disabled?: boolean;
onChange?: () => void | Promise<void>;
}) {
- const [pending, startTransition] = useTransition();
- const [error, setError] = useState<string | null>(null);
- const router = useRouter();
- // Disabled until hydrated. A click on a server-rendered button before React
- // attaches fires NOTHING — no request, no job, no error — which is the
- // recorded root cause of the digest pilot's "un-created job".
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
-
- function toggle() {
- setError(null);
- startTransition(async () => {
- const result = await (paused ? resumeBackfillAction() : pauseBackfillAction());
- if (!result.ok) setError(result.error ?? "Failed.");
- if (onChange) await onChange();
- else router.refresh();
- });
- }
-
- const busy = pending || !mounted;
- return (
- <>
- <button
- type="button"
- disabled={busy || (!paused && disabled)}
- aria-label={paused ? "resume backfill" : "pause backfill"}
- onClick={toggle}
- title={
- paused
- ? "Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped."
- : "Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart."
- }
- className={
- paused
- ? "px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50"
- : "px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50"
- }
- >
- {paused ? "Resume Backfill" : "Pause Backfill"}
- </button>
- {error && (
- <span role="alert" className="text-xs text-destructive">
- {error}
- </span>
- )}
- </>
+ return paused ? (
+ <LaneActionButton
+ label="Resume Backfill"
+ glyph="▶"
+ ariaLabel="resume backfill"
+ title="Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped."
+ variant="active"
+ action={resumeBackfillAction}
+ onChange={onChange}
+ />
+ ) : (
+ <LaneActionButton
+ label="Pause Backfill"
+ glyph="❙❙"
+ ariaLabel="pause backfill"
+ title="Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart."
+ variant="idle"
+ disabled={disabled}
+ action={pauseBackfillAction}
+ onChange={onChange}
+ />
);
}
diff --git a/editor/app/jobs/components/PauseDownloadsButton.tsx b/editor/app/jobs/components/PauseDownloadsButton.tsx
@@ -1,18 +1,16 @@
"use client";
-import { useTransition } from "react";
-import { useRouter } from "next/navigation";
-import {
- pauseDownloadsAction,
- resumeDownloadsAction,
-} from "../actions";
+import { LaneActionButton } from "../../components/lanes/LaneActionButton";
+import { pauseDownloadsAction, resumeDownloadsAction } from "../actions";
// Global pause/resume for downloads, persisted in settings.json (downloadsPaused
// — survives a restart). Pausing gates the auto-download runner on its next loop
// iteration and makes manual download-bearing pipeline actions return a
-// "Downloads are paused" notice; store-playlist/enumeration stay allowed. Mirrors
-// PauseTranscriptionsButton so the dashboard cockpit and the monitor widget can
-// present the two pauses side by side.
+// "Downloads are paused" notice; store-playlist/enumeration stay allowed.
+//
+// A thin wrapper over the shared lane button, in the same prop shape as
+// PauseTranscriptionsButton — see there for why. The dashboard and the widget
+// render the downloads gate through LaneDeck instead.
export function PauseDownloadsButton({
paused,
disabled,
@@ -22,35 +20,25 @@ export function PauseDownloadsButton({
disabled?: boolean;
onChange?: () => void | Promise<void>;
}) {
- const [pending, startTransition] = useTransition();
- const router = useRouter();
-
- function toggle() {
- startTransition(async () => {
- await (paused ? resumeDownloadsAction() : pauseDownloadsAction());
- if (onChange) await onChange();
- else router.refresh();
- });
- }
-
return paused ? (
- <button
- type="button"
- disabled={pending}
- onClick={toggle}
- className="px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50"
- >
- Resume Downloads
- </button>
+ <LaneActionButton
+ label="Resume Downloads"
+ glyph="▶"
+ ariaLabel="Resume Downloads"
+ variant="active"
+ action={resumeDownloadsAction}
+ onChange={onChange}
+ />
) : (
- <button
- type="button"
- disabled={pending || disabled}
- onClick={toggle}
+ <LaneActionButton
+ label="Pause Downloads"
+ glyph="❙❙"
+ ariaLabel="Pause Downloads"
title="Stop new downloads: the auto-download runner idles and manual sync/download actions are blocked until resumed. Enumeration (store-playlist) still runs."
- className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50"
- >
- Pause Downloads
- </button>
+ variant="idle"
+ disabled={disabled}
+ action={pauseDownloadsAction}
+ onChange={onChange}
+ />
);
}
diff --git a/editor/app/jobs/components/PauseTranscriptionsButton.tsx b/editor/app/jobs/components/PauseTranscriptionsButton.tsx
@@ -1,7 +1,6 @@
"use client";
-import { useTransition } from "react";
-import { useRouter } from "next/navigation";
+import { LaneActionButton } from "../../components/lanes/LaneActionButton";
import {
pauseAllWorkersAction,
resumeAllWorkersAction,
@@ -10,8 +9,13 @@ import {
// Global pause/resume for transcription workers. Pausing stops handing out new
// work AND gracefully stops in-flight parakeet jobs after the current segment
// (cached for resume); other engines run their in-flight file to completion.
-// Shared by the Workers page (live `paused` from polling, refetch on toggle)
-// and the Active Jobs page (server-read `paused`, router.refresh on toggle).
+//
+// A THIN WRAPPER over the shared lane button. The Workers page (live `paused`
+// from polling, refetch on toggle) and the Active Jobs page (server-read
+// `paused`, router.refresh on toggle) call it in this prop shape and stay
+// untouched; the dashboard and the widget reach the same button through
+// LaneDeck. One implementation, so the hydration guard and the error handling
+// cannot go missing from one of them.
export function PauseTranscriptionsButton({
paused,
disabled,
@@ -21,35 +25,28 @@ export function PauseTranscriptionsButton({
disabled?: boolean;
onChange?: () => void | Promise<void>;
}) {
- const [pending, startTransition] = useTransition();
- const router = useRouter();
-
- function toggle() {
- startTransition(async () => {
- await (paused ? resumeAllWorkersAction() : pauseAllWorkersAction());
- if (onChange) await onChange();
- else router.refresh();
- });
- }
-
return paused ? (
- <button
- type="button"
- disabled={pending}
- onClick={toggle}
- className="px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50"
- >
- Resume Transcriptions
- </button>
+ <LaneActionButton
+ label="Resume Transcriptions"
+ glyph="▶"
+ ariaLabel="Resume Transcriptions"
+ variant="active"
+ action={resumeAllWorkersAction}
+ onChange={onChange}
+ />
) : (
- <button
- type="button"
- disabled={pending || disabled}
- onClick={toggle}
+ <LaneActionButton
+ label="Pause Transcriptions"
+ glyph="❙❙"
+ ariaLabel="Pause Transcriptions"
title="Stop taking new work; in-flight parakeet jobs finish the current segment, cache it, and resume on the next run"
- className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50"
- >
- Pause Transcriptions
- </button>
+ variant="idle"
+ // Only the pause takes the caller's disable (no workers to pause). A
+ // resume must always be reachable, or a paused pool with zero enabled
+ // workers could never be released.
+ disabled={disabled}
+ action={pauseAllWorkersAction}
+ onChange={onChange}
+ />
);
}
diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx
@@ -184,13 +184,10 @@ export function MonitorWidget({
sync={config.sync}
channel={config.channel}
confirmSyncAll={config.syncConfirm}
- paused={workersPayload?.paused ?? false}
- downloadsPaused={workersPayload?.downloadsPaused ?? false}
- backfillEnabled={syncData?.backfill.enabled ?? false}
- backfillAnyKind={syncData?.backfill.anyKind ?? false}
+ workersData={workersPayload}
+ syncData={syncData}
onWorkersChange={refetchWorkers}
onSynced={refetchSync}
- onBackfillChange={refetchSync}
/>
);
case "lastSync":
diff --git a/editor/app/widget/components/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx
@@ -1,78 +1,75 @@
"use client";
import { useState } from "react";
-import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton";
-import { PauseDownloadsButton } from "../../jobs/components/PauseDownloadsButton";
-import { PauseBackfillButton } from "../../jobs/components/PauseBackfillButton";
+import type { WidgetSyncPayload } from "../../api/widget/sync/route";
+import type { WorkersPayload } from "../../workers/components/WorkersView";
+import { LaneDeck } from "../../components/lanes/LaneDeck";
import { DrainAllButton } from "../../jobs/components/DrainAllButton";
import { RetryAllFailedButton } from "../../jobs/components/RetryAllFailedButton";
import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions";
import { syncAction } from "../../channels/[slug]/pipelineActions";
+import { useSectionFrame } from "./WidgetSection";
// Opt-in interactive controls for the monitor widget. Two independent
// capabilities, each behind its own flag:
-// - `controls` → Pause/Resume (transcriptions, downloads, backfill) + Drain +
-// Retry (reused verbatim from the Workers / Active Jobs / dashboard pages),
-// so a pinned widget can free the GPU, wind work down, hold a days-long
-// backfill, or recover failures without opening the full app.
+// - `controls` → the four LANE CARDS (transcription, downloads, digest,
+// backfill — each with its gate and, where it has one, its sweep) plus Drain
+// and Retry. The deck is the same component the dashboard band renders, so a
+// pinned widget now has FULL PARITY: it could not start or stop a sweep at
+// all before, and had no digest control whatsoever.
// - `sync` → a channel-aware Sync button: pinned to one channel it syncs just
// that channel (draining the per-channel stream); otherwise it sweeps all
// channels.
// `onWorkersChange` refetches the worker payload so the pause/resume label flips
-// immediately; `onSynced` refetches the last-sync/scheduler readouts so they
-// update as soon as a sweep is queued; `onBackfillChange` does the same for the
-// backfill lane, whose state rides that same payload.
+// immediately; `onSynced` refetches the sync payload, which is where the digest
+// and backfill lane state rides as well as the last-sync/scheduler readouts.
export function WidgetControls({
controls,
sync,
channel,
confirmSyncAll,
- paused,
- downloadsPaused,
- backfillEnabled,
- backfillAnyKind,
+ workersData,
+ syncData,
onWorkersChange,
onSynced,
- onBackfillChange,
}: {
controls: boolean;
sync: boolean;
channel?: string;
confirmSyncAll: boolean;
- paused: boolean;
- downloadsPaused: boolean;
- // settings.backfill.enabled. Persisted state, not in-memory runtime state, so
- // the widget's 15s-floor sync poll is enough to keep the label honest between
- // clicks — and `onBackfillChange` flips it immediately on one.
- backfillEnabled: boolean;
- // Whether any backfill FEATURE is registered. With none there is nothing to
- // hold, and a dead button reads as a broken one — the same gate PipelineBand
- // puts on the dashboard's copy.
- backfillAnyKind: boolean;
+ // The two polled payloads the lanes read, passed whole rather than as scalars:
+ // LaneDeck is the one place that knows which field belongs to which lane, and
+ // threading eight booleans through here is how that knowledge got duplicated
+ // in the first place. Persisted state (the pauses, the sweep flags) rides the
+ // widget's 15s-floor poll, and a click refetches immediately.
+ workersData: WorkersPayload | null;
+ syncData: WidgetSyncPayload | null;
onWorkersChange: () => void | Promise<void>;
onSynced: () => void | Promise<void>;
- onBackfillChange: () => void | Promise<void>;
}) {
+ const { dense } = useSectionFrame();
return (
<section
aria-label="Controls"
- className="flex flex-wrap items-center gap-1.5"
+ className={
+ dense
+ ? "flex flex-wrap items-center gap-1.5"
+ : "flex flex-col gap-2"
+ }
>
{controls && (
<>
- <PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} />
- <PauseDownloadsButton
- paused={downloadsPaused}
- onChange={onWorkersChange}
+ <LaneDeck
+ workers={workersData}
+ sync={syncData}
+ onWorkersChange={onWorkersChange}
+ onSynced={onSynced}
/>
- {backfillAnyKind && (
- <PauseBackfillButton
- paused={!backfillEnabled}
- onChange={onBackfillChange}
- />
- )}
- <DrainAllButton />
- <RetryAllFailedButton />
+ {/* Not lanes: one-shot recovery actions over the whole queue. */}
+ <div className="flex flex-wrap items-center gap-1.5">
+ <DrainAllButton />
+ <RetryAllFailedButton />
+ </div>
</>
)}
{sync && (