commit 6bc63ab481d0d2700b7ebd5838c960b0de02922d
parent c08b2f7cd061c6050b67b4a0517f12c86551a374
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 4 Jul 2026 08:09:16 -0400
Merge feat/widget-builder-actionable: needs-work list, actions, in-place settings gear
Diffstat:
8 files changed, 523 insertions(+), 134 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **The monitor widget gains a needs-work channel list, more interaction buttons, and an in-place settings gear.** Three additions, all driveable from the widget builder. **(1) A "Needs work" list** (URL flag `act=1`) — a compact, per-channel worklist of videos to download (`↓ N`) or transcribe (`✎ N`), reusing the same `loadActionableSummary` that powers the `/actionable` page via a new `/api/widget/actionable` route; it polls on a 15s floor (the backlog changes on job completions, not seconds) and caps at 6 channels with a `+N more` line. **(2) More interactions** behind the existing `controls=1` switch: each needs-work row gains the same per-channel **Download missing** / **Transcribe pending** buttons as the actionable page (reusing `InlineActionButton`), and the controls row adds **Retry all failed** alongside Pause/Resume + Drain. **(3) An in-place settings gear** (on by default; URL flag `gear=0` to hide, or a **Show settings gear** builder checkbox) — clicking it opens the builder's own form *inside the widget window*, so a pinned widget can be reconfigured live without opening the builder page; edits apply immediately and mirror into the address bar via `history.replaceState`, so a reload preserves them and the link stays copyable. The builder form is extracted into a shared `WidgetConfigForm` used by both the builder and the overlay, and the widget's poller now fetches immediately on (re)subscribe instead of after one interval, so newly-enabled sections render at once. Existing links render unchanged (the two new flags default to their old behavior; the gear is the one new default-visible affordance and is read-only — it mutates no server state). See `editor/app/widget/lib/config.ts`, the new `editor/app/widget/components/WidgetConfigForm.tsx` and `editor/app/api/widget/actionable/route.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls}.tsx`, `editor/app/widget/builder/components/WidgetBuilder.tsx`, and `editor/e2e/widget.spec.ts`.
- **Every site build now bundles downloadable transcript & live-chat archive zips.** The archive builders (per-channel `<slug>.zip`, combined `all-transcripts.zip` / `all-live-chat.zip`) previously only ran as standalone actions that wrote to a non-served directory; now `compose-site` generates them for the site's own channels straight into the served `public/archives/` and writes a `manifest.json` (sizes + counts) that the site's new **Downloads** page reads. **`zip` is now the default archive format** everywhere (was `tar.gz`), and the Build page's format help text tracks the selected format. Generation is **on by default with three opt-out levels**: a global **Generate archive zips on build** toggle in Settings, a per-site **Generate archive zips** toggle (plus an optional **Archive size cap (MB)**) on the site's page, and a per-build **Skip archive zips** checkbox on the Build and Build & Deploy controls (`BUILD_ARCHIVES=0`). Because a single file over ~25 MB breaks a Cloudflare Pages deploy, any archive over the cap (default 25 MB; `0` = no cap) is dropped from what's served and flagged `oversize` in the manifest so the deploy still succeeds and the Downloads page shows it as unavailable rather than a dead link. See `common/bin/compose-site.ts` (`composeArchives`), `common/controller/archive{Transcripts,LiveChat}.ts` (new `outDir` option), `common/lib/archiveOptions.ts` (default + manifest types), `common/lib/{site,settings}.ts` (opt-out flags), and `editor/app/{deploy/buildDeployCore.ts,build/buildAction.ts,deploy/components/Build{Export,Deploy}Button.tsx,sites/components/SiteForm.tsx,settings/components/SettingsForm.tsx}`.
- **You can now change a channel's slug (its id) — deliberately, from the Danger zone.** A channel's slug *is* its on-disk directory name (`transcripts/channels/<slug>/`), so it used to be fixed at creation ("Slug is fixed once a channel is created"). A new **Rename** form in the channel's Danger zone lifts that: enter a new slug and **type the current slug to confirm** (same friction as delete), and the rename is blocked while the channel has running/queued jobs (the in-memory registry keys by slug). Because the slug is a directory name, the rename does a **full migration** of every slug-keyed store so nothing silently breaks: it moves the channel dir (config, data, playlist, snapshot, shards, failed lists) **and** the saved-video store dir — rewriting each `saved-video.json` pointer's absolute `dir` so persisted source videos still resolve — then retargets every site.json membership, the sync scheduler's per-channel backoff state, and any job bookmarks. The two filesystem moves run first and roll back on failure; the metadata updates that follow are atomic and best-effort (surfaced as warnings). Renaming **changes the channel's public URL** (the old one 404s), which the form warns about. The slug grammar is also now validated on create. See `common/controller/renameChannel.ts`, `common/controller/channels.ts` (`isValidChannelSlug`), `common/lib/savedVideo-server.ts` (`rewriteSavedVideoDir`), `common/jobs/bookmarks.ts` (`renameChannelInBookmarks`), `editor/app/channels/{actions.ts,components/RenameChannelForm.tsx,[slug]/page.tsx}`, and `editor/e2e/channel-rename.spec.ts`.
- **New Queue diagnostics page (`/jobs/queue`): see & force-release stuck jobs.** The job system has two sources of truth that can drift — the registry owns each job's `status`, the scheduler owns the running SLOT per queue. A cancel that never finalizes (a child that ignored SIGTERM, a crashed finalizer) leaves a job "cancelled" in the registry while the scheduler still marks its slot running, silently blocking every job behind it on that queue — and the Active Jobs page hides it (it filters to running/queued). The new **Queue** page reconciles the two: it builds from the **scheduler** as the source of truth for slots, cross-checks each against its registry record, and flags a running head as **stuck** when the record is terminal-but-holding-slot, evicted, or (softer) a live job idle past 10 minutes. It **auto-heals** the hard cases on every view/poll (frees terminal/evicted slots), shows a health strip (active queues, running, queued, **stuck**, workers), per-queue cards with the held-for duration / PID (`kill -9` hint) / last log line, and a **Force-release** button per slot (SIGKILLs the child and frees the slot unconditionally) plus a **Reap all stuck** action. Force-release is also available on any running job in Active Jobs, and Active Jobs links to Queue with a stuck-count badge. See `common/jobs/registry.ts` (`forceRelease`), `editor/app/jobs/queue/*`, `editor/app/jobs/{actions.ts,components/ForceReleaseJobButton.tsx}`, and `editor/e2e/queue.spec.ts`.
diff --git a/editor/app/api/widget/actionable/route.ts b/editor/app/api/widget/actionable/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ actionableUndownloadedCount,
+ actionableUntranscribedCount,
+ loadActionableSummary,
+} from "../../../actionable/lib/loadActionable";
+
+export const dynamic = "force-dynamic";
+
+export type WidgetActionableChannel = {
+ slug: string;
+ undownloaded: number;
+ untranscribed: number;
+};
+
+export type WidgetActionablePayload = {
+ channels: WidgetActionableChannel[];
+};
+
+// Backs the monitor widget's optional "Needs work" strip. Reuses the same
+// actionable summary that powers the /actionable page, reduced to the two
+// buckets a widget acts on — videos to download and videos to transcribe — for
+// each channel that has any, sorted by the larger backlog first.
+export async function GET() {
+ const summary = await loadActionableSummary(getPaths());
+ const channels = summary.rows
+ .map((row) => ({
+ slug: row.channel.slug,
+ undownloaded: actionableUndownloadedCount(row),
+ untranscribed: actionableUntranscribedCount(row),
+ }))
+ .filter((c) => c.undownloaded > 0 || c.untranscribed > 0)
+ .sort(
+ (a, b) =>
+ b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed),
+ );
+ return NextResponse.json({ channels } satisfies WidgetActionablePayload);
+}
diff --git a/editor/app/widget/builder/components/WidgetBuilder.tsx b/editor/app/widget/builder/components/WidgetBuilder.tsx
@@ -6,6 +6,7 @@ import {
WIDGET_DEFAULTS,
type WidgetConfig,
} from "../../lib/config";
+import { WidgetConfigForm } from "../../components/WidgetConfigForm";
import { loadWidgetConfig, saveWidgetConfig } from "../widgetConfigStorage";
const SIZE_PRESETS: { label: string; width: number; height: number }[] = [
@@ -73,99 +74,7 @@ export function WidgetBuilder() {
return (
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<form className="flex flex-col gap-4 lg:w-80 lg:shrink-0">
- <fieldset className="flex flex-col gap-2">
- <legend className="text-sm font-medium mb-1">Sections</legend>
- <Check
- label="Workers"
- checked={config.workers}
- onChange={(v) => patch({ workers: v })}
- />
- <Check
- label="Active jobs"
- checked={config.jobs}
- onChange={(v) => patch({ jobs: v })}
- />
- </fieldset>
-
- <fieldset className="flex flex-col gap-2">
- <legend className="text-sm font-medium mb-1">Display</legend>
- <Check
- label="Compact (hide per-task detail)"
- checked={config.compact}
- onChange={(v) => patch({ compact: v })}
- />
- <Check
- label="Show batch progress bar"
- checked={config.jobBar}
- onChange={(v) => patch({ jobBar: v })}
- />
- <Check
- label="Show progress in job heading"
- checked={config.headingProgress}
- onChange={(v) => patch({ headingProgress: v })}
- />
- <Check
- label="Show time estimates (ETA)"
- checked={config.eta}
- onChange={(v) => patch({ eta: v })}
- />
- <Check
- label="Show disk indicator"
- checked={config.disk}
- onChange={(v) => patch({ disk: v })}
- />
- <Check
- label="Show cleanable indicator"
- checked={config.cleanable}
- onChange={(v) => patch({ cleanable: v })}
- />
- <Check
- label="Show worker names"
- checked={config.workerLabels}
- onChange={(v) => patch({ workerLabels: v })}
- />
- <Check
- label="Show section titles"
- checked={config.showTitles}
- onChange={(v) => patch({ showTitles: v })}
- />
- <Check
- label="Hide when idle"
- checked={config.hideIdle}
- onChange={(v) => patch({ hideIdle: v })}
- />
- <Check
- label="Show control buttons (pause, drain)"
- checked={config.controls}
- onChange={(v) => patch({ controls: v })}
- />
- </fieldset>
-
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Channel filter (slug, optional)</span>
- <input
- type="text"
- value={config.channel ?? ""}
- onChange={(e) => patch({ channel: e.target.value || undefined })}
- placeholder="all channels"
- className="rounded border border-border bg-card px-2 py-1"
- />
- </label>
-
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Poll interval (seconds)</span>
- <input
- type="number"
- min={1}
- value={config.pollSeconds}
- onChange={(e) =>
- patch({
- pollSeconds: Math.max(1, Math.round(Number(e.target.value) || 1)),
- })
- }
- className="rounded border border-border bg-card px-2 py-1 w-24"
- />
- </label>
+ <WidgetConfigForm config={config} onChange={patch} />
</form>
<div className="flex flex-1 flex-col gap-3 min-w-0">
@@ -200,8 +109,11 @@ export function WidgetBuilder() {
<code className="font-mono"><iframe></code>. It has no sidebar;
by default it's read-only, but{" "}
<strong>Show control buttons</strong> adds Pause/Resume
- Transcriptions and Drain all. <strong>Open popup</strong> launches a
- chromeless window at the selected preview size.
+ Transcriptions, Drain all, Retry all failed, and per-channel
+ Download/Transcribe on the needs-work list. The{" "}
+ <strong>settings gear</strong> lets you reconfigure the widget in
+ place; turn it off for a locked-down link. <strong>Open popup</strong>{" "}
+ launches a chromeless window at the selected preview size.
</p>
</div>
@@ -243,25 +155,3 @@ export function WidgetBuilder() {
</div>
);
}
-
-function Check({
- label,
- checked,
- onChange,
-}: {
- label: string;
- checked: boolean;
- onChange: (v: boolean) => void;
-}) {
- return (
- <label className="flex items-center gap-2 text-sm">
- <input
- type="checkbox"
- checked={checked}
- onChange={(e) => onChange(e.target.checked)}
- className="h-4 w-4"
- />
- <span>{label}</span>
- </label>
- );
-}
diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx
@@ -9,7 +9,10 @@ import type {
import type { RunningJobsListItem } from "../../jobs/components/RunningJobsList";
import { jobKindLabel } from "../../jobs/jobKindLabels";
import type { WorkersPayload, WorkerView } from "../../workers/components/WorkersView";
-import type { WidgetConfig } from "../lib/config";
+import { InlineActionButton } from "../../actionable/components/InlineActionButton";
+import type { WidgetActionablePayload } from "../../api/widget/actionable/route";
+import { buildWidgetQuery, type WidgetConfig } from "../lib/config";
+import { WidgetConfigForm } from "./WidgetConfigForm";
import { WidgetControls } from "./WidgetControls";
// Read-only monitor widget. Reuses the existing ~1s poll pattern from
@@ -61,7 +64,11 @@ function usePolledPayload<T>(
if (!cancelled) timer = setTimeout(tick, pollMs);
}
}
- timer = setTimeout(tick, pollMs);
+ // Fetch immediately on (re)subscribe, then poll on the interval. Sections
+ // seeded from the server (jobs/workers) get a harmless refresh; those with a
+ // null initial (cleanable/actionable) render on first tick instead of after
+ // a full interval — matters most for the actionable strip's 15s cadence.
+ void tick();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
@@ -71,7 +78,7 @@ function usePolledPayload<T>(
}
export function MonitorWidget({
- config,
+ config: initialConfig,
initialJobs,
initialWorkers,
}: {
@@ -79,6 +86,14 @@ export function MonitorWidget({
initialJobs: ActiveJobsPayload | null;
initialWorkers: WorkersPayload | null;
}) {
+ // The URL (parsed into `initialConfig` server-side) is authoritative, so we
+ // seed state from the prop only — never localStorage — to keep the first
+ // client render byte-identical to the server's. The in-widget settings gear
+ // edits this state and mirrors it back into the address bar (see `patch`), so
+ // a reload re-parses the same config.
+ const [config, setConfig] = useState<WidgetConfig>(initialConfig);
+ const [settingsOpen, setSettingsOpen] = useState(false);
+
const pollMs = config.pollSeconds * 1000;
// The controls row needs live `paused` state, so poll workers whenever either
// the Workers section or the controls are shown.
@@ -102,6 +117,27 @@ export function MonitorWidget({
pollMs,
null,
);
+ // The worklist changes on job completions (minutes), not seconds, and reading
+ // every channel snapshot each tick is heavier than the other endpoints — so
+ // poll it no faster than every 15s regardless of the configured cadence.
+ const { data: actionablePayload } = usePolledPayload<WidgetActionablePayload>(
+ "/api/widget/actionable",
+ config.actionable,
+ Math.max(pollMs, 15000),
+ null,
+ );
+
+ // Apply a config change and mirror it into the address bar so the widget is
+ // self-describing: a reload re-parses the same config and the link stays
+ // copyable. `history.replaceState` (not router.replace) avoids a server
+ // round-trip and the global AutoRefresh race. An all-default config drops the
+ // query entirely.
+ function patch(p: Partial<WidgetConfig>) {
+ const next = { ...config, ...p };
+ setConfig(next);
+ const q = buildWidgetQuery(next);
+ window.history.replaceState(null, "", q ? `?${q}` : window.location.pathname);
+ }
const jobs = (jobsPayload?.jobs ?? []).filter(
(j) => !config.channel || j.channelSlug === config.channel,
@@ -109,23 +145,44 @@ export function MonitorWidget({
const workers = workersPayload?.workers ?? [];
const disk = jobsPayload?.disk;
+ const actionableChannels = actionablePayload?.channels ?? [];
+
const nothingActive =
(!config.jobs || jobs.length === 0) &&
(!config.workers || workers.every((w) => !w.busy));
+ // The settings gear + its in-place overlay. Shared by the idle and full
+ // renders so you can reconfigure a collapsed widget too. Positioned against
+ // the widget's own relative container (or the viewport, for the overlay).
+ const settingsLayer = (
+ <SettingsLayer
+ enabled={config.settings}
+ open={settingsOpen}
+ onToggle={() => setSettingsOpen((o) => !o)}
+ onClose={() => setSettingsOpen(false)}
+ config={config}
+ onChange={patch}
+ />
+ );
+
// A low-disk warning is worth showing even when nothing is running, since it
// explains why no downloads start — so it overrides hideIdle. Interactive
// controls also stay visible when idle (you may want to pause preemptively).
if (config.hideIdle && nothingActive && !disk?.low && !config.controls) {
return (
- <div className="p-2 text-xs text-muted-foreground" aria-label="monitor idle">
+ <div
+ className="relative p-2 text-xs text-muted-foreground"
+ aria-label="monitor idle"
+ >
Idle
+ {settingsLayer}
</div>
);
}
return (
- <div className="flex flex-col gap-3 p-2 text-foreground">
+ <div className="relative flex flex-col gap-3 p-2 text-foreground">
+ {settingsLayer}
{config.controls && (
<WidgetControls
paused={workersPayload?.paused ?? false}
@@ -154,6 +211,13 @@ export function MonitorWidget({
eta={config.eta}
/>
)}
+ {config.actionable && (
+ <ActionableStrip
+ channels={actionableChannels}
+ showTitle={config.showTitles}
+ interactive={config.controls}
+ />
+ )}
</div>
);
}
@@ -291,6 +355,92 @@ function CleanableStrip({ bytes }: { bytes: number }) {
);
}
+// Compact "needs work" list: channels with videos to download or transcribe,
+// echoing the ↓ (download) / ✎ (transcribe) verbs used by the task bars so the
+// worklist reads in the same language. Read-only counts by default; when the
+// widget is interactive (controls=1), each row gains the same per-channel
+// Download/Transcribe buttons as the /actionable page.
+const ACTIONABLE_LIMIT = 6;
+
+function ActionableStrip({
+ channels,
+ showTitle,
+ interactive,
+}: {
+ channels: WidgetActionablePayload["channels"];
+ showTitle: boolean;
+ interactive: boolean;
+}) {
+ const shown = channels.slice(0, ACTIONABLE_LIMIT);
+ const overflow = channels.length - shown.length;
+ return (
+ <section aria-label="Needs work" className="flex flex-col gap-1.5">
+ {showTitle && (
+ <div className="flex items-baseline gap-2">
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+ Needs work
+ </h2>
+ <span className="text-xs text-muted-foreground">
+ {channels.length} {channels.length === 1 ? "channel" : "channels"}
+ </span>
+ </div>
+ )}
+ {channels.length === 0 ? (
+ <p className="text-xs text-muted-foreground">Everything's handled.</p>
+ ) : (
+ <ul className="flex flex-col gap-1.5">
+ {shown.map((c) => (
+ <li
+ key={c.slug}
+ className="flex flex-col gap-1 rounded border border-border bg-card px-2 py-1"
+ >
+ <div className="flex items-center justify-between gap-2">
+ <span
+ className="font-mono text-xs truncate"
+ title={c.slug}
+ >
+ {c.slug}
+ </span>
+ <span className="flex shrink-0 items-center gap-1">
+ {c.undownloaded > 0 && (
+ <span className="rounded px-1.5 py-0.5 text-[10px] tabular-nums bg-warning-soft text-warning">
+ ↓ {c.undownloaded}
+ </span>
+ )}
+ {c.untranscribed > 0 && (
+ <span className="rounded px-1.5 py-0.5 text-[10px] tabular-nums bg-info-soft text-info">
+ ✎ {c.untranscribed}
+ </span>
+ )}
+ </span>
+ </div>
+ {interactive && (
+ <div className="flex flex-wrap items-center gap-1.5">
+ {c.undownloaded > 0 && (
+ <InlineActionButton
+ variant={{ kind: "downloadMissing", slug: c.slug }}
+ />
+ )}
+ {c.untranscribed > 0 && (
+ <InlineActionButton
+ variant={{ kind: "transcribeMissing", slug: c.slug }}
+ />
+ )}
+ </div>
+ )}
+ </li>
+ ))}
+ {overflow > 0 && (
+ <li className="text-xs text-muted-foreground">
+ +{overflow} more {overflow === 1 ? "channel" : "channels"}
+ </li>
+ )}
+ </ul>
+ )}
+ </section>
+ );
+}
+
function ActiveJobsStrip({
jobs,
showTitle,
@@ -538,3 +688,69 @@ function TaskBar({
</div>
);
}
+
+// The settings gear and its in-place config overlay. The gear anchors to the
+// widget's own (relative) container; the overlay covers the whole widget window
+// (fixed inset-0) and hosts the same form the builder uses, so a pinned widget
+// can be reconfigured without opening the full builder page. Rendering nothing
+// when the gear is disabled keeps hooks out of this component entirely.
+function SettingsLayer({
+ enabled,
+ open,
+ onToggle,
+ onClose,
+ config,
+ onChange,
+}: {
+ enabled: boolean;
+ open: boolean;
+ onToggle: () => void;
+ onClose: () => void;
+ config: WidgetConfig;
+ onChange: (patch: Partial<WidgetConfig>) => void;
+}) {
+ if (!enabled) return null;
+ return (
+ <>
+ <button
+ type="button"
+ aria-label="Settings"
+ aria-expanded={open}
+ onClick={onToggle}
+ className="absolute right-1 top-1 z-10 flex h-6 w-6 items-center justify-center rounded border border-border bg-card/80 text-muted-foreground backdrop-blur hover:bg-muted hover:text-foreground"
+ >
+ <span aria-hidden className="text-sm leading-none">
+ ⚙
+ </span>
+ </button>
+ {open && (
+ <div
+ role="dialog"
+ aria-label="Widget settings"
+ className="fixed inset-0 z-50 overflow-auto bg-background p-3 text-foreground"
+ >
+ <div className="mb-3 flex items-center justify-between">
+ <h2 className="text-sm font-semibold">Widget settings</h2>
+ <button
+ type="button"
+ aria-label="Close settings"
+ onClick={onClose}
+ className="flex h-6 w-6 items-center justify-center rounded border border-border hover:bg-muted"
+ >
+ <span aria-hidden className="text-sm leading-none">
+ ✕
+ </span>
+ </button>
+ </div>
+ <form className="flex flex-col gap-4">
+ <WidgetConfigForm config={config} onChange={onChange} />
+ </form>
+ <p className="mt-3 text-xs text-muted-foreground">
+ Changes apply live and update this widget's link — reload keeps
+ them.
+ </p>
+ </div>
+ )}
+ </>
+ );
+}
diff --git a/editor/app/widget/components/WidgetConfigForm.tsx b/editor/app/widget/components/WidgetConfigForm.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import type { WidgetConfig } from "../lib/config";
+
+// The widget's configuration form — the section/display checkboxes plus the
+// channel and poll-interval inputs. Shared by the /widget/builder page (which
+// wraps it with the link/copy/preview UI) and the in-widget settings overlay
+// (which lets you reconfigure a pinned widget in place). Callers own the
+// surrounding <form> element.
+export function WidgetConfigForm({
+ config,
+ onChange,
+}: {
+ config: WidgetConfig;
+ onChange: (patch: Partial<WidgetConfig>) => void;
+}) {
+ return (
+ <>
+ <fieldset className="flex flex-col gap-2">
+ <legend className="text-sm font-medium mb-1">Sections</legend>
+ <Check
+ label="Workers"
+ checked={config.workers}
+ onChange={(v) => onChange({ workers: v })}
+ />
+ <Check
+ label="Active jobs"
+ checked={config.jobs}
+ onChange={(v) => onChange({ jobs: v })}
+ />
+ <Check
+ label="Channels needing work"
+ checked={config.actionable}
+ onChange={(v) => onChange({ actionable: v })}
+ />
+ </fieldset>
+
+ <fieldset className="flex flex-col gap-2">
+ <legend className="text-sm font-medium mb-1">Display</legend>
+ <Check
+ label="Compact (hide per-task detail)"
+ checked={config.compact}
+ onChange={(v) => onChange({ compact: v })}
+ />
+ <Check
+ label="Show batch progress bar"
+ checked={config.jobBar}
+ onChange={(v) => onChange({ jobBar: v })}
+ />
+ <Check
+ label="Show progress in job heading"
+ checked={config.headingProgress}
+ onChange={(v) => onChange({ headingProgress: v })}
+ />
+ <Check
+ label="Show time estimates (ETA)"
+ checked={config.eta}
+ onChange={(v) => onChange({ eta: v })}
+ />
+ <Check
+ label="Show disk indicator"
+ checked={config.disk}
+ onChange={(v) => onChange({ disk: v })}
+ />
+ <Check
+ label="Show cleanable indicator"
+ checked={config.cleanable}
+ onChange={(v) => onChange({ cleanable: v })}
+ />
+ <Check
+ label="Show worker names"
+ checked={config.workerLabels}
+ onChange={(v) => onChange({ workerLabels: v })}
+ />
+ <Check
+ label="Show section titles"
+ checked={config.showTitles}
+ onChange={(v) => onChange({ showTitles: v })}
+ />
+ <Check
+ label="Hide when idle"
+ checked={config.hideIdle}
+ onChange={(v) => onChange({ hideIdle: v })}
+ />
+ <Check
+ label="Show settings gear"
+ checked={config.settings}
+ onChange={(v) => onChange({ settings: v })}
+ />
+ <Check
+ label="Show control buttons (pause, drain, retry, per-channel actions)"
+ checked={config.controls}
+ onChange={(v) => onChange({ controls: v })}
+ />
+ </fieldset>
+
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Channel filter (slug, optional)</span>
+ <input
+ type="text"
+ value={config.channel ?? ""}
+ onChange={(e) => onChange({ channel: e.target.value || undefined })}
+ placeholder="all channels"
+ className="rounded border border-border bg-card px-2 py-1"
+ />
+ </label>
+
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Poll interval (seconds)</span>
+ <input
+ type="number"
+ min={1}
+ value={config.pollSeconds}
+ onChange={(e) =>
+ onChange({
+ pollSeconds: Math.max(1, Math.round(Number(e.target.value) || 1)),
+ })
+ }
+ className="rounded border border-border bg-card px-2 py-1 w-24"
+ />
+ </label>
+ </>
+ );
+}
+
+function Check({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={checked}
+ onChange={(e) => onChange(e.target.checked)}
+ className="h-4 w-4"
+ />
+ <span>{label}</span>
+ </label>
+ );
+}
diff --git a/editor/app/widget/components/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx
@@ -2,13 +2,14 @@
import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton";
import { DrainAllButton } from "../../jobs/components/DrainAllButton";
+import { RetryAllFailedButton } from "../../jobs/components/RetryAllFailedButton";
// Opt-in interactive controls for the monitor widget (enabled with controls=1).
// Reuses the same actions/buttons as the Workers and Active Jobs pages so a
-// pinned widget can free the GPU (pause) or wind work down (drain) without
-// opening the full app. `onWorkersChange` refetches the widget's worker payload
-// so the pause/resume label flips immediately instead of waiting for the next
-// poll.
+// pinned widget can free the GPU (pause), wind work down (drain), or recover
+// failures (retry) without opening the full app. `onWorkersChange` refetches the
+// widget's worker payload so the pause/resume label flips immediately instead of
+// waiting for the next poll.
export function WidgetControls({
paused,
onWorkersChange,
@@ -23,6 +24,7 @@ export function WidgetControls({
>
<PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} />
<DrainAllButton />
+ <RetryAllFailedButton />
</section>
);
}
diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts
@@ -36,6 +36,12 @@ export type WidgetConfig = {
// Opt-in interactive controls (Pause/Resume Transcriptions, Drain all). Off by
// default — the widget stays read-only unless this is enabled.
controls: boolean;
+ // Show the "Needs work" strip — a compact list of channels with videos to
+ // download or transcribe. Off by default (mirrors the opt-in cleanable strip).
+ actionable: boolean;
+ // Show the in-widget settings gear that opens the config overlay in place. On
+ // by default; turn off to bake a locked-down shared/embedded link.
+ settings: boolean;
};
export const WIDGET_DEFAULTS: WidgetConfig = {
@@ -53,6 +59,8 @@ export const WIDGET_DEFAULTS: WidgetConfig = {
cleanable: false,
workerLabels: true,
controls: false,
+ actionable: false,
+ settings: true,
};
// Next's searchParams give each key as string | string[] | undefined.
@@ -96,6 +104,8 @@ export function parseWidgetConfig(params: RawParams): WidgetConfig {
cleanable: parseBool(params.clean, WIDGET_DEFAULTS.cleanable),
workerLabels: parseBool(params.wnames, WIDGET_DEFAULTS.workerLabels),
controls: parseBool(params.controls, WIDGET_DEFAULTS.controls),
+ actionable: parseBool(params.act, WIDGET_DEFAULTS.actionable),
+ settings: parseBool(params.gear, WIDGET_DEFAULTS.settings),
};
}
@@ -127,5 +137,9 @@ export function buildWidgetQuery(config: WidgetConfig): string {
sp.set("wnames", config.workerLabels ? "1" : "0");
if (config.controls !== WIDGET_DEFAULTS.controls)
sp.set("controls", config.controls ? "1" : "0");
+ if (config.actionable !== WIDGET_DEFAULTS.actionable)
+ sp.set("act", config.actionable ? "1" : "0");
+ if (config.settings !== WIDGET_DEFAULTS.settings)
+ sp.set("gear", config.settings ? "1" : "0");
return sp.toString();
}
diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts
@@ -1,7 +1,9 @@
// The monitor widget (/widget) and its builder (/widget/builder). The widget is
// meant to embed in a small pinned window or iframe: the app shell (sidebar,
-// command palette) is stripped on exactly /widget. It is read-only by default,
-// but `controls=1` opts into interactive Pause/Resume + Drain buttons. The
+// command palette) is stripped on exactly /widget. It is read-only by default
+// (aside from an in-place settings gear), but `controls=1` opts into interactive
+// Pause/Resume + Drain + Retry buttons, `act=1` shows a "needs work" channel
+// list, and the two combine into per-channel Download/Transcribe actions. The
// builder lives inside the normal shell and composes links.
import { test, expect } from "@playwright/test";
@@ -19,7 +21,7 @@ test.beforeEach(async () => {
await writeSettings(TWO_WORKERS);
});
-test("the bare widget has no sidebar and no controls", async ({ page }) => {
+test("the bare widget has no sidebar and stays read-only", async ({ page }) => {
await page.goto("/widget");
// Chrome is stripped: no sidebar nav.
@@ -31,11 +33,46 @@ test("the bare widget has no sidebar and no controls", async ({ page }) => {
await expect(page.getByLabel("worker GPU idle")).toBeVisible();
await expect(page.getByLabel("worker CPU idle")).toBeVisible();
- // Read-only: no action buttons of any kind.
- await expect(page.getByRole("button")).toHaveCount(0);
+ // The settings gear ships on by default, but nothing mutates worker/job state:
+ // no Pause/Resume/Drain/Retry controls unless controls=1.
+ await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Pause Transcriptions" }),
+ ).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Drain all" })).toHaveCount(0);
+});
+
+test("gear=0 hides the settings gear", async ({ page }) => {
+ await page.goto("/widget?gear=0");
+ await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible();
+ // The gear is gone, so a locked-down link exposes no settings affordance.
+ // (Scoped to the gear rather than all buttons — `next dev` injects its own
+ // Dev Tools button, absent from a production build.)
+ await expect(page.getByRole("button", { name: "Settings" })).toHaveCount(0);
+});
+
+test("the settings gear opens an in-place overlay that edits the widget live", async ({
+ page,
+}) => {
+ await page.goto("/widget");
+
+ await page.getByRole("button", { name: "Settings" }).click();
+ const dialog = page.getByRole("dialog", { name: "Widget settings" });
+ await expect(dialog).toBeVisible();
+
+ // Toggling a section in the overlay mirrors into the address bar immediately
+ // (history.replaceState) so a reload preserves it and the link stays copyable.
+ await dialog.getByRole("checkbox", { name: "Workers" }).uncheck();
+ await expect(page).toHaveURL(/[?&]workers=0(&|$)/);
+
+ // Close the overlay and confirm the change is applied to the live widget.
+ await page.getByRole("button", { name: "Close settings" }).click();
+ await expect(dialog).toHaveCount(0);
+ await expect(page.getByRole("heading", { name: "Workers" })).toHaveCount(0);
+ await expect(page.getByRole("heading", { name: "Active" })).toBeVisible();
});
-test("controls=1 adds interactive Pause/Resume and Drain buttons", async ({
+test("controls=1 adds interactive Pause/Resume, Drain, and Retry buttons", async ({
page,
}) => {
await page.goto("/widget?controls=1");
@@ -44,6 +81,9 @@ test("controls=1 adds interactive Pause/Resume and Drain buttons", async ({
const pause = page.getByRole("button", { name: "Pause Transcriptions" });
await expect(pause).toBeVisible();
await expect(page.getByRole("button", { name: "Drain all" })).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Retry all failed" }),
+ ).toBeVisible();
// Pausing flips the toggle to Resume (the widget refetches workers on change).
await pause.click();
@@ -69,6 +109,39 @@ test("section params gate what renders", async ({ page }) => {
await expect(page.getByRole("heading", { name: "Workers" })).toHaveCount(0);
});
+test("act=1 shows a compact needs-work list of channels", async ({ page }) => {
+ await resetData("youtube-with-playlist");
+ // Visiting the channel detail page auto-generates the snapshot the list reads.
+ await page.goto("/channels/test-youtube");
+
+ await page.goto("/widget?act=1");
+ const list = page.getByRole("region", { name: "Needs work" });
+ await expect(list).toBeVisible({ timeout: 10_000 });
+ // youtube-with-playlist leaves 4 videos undownloaded (↓), none awaiting
+ // transcription — the strip echoes the task-bar download verb.
+ await expect(list).toContainText("test-youtube");
+ await expect(list).toContainText("↓ 4");
+});
+
+test("act=1&controls=1 gives the list per-channel Download actions", async ({
+ page,
+}) => {
+ await resetData("youtube-with-playlist");
+ await page.goto("/channels/test-youtube"); // auto-generate snapshot
+
+ await page.goto("/widget?act=1&controls=1");
+ const download = page.getByRole("button", {
+ name: "download missing test-youtube",
+ });
+ await expect(download).toBeVisible({ timeout: 10_000 });
+
+ // Reuses the /actionable inline action: clicking queues a download-missing job.
+ await download.click();
+ await expect(
+ page.getByLabel("download missing test-youtube job"),
+ ).toBeVisible({ timeout: 10_000 });
+});
+
test("the builder keeps the shell and composes a link", async ({ page }) => {
await page.goto("/widget/builder");
@@ -92,7 +165,7 @@ test("the builder keeps the shell and composes a link", async ({ page }) => {
const popupPromise = page.waitForEvent("popup");
await page.getByRole("button", { name: "Open popup" }).click();
const popup = await popupPromise;
- await expect(popup).toHaveURL(/\/widget\?workers=0$/);
+ await expect(popup).toHaveURL(/\/widget\?workers=0(&|$)/);
await popup.close();
});
@@ -124,8 +197,16 @@ test("display toggles serialize into the link and preview", async ({ page }) =>
await expect(url).toHaveValue(/[?&]wnames=0(&|$)/);
await expect(preview).toHaveAttribute("src", /[?&]wnames=0(&|$)/);
+ // New flags: the needs-work list (act) and the settings gear off-switch (gear).
+ await page.getByRole("checkbox", { name: "Channels needing work" }).check();
+ await expect(url).toHaveValue(/[?&]act=1(&|$)/);
+ await expect(preview).toHaveAttribute("src", /[?&]act=1(&|$)/);
+
+ await page.getByRole("checkbox", { name: "Show settings gear" }).uncheck();
+ await expect(url).toHaveValue(/[?&]gear=0(&|$)/);
+
await page
- .getByRole("checkbox", { name: "Show control buttons (pause, drain)" })
+ .getByRole("checkbox", { name: "Show control buttons" })
.check();
await expect(url).toHaveValue(/[?&]controls=1(&|$)/);
await expect(preview).toHaveAttribute("src", /[?&]controls=1(&|$)/);