commit 7fde7d82cfe13702f803cb43e070e604a5891d2c
parent a9d6c9f91502f41e1d8e276a3c1e40ecd62e4aeb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 13:40:54 -0400
review fixes: rack-only dim, slug-only sections, report dates, one-line compact rows
From the slice 3a review:
1. Dim build-excluded / paused rows only in the rack (`sticky`); the dashboard
and the work tables never dimmed and do not now.
2. /channels projects the group sections to `{slug}` before they cross to the
client (`ChannelGroupSectionView`), so no ChannelConfig reaches the browser
through the grouped view either.
3. Work tables' report cell keeps the word and the date: "stale · <stamp>",
"missing", the stamp in `title`. The rack's cell is unchanged.
4. `suppressHydrationWarning` passes through Td for the date cells (formatStamp
runs in an SSR'd client component now), as JobRow's dates already do.
5. Compact JobRow: the heading stays on one line and the detail truncates,
with the full text in `title`.
6. InFlightList labels each unit with its lane's real job kind
(auto-download-unit / auto-transcribe / auto-digest / auto-backfill) and
hides the status pill (`show.statusPill`, default on elsewhere).
7. The lane line offers Force-release only on a stuck runner job
(`JobRowActions forceRelease="stuck"`).
8. Dead exports removed: loadActionable's reportStateOf re-export, the
function-form column label and ChannelHeadCtx, and JobRow's internal
fmtDuration / queuePlace / JobRowHeading.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
11 files changed, 141 insertions(+), 63 deletions(-)
diff --git a/common/views/channelGroupSections.ts b/common/views/channelGroupSections.ts
@@ -63,6 +63,14 @@ export type StationWork = {
laneOff?: boolean;
};
+// What the /channels client reads of a section: everything but the stats,
+// which it only counts and joins by slug. The page projects to this before the
+// sections cross to the browser, so no ChannelConfig (url, dataDir, yt-dlp
+// args, cookie settings) rides along with them.
+export type ChannelGroupSectionView = Omit<ChannelGroupSection, "channels"> & {
+ channels: Array<{ slug: string }>;
+};
+
export type ChannelGroupSection = {
group: ChannelGroup;
// Membership order asc (absent sorts last), then slug.
diff --git a/editor/app/channels/components/ChannelGroupHeaderRow.tsx b/editor/app/channels/components/ChannelGroupHeaderRow.tsx
@@ -1,6 +1,6 @@
"use client";
-import type { ChannelGroupSection } from "yt-dlp-transcript-common/views/channelGroupSections";
+import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections";
import { ChannelGroupLine } from "./ChannelGroupLine";
// A group's section header: one full-colspan row above its channels' rows, so
@@ -19,7 +19,7 @@ export function ChannelGroupHeaderRow({
siteId,
colSpan,
}: {
- section: ChannelGroupSection;
+ section: ChannelGroupSectionView;
siteId: string;
// The table's full width, owned by ChannelsTable: it is the one that knows
// how many pipeline columns the settings switched on.
diff --git a/editor/app/channels/components/ChannelGroupLine.tsx b/editor/app/channels/components/ChannelGroupLine.tsx
@@ -3,7 +3,7 @@
import Link from "next/link";
import { Fragment, useEffect, useState } from "react";
import type {
- ChannelGroupSection,
+ ChannelGroupSectionView,
StationId,
StationWork,
} from "yt-dlp-transcript-common/views/channelGroupSections";
@@ -28,7 +28,7 @@ type Station = {
// Fixed for four stations; the speakers station reads its label off the
// section (`speakersLabel`), because what that lane is called depends on which
// operations are switched on and only the server can ask the registry.
- label: string | ((section: ChannelGroupSection) => string);
+ label: string | ((section: ChannelGroupSectionView) => string);
// What replaces the button once the group's work is provably finished. A dead
// button reading "Download 0" invites a click that would do nothing.
done: string;
@@ -117,7 +117,7 @@ export function ChannelGroupLine({
section,
siteId,
}: {
- section: ChannelGroupSection;
+ section: ChannelGroupSectionView;
siteId: string;
}) {
const [status, setStatus] = useState<Status>({ kind: "idle" });
diff --git a/editor/app/channels/components/ChannelsRack.tsx b/editor/app/channels/components/ChannelsRack.tsx
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow";
import { BandLegend } from "../../components/pipelines/StateBand";
-import type { ChannelGroupSection } from "yt-dlp-transcript-common/views/channelGroupSections";
+import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections";
import { ChannelFocusBar, type FocusSite } from "./ChannelFocusBar";
import {
ChannelSelectionDeck,
@@ -49,7 +49,7 @@ export function ChannelsRack({
// Null under "all sites" (or with no sites configured): groups only partition
// a SINGLE site's channels, so there is no one grouping to render across the
// pool. That path is today's flat table, unchanged.
- sections?: ChannelGroupSection[] | null;
+ sections?: ChannelGroupSectionView[] | null;
siteId?: string;
// The configured storage locations, resolved on the server. The selection
// deck's destination list; empty when none are configured, which leaves the
diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx
@@ -6,7 +6,7 @@ import {
bandSentence,
type OperationBand,
} from "yt-dlp-transcript-common/views/pipeline/band";
-import type { ChannelGroupSection } from "yt-dlp-transcript-common/views/channelGroupSections";
+import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections";
import { tierOrder } from "yt-dlp-transcript-common/lib/channelPriority";
import { StateBand } from "../../components/pipelines/StateBand";
import { ChannelGroupHeaderRow } from "./ChannelGroupHeaderRow";
@@ -218,7 +218,7 @@ export function ChannelsTable({
selection: false | ChannelSelection;
// One tbody per group, each with its header row. Null (or no siteId) is the
// flat table: groups only partition a SINGLE site's channels.
- sections?: ChannelGroupSection[] | null;
+ sections?: ChannelGroupSectionView[] | null;
siteId?: string;
// The rack: the thead pins to the scroll region's top and the checkbox and
// Slug cells pin to its left. The caller owns the scroll region.
@@ -323,11 +323,7 @@ export function ChannelsTable({
continue;
}
const col = CHANNEL_COLUMNS[id];
- const label =
- labels[id] ??
- (typeof col.label === "function"
- ? col.label({ labels, sticky })
- : col.label);
+ const label = labels[id] ?? col.label;
const className = [
col.th?.className ?? "",
sticky ? (col.th?.stickyClassName ?? "") : "",
@@ -523,8 +519,15 @@ function ChannelTableRow({
// hosts the popover — is the one cell that is NOT dimmed (its registry entry
// says `dim: false`). Moving this back onto the row re-breaks the menu
// (operator report 2026-09-17).
+ //
+ // RACK ONLY. The dashboard and the work tables never dimmed a row: their
+ // priority is neutral, and build exclusion only affects the export — dimming
+ // a channel in an "undownloaded" list would read as "out of the pipeline"
+ // when it is not.
const dim =
- c.excludeFromBuild || c.priority.tier === "paused" ? " opacity-60" : "";
+ sticky && (c.excludeFromBuild || c.priority.tier === "paused")
+ ? " opacity-60"
+ : "";
const ctx: ChannelCellCtx = {
dim,
stickyBg,
@@ -592,6 +595,7 @@ function ChannelTableRow({
className={`${cell.className ?? ""}${col.dim === false ? "" : dim}`}
ariaLabel={cell.ariaLabel}
title={cell.title}
+ suppressHydrationWarning={cell.suppressHydrationWarning}
>
{cell.content}
</Td>,
@@ -676,18 +680,23 @@ function Td({
ariaLabel,
title,
pad,
+ suppressHydrationWarning,
}: {
children: ReactNode;
className?: string;
ariaLabel?: string;
title?: string;
pad: string;
+ // Locale-formatted dates: the server's locale/timezone need not be the
+ // browser's (a Docker runtime is UTC), as JobRow's date cells already allow.
+ suppressHydrationWarning?: boolean;
}) {
return (
<td
className={`${pad} align-middle ${className}`}
aria-label={ariaLabel}
title={title}
+ suppressHydrationWarning={suppressHydrationWarning}
>
{children}
</td>
diff --git a/editor/app/channels/components/channelColumns.tsx b/editor/app/channels/components/channelColumns.tsx
@@ -74,17 +74,15 @@ export type ChannelCell = {
className?: string;
ariaLabel?: string;
title?: string;
+ // Set on locale-formatted date cells; see Td in ./ChannelsTable.tsx.
+ suppressHydrationWarning?: boolean;
};
-export type ChannelHeadCtx = {
- labels: ChannelColumnLabels;
- sticky: boolean;
-};
export type ChannelColumn = {
// A caller's `labels[id]` wins over this — a work section names its count
// column ("undownloaded") and calls the report column "Last report".
- label: string | ((ctx: ChannelHeadCtx) => string);
+ label: string;
align?: "left" | "right";
sortKey?: ChannelSortKey;
// `stickyClassName` applies only in the rack (`sticky`), where the Slug
@@ -244,6 +242,7 @@ export const CHANNEL_COLUMNS: Record<
th: { className: "whitespace-nowrap" },
cell: (c, ctx) => ({
className: "whitespace-nowrap text-xs tabular-nums text-muted-foreground",
+ suppressHydrationWarning: true,
// The rack leaves a never-synced cell empty; a work table says so.
content: formatStamp(c.lastSyncedAt) || (ctx.sticky ? "" : "never"),
}),
@@ -268,16 +267,26 @@ export const CHANNEL_COLUMNS: Record<
title:
"When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong.",
},
- cell: (c) => ({
- ariaLabel: `report age for ${c.slug}`,
- className: `whitespace-nowrap text-xs tabular-nums ${
- c.report.state === "current" ? "text-muted-foreground" : "text-warning"
- }`,
- content:
- c.report.state === "current"
- ? formatStamp(c.report.generatedAt)
- : c.report.state,
- }),
+ // The rack says only the state word when the report cannot be trusted; a
+ // work table ("Last report") keeps the date beside it — how stale is what
+ // decides whether that row's count can be believed.
+ cell: (c, ctx) => {
+ const stamp = formatStamp(c.report.generatedAt);
+ return {
+ ariaLabel: `report age for ${c.slug}`,
+ className: `whitespace-nowrap text-xs tabular-nums ${
+ c.report.state === "current" ? "text-muted-foreground" : "text-warning"
+ }`,
+ title: stamp || undefined,
+ suppressHydrationWarning: true,
+ content:
+ c.report.state === "current"
+ ? stamp
+ : !ctx.sticky && c.report.state === "stale" && stamp
+ ? `stale · ${stamp}`
+ : c.report.state,
+ };
+ },
},
// THE TWO STORAGE COLUMNS. Which disk the media is on, and how much of it
// there is — the pair that makes "what should I move" answerable without
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -46,7 +46,10 @@ import {
type ChannelRowView,
} from "yt-dlp-transcript-common/views/channelRow";
import type { ChannelVolume } from "./components/ChannelVolumeBar";
-import { buildChannelGroupSections } from "yt-dlp-transcript-common/views/channelGroupSections";
+import {
+ buildChannelGroupSections,
+ type ChannelGroupSectionView,
+} from "yt-dlp-transcript-common/views/channelGroupSections";
import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton";
import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton";
import { resolveActiveSite } from "../lib/activeSite";
@@ -308,14 +311,19 @@ export default async function ChannelsPage({
const shownSlugs = new Set(channels.map((c) => c.slug));
// GROUPS ARE A PARTITION OF A SITE, so a volume filter and the grouped render
// cannot both be true: half a group is not a group. Filtering flattens.
- const sections =
+ // PROJECTED to slugs before they cross to the client: a section's stats
+ // carry each channel's full config, and the rack reads only slug and count.
+ const sections: ChannelGroupSectionView[] | null =
activeSite && locationFilter === null
? buildChannelGroupSections(
activeSite,
stats.filter((st) => shownSlugs.has(st.slug)),
briefs,
settings,
- )
+ ).map((s) => ({
+ ...s,
+ channels: s.channels.map((c) => ({ slug: c.slug })),
+ }))
: null;
const freshness = summariseFreshness(
briefs.filter((b) => shownSlugs.has(b.slug)),
diff --git a/editor/app/jobs/components/JobRow.tsx b/editor/app/jobs/components/JobRow.tsx
@@ -38,6 +38,7 @@ export type JobRowShow = {
links: boolean; // Job / channel / video links (compact: plain text when off)
log: boolean; // card: the Show log toggle + tail
elapsed: boolean; // compact: "3m07s" since startedAt, at the line's end
+ statusPill: boolean; // off where every row is running by construction
};
type Variant = "table" | "card" | "compact";
@@ -52,6 +53,7 @@ const DEFAULTS: Record<Variant, JobRowShow> = {
links: true,
log: false,
elapsed: false,
+ statusPill: true,
},
card: {
tasks: true,
@@ -62,6 +64,7 @@ const DEFAULTS: Record<Variant, JobRowShow> = {
links: true,
log: false,
elapsed: false,
+ statusPill: true,
},
compact: {
tasks: true,
@@ -72,10 +75,11 @@ const DEFAULTS: Record<Variant, JobRowShow> = {
links: false,
log: false,
elapsed: false,
+ statusPill: true,
},
};
-export function fmtDuration(ms: number): string {
+function fmtDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)}s`;
@@ -84,7 +88,7 @@ export function fmtDuration(ms: number): string {
}
// "next in line" reads better than "1st"; past that, an ordinal.
-export function queuePlace(position: number): string {
+function queuePlace(position: number): string {
if (position === 1) return "next in line";
const rem100 = position % 100;
const rem10 = position % 10;
@@ -136,7 +140,15 @@ function videoHref(job: JobRowView): string | null {
// anything live, and Force-release anything running or stuck (the escape hatch
// for a Cancel that leaves a queue wedged; see registry.forceRelease — a stuck
// terminal/evicted row has no Cancel to offer, so it gets this instead).
-export function JobRowActions({ job }: { job: JobRowView }) {
+export function JobRowActions({
+ job,
+ forceRelease = "running-or-stuck",
+}: {
+ job: JobRowView;
+ // The lane line offers Force-release only on a STUCK runner: for a healthy
+ // one it does what Cancel beside it does, as a second, filled-red button.
+ forceRelease?: "running-or-stuck" | "stuck";
+}) {
return (
<>
{job.status === "failed" && job.replayable && (
@@ -155,9 +167,8 @@ export function JobRowActions({ job }: { job: JobRowView }) {
{(job.status === "running" || job.status === "queued") && (
<CancelJobButton jobId={job.id} />
)}
- {(job.status === "running" || Boolean(job.stuck)) && (
- <ForceReleaseJobButton jobId={job.id} />
- )}
+ {((forceRelease === "running-or-stuck" && job.status === "running") ||
+ Boolean(job.stuck)) && <ForceReleaseJobButton jobId={job.id} />}
</>
);
}
@@ -180,7 +191,7 @@ function AutoBadge({ compact }: { compact?: boolean }) {
// THE HEADING: status pill · kind · detail · auto badge · links · queue key.
// "Fetch window" alone is the least useful row on a page — every window fetch
// has that kind — so WHO ASKED (`detail`) sits beside the kind.
-export function JobRowHeading({
+function JobRowHeading({
job,
compact = false,
links = true,
@@ -188,7 +199,9 @@ export function JobRowHeading({
hideVideoId,
headingProgress = false,
eta = false,
+ statusPill = true,
}: {
+ statusPill?: boolean;
job: JobRowView;
compact?: boolean;
links?: boolean;
@@ -201,24 +214,36 @@ export function JobRowHeading({
const video = videoHref(job);
return (
<>
+ {statusPill && (
+ <span
+ className={
+ compact
+ ? `uppercase tracking-wide px-1.5 py-0.5 rounded text-[10px] ${statusColor(job.status)}`
+ : `text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(job.status)}`
+ }
+ >
+ {job.status}
+ </span>
+ )}
<span
- className={
- compact
- ? `uppercase tracking-wide px-1.5 py-0.5 rounded text-[10px] ${statusColor(job.status)}`
- : `text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(job.status)}`
- }
- >
- {job.status}
- </span>
- <span
- className={compact ? "font-mono" : "text-xs font-medium"}
+ className={compact ? "shrink-0 font-mono" : "text-xs font-medium"}
title={job.kind}
>
{job.kind ? jobKindLabel(job.kind) : "—"}
</span>
- {job.detail && (
- <span className="text-xs text-muted-foreground">· {job.detail}</span>
- )}
+ {job.detail &&
+ (compact ? (
+ // One line, always: a pinned widget and an in-flight list are
+ // narrow, and "rule N · <leaf sentence>" would wrap into a paragraph.
+ <span
+ className="min-w-0 truncate text-xs text-muted-foreground"
+ title={job.detail}
+ >
+ · {job.detail}
+ </span>
+ ) : (
+ <span className="text-xs text-muted-foreground">· {job.detail}</span>
+ ))}
{job.background && <AutoBadge compact={compact} />}
{links && linksToJob(job) && (
<Link href={`/jobs/${job.id}`} className={linkClass}>
@@ -314,10 +339,11 @@ export function JobRow({
);
return (
<div className="flex flex-col gap-1">
- <div className="flex flex-wrap items-center gap-1.5 text-xs">
+ <div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-xs">
<JobRowHeading
job={job}
compact
+ statusPill={show.statusPill}
links={show.links}
hideChannelSlug={hideChannelSlug}
hideVideoId={hideVideoId}
@@ -384,7 +410,9 @@ function CardRow({
</div>
)}
</div>
- {show.jobBar && job.progress && <JobProgressBar progress={job.progress} />}
+ {show.jobBar && job.progress && (
+ <JobProgressBar progress={job.progress} />
+ )}
{show.tasks && <TaskList job={job} />}
{showLog && <JobLogTail jobId={job.id} initiallyRunning />}
</li>
@@ -422,7 +450,10 @@ function TableRow({
className="border-t border-border"
>
<td className="px-3 py-2 font-mono text-xs align-top">
- <Link href={`/jobs/${j.id}`} className="underline hover:text-foreground">
+ <Link
+ href={`/jobs/${j.id}`}
+ className="underline hover:text-foreground"
+ >
{j.id}
</Link>
</td>
diff --git a/editor/app/jobs/components/LaneStrip.tsx b/editor/app/jobs/components/LaneStrip.tsx
@@ -81,7 +81,7 @@ function LaneRow({
>
log
</Link>
- <JobRowActions job={job} />
+ <JobRowActions job={job} forceRelease="stuck" />
</span>
)}
</section>
diff --git a/editor/app/lib/actionable/loadActionable.ts b/editor/app/lib/actionable/loadActionable.ts
@@ -4,7 +4,6 @@ import type { ChannelBrief } from "yt-dlp-transcript-common/controller/channels"
import type { WidgetActionableChannel } from "yt-dlp-transcript-common/views/widgetActionable";
import { getChannelBriefs } from "../requestCache";
import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
-import { reportStateOf } from "yt-dlp-transcript-common/views/channelRow";
import {
cleanExtraFormatsBytesOf,
cleanExtraFormatsCountOf,
@@ -40,10 +39,6 @@ export type ActionableSummary = {
digestWarnings: ActionableRow[];
};
-// `reportStateOf` lives in common/views/channelRow.ts now, beside the row
-// builder that reads it; re-exported so every importer keeps its path.
-export { reportStateOf };
-
// THE COUNT HELPERS, as wrappers. The counting itself is
// common/views/actionableCounts.ts — pure functions over one snapshot — which
// the channel-row builder calls too, so a dashboard cell and this census cannot
diff --git a/editor/app/operations/components/InFlightList.tsx b/editor/app/operations/components/InFlightList.tsx
@@ -3,8 +3,22 @@
import type { AutoQueueKindStatus } from "yt-dlp-transcript-common/views/autoQueueStatus";
import { fromInFlight } from "yt-dlp-transcript-common/views/jobRows";
import { JobRow } from "../../jobs/components/JobRow";
+import type { AutoQueueKind } from "yt-dlp-transcript-common/lib/autoQueueTypes";
import { type Channel, leafOrder, leafSentence } from "./dispatch";
+// The JOB kind a lane's unit runs under, so the row's label resolves
+// (jobKindLabel) rather than showing a raw lane id. A download unit is its own
+// registry job; every other lane's unit is a task on the lane's runner job.
+// Mirrors autoRunner.ts's RUNNER_JOB_KINDS / AUTO_DOWNLOAD_UNIT_KIND, which a
+// client component cannot import (that module reaches the filesystem); a
+// Record over the lane union, so a new lane is a compile error here.
+const UNIT_JOB_KIND: Record<AutoQueueKind, string> = {
+ download: "auto-download-unit",
+ transcription: "auto-transcribe",
+ digest: "auto-digest",
+ backfill: "auto-backfill",
+};
+
// What the runner is doing RIGHT NOW. `runner.inFlight` has always been in the
// status payload — video id, owning channel, the leaf that claimed it and when
// it started — and the old page rendered a count and threw the rest away.
@@ -47,7 +61,7 @@ export function InFlightList({
leaf ? ` · ${leafSentence(leaf, channels)}` : ""
}`;
const row = fromInFlight(item, {
- kind: status.kind,
+ kind: UNIT_JOB_KIND[status.kind],
runnerJobId: status.runner.jobId,
detail,
});
@@ -61,6 +75,10 @@ export function InFlightList({
tasks: false,
links: true,
elapsed: true,
+ // Every unit here is in flight by construction; a pill
+ // saying so on each line is noise (and wrong for a download
+ // unit still queued on its platform).
+ statusPill: false,
}}
now={now}
/>