commit 3cacaa177064da3e59457c391a5f63469f70537c
parent dbeca16b6375651041928e567e970a97970ac50f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 22 May 2026 08:51:31 -0400
fix actionable page, make active/jobs badges not redundant
Diffstat:
5 files changed, 112 insertions(+), 20 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -20,6 +20,8 @@
### Changed
- "Recent changes" card on the dashboard now clamps to a fixed height with a gradient fade at the bottom; the entire card is a link to `/changelog`.
- `/channels` rows are now dimmed when a channel is excluded from Sync all, matching the existing treatment for build exclusion.
+- `/jobs/active` now lists running jobs above queued jobs — both within each channel section and in the section order itself. Channels with running work appear before queued-only channels so the live jobs stay at the top of the page without scrolling. Ordering within each status group still follows the existing `queuedAt`-descending tiebreaker.
+- Sidebar nav badges are no longer redundant: **Jobs** continues to count running + queued, while **Active** now counts only running jobs.
### Fixed
- Channel page: clicking a pipeline stage in the side rail (or a mobile stage badge) no longer jumps up into the selected video's viewer panel. The per-video stage cards were emitting the same DOM ids as the channel-level pipeline sections, so the browser scrolled to the first match. The per-video cards now use a `video-stage-` prefix.
diff --git a/editor/app/actionable/actions.ts b/editor/app/actionable/actions.ts
@@ -2,10 +2,6 @@
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import {
- detectPlatform,
- platformQueueKey,
-} from "yt-dlp-transcript-common/lib/platform";
import { listChannels } from "yt-dlp-transcript-common/controller/channels";
import { generateChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
@@ -16,6 +12,18 @@ export type RefreshAllResult = {
skipped: { slug: string; reason: string }[];
};
+async function drainStream(stream: ReadableStream<string>): Promise<void> {
+ const reader = stream.getReader();
+ try {
+ while (true) {
+ const { done } = await reader.read();
+ if (done) return;
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
+
export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResult> {
const paths = getPaths();
const channels = await listChannels(paths);
@@ -32,17 +40,19 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu
);
const queued: string[] = [];
const skipped: { slug: string; reason: string }[] = [];
+ const streams: ReadableStream<string>[] = [];
for (const c of channels) {
if (active.has(c.slug)) {
skipped.push({ slug: c.slug, reason: "already running" });
continue;
}
- const queueKey = platformQueueKey(
- c.config.platform ?? detectPlatform(c.config.url),
- );
const result = await runManagedFunction({
kind: "refresh-report",
- queueKey,
+ // Empty queueKey: bypass queue serialization. Snapshot regen is a
+ // local filesystem scan that never touches the platform, so there's
+ // no reason for it to wait behind sync/download work. See
+ // registry.ts:69-72 for the documented escape hatch.
+ queueKey: "",
paths,
channelSlug: c.slug,
fn: async (onLog) => {
@@ -53,7 +63,10 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu
`${snap.undownloadedIds.length} undownloaded · ` +
`${snap.buckets.downloadedNoTranscript.length} awaiting transcription.`,
);
- revalidatePath(`/channels/${c.slug}`);
+ // Deliberately no revalidatePath here — calling it from a
+ // background fn races with the in-flight re-render of /actionable
+ // that the action's own revalidatePath triggers. The action's
+ // single revalidate at the end picks up every fresh snapshot.
},
});
if (!result.ok) {
@@ -61,10 +74,13 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu
continue;
}
queued.push(c.slug);
- // The job keeps running and writes to disk via runManagedFunction's onLog;
- // no consumer here means we drop the stream to free buffered chunks.
- void result.stream.cancel();
+ streams.push(result.stream);
}
+ // Wait for all snapshots to finish writing before revalidating so the
+ // re-rendered /actionable reads fresh counts. With queueKey === "" the
+ // jobs all run in parallel, so this waits roughly the time of the
+ // slowest snapshot, not the sum.
+ await Promise.all(streams.map(drainStream));
revalidatePath("/actionable");
revalidatePath("/");
return { queued, skipped };
diff --git a/editor/app/jobs/active/page.tsx b/editor/app/jobs/active/page.tsx
@@ -22,6 +22,10 @@ export default async function ActiveJobsPage() {
const activeJobs: RunningJobsListItem[] = registry
.list()
.filter((j) => j.status === "running" || j.status === "queued")
+ .sort((a, b) => {
+ if (a.status === b.status) return 0;
+ return a.status === "running" ? -1 : 1;
+ })
.map((j) => ({
id: j.id,
kind: j.kind,
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -25,7 +25,7 @@ export async function generateMetadata(): Promise<Metadata> {
type NavLink = {
href: string;
label: string;
- badgeKey?: "jobs" | "changelog";
+ badgeKey?: "jobs" | "running" | "changelog";
};
const NAV_LINKS: NavLink[] = [
@@ -33,7 +33,7 @@ const NAV_LINKS: NavLink[] = [
{ href: "/channels", label: "Channels" },
{ href: "/actionable", label: "Actionable" },
{ href: "/jobs", label: "Jobs", badgeKey: "jobs" },
- { href: "/jobs/active", label: "Active", badgeKey: "jobs" },
+ { href: "/jobs/active", label: "Active", badgeKey: "running" },
{ href: "/build", label: "Build" },
{ href: "/deploy", label: "Deploy" },
{ href: "/settings", label: "Settings" },
@@ -59,9 +59,11 @@ export default async function RootLayout({
}>) {
const settings = getSettings();
const headerLabel = settings.headerTitle || settings.siteTitle;
- const activeJobs = getRegistry()
- .list()
- .filter((j) => j.status === "running" || j.status === "queued").length;
+ const allJobs = getRegistry().list();
+ const activeJobs = allJobs.filter(
+ (j) => j.status === "running" || j.status === "queued",
+ ).length;
+ const runningJobs = allJobs.filter((j) => j.status === "running").length;
const latestChangelogDate = readChangelogLatestDate();
return (
<html lang="en" className="h-full antialiased">
@@ -93,8 +95,17 @@ export default async function RootLayout({
/>
);
}
+ const badgeCount =
+ link.badgeKey === "jobs"
+ ? activeJobs
+ : link.badgeKey === "running"
+ ? runningJobs
+ : 0;
+ const badgeNoun =
+ link.badgeKey === "running" ? "running job" : "active job";
const showBadge =
- link.badgeKey === "jobs" && activeJobs > 0;
+ (link.badgeKey === "jobs" || link.badgeKey === "running") &&
+ badgeCount > 0;
return (
<Link
key={link.href}
@@ -104,10 +115,10 @@ export default async function RootLayout({
<span>{link.label}</span>
{showBadge && (
<span
- aria-label={`${activeJobs} active job${activeJobs === 1 ? "" : "s"}`}
+ aria-label={`${badgeCount} ${badgeNoun}${badgeCount === 1 ? "" : "s"}`}
className="ml-auto text-xs rounded-full border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 px-2 py-0.5 leading-none"
>
- {activeJobs}
+ {badgeCount}
</span>
)}
</Link>
diff --git a/editor/e2e/jobs-active-order.spec.ts b/editor/e2e/jobs-active-order.spec.ts
@@ -0,0 +1,59 @@
+import { test, expect } from "@playwright/test";
+import { resetData } from "./helpers";
+
+async function setQueueViaCustom(
+ page: import("@playwright/test").Page,
+ actionLabel: string,
+ value: string,
+) {
+ await page
+ .getByLabel(`queue for ${actionLabel}`, { exact: true })
+ .selectOption({ label: "Custom…" });
+ await page
+ .getByLabel(`custom queue for ${actionLabel}`, { exact: true })
+ .fill(value);
+ await page
+ .getByRole("button", { name: `save custom queue for ${actionLabel}` })
+ .click();
+}
+
+test("active jobs page sorts channels with running jobs before queued-only channels", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+
+ await page.goto("/channels/slow-a");
+ await setQueueViaCustom(page, "Sync", "qShared");
+ await page.getByRole("button", { name: "Sync" }).click();
+ await expect(page.getByLabel("Sync output")).toContainText("test-slow", {
+ timeout: 15_000,
+ });
+
+ await page.goto("/channels/slow-b");
+ await page
+ .getByLabel("queue for Sync", { exact: true })
+ .selectOption({ label: "qShared" });
+ await page.getByRole("button", { name: "Sync" }).click();
+ await expect(page.getByLabel("Sync queue status")).toContainText("qShared", {
+ timeout: 10_000,
+ });
+
+ await page.goto("/jobs/active");
+
+ const sections = page.locator("section[aria-label^='Active jobs for ']");
+ await expect(sections).toHaveCount(2);
+ const labels = await sections.evaluateAll((els) =>
+ els.map((e) => e.getAttribute("aria-label") ?? ""),
+ );
+ expect(labels[0]).toContain("Slow A");
+ expect(labels[1]).toContain("Slow B");
+
+ const slowASection = sections.filter({ hasText: "Slow A" });
+ const slowBSection = sections.filter({ hasText: "Slow B" });
+ await expect(slowASection.getByText("running", { exact: true })).toBeVisible();
+ await expect(slowBSection.getByText("queued", { exact: true })).toBeVisible();
+
+ // Nav badges: Jobs counts running + queued; Active counts running only.
+ await expect(page.getByLabel("2 active jobs")).toHaveText("2");
+ await expect(page.getByLabel("1 running job")).toHaveText("1");
+});