commit fd8acd675d37c1ce02c847efeb8b13699389e3e9
parent 4057f76683f5508b6703374e6204973d28b176e2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 27 Apr 2026 00:30:57 -0400
feat(editor): cancel running jobs and fixed-height log boxes
Adds a Cancel button at every place a running job is visible and
converts log boxes from grow-to-max-height to fixed 24rem with smart
auto-follow.
UI:
- common/components/StreamActionLog: new optional cancelAction prop;
when running with a known jobId the box renders a Cancel button next
to the run button. Pre is now h-96 (fixed) and a useLayoutEffect
scrolls to bottom only when the user is within ~24px of it, so manual
scroll-up sticks until they scroll back down. Wired into all four
consumer panels (build, pipeline ×3, whisper ×2).
- editor/app/jobs/[id]/JobLogTail: same fixed-height + smart-follow
treatment. Inline Cancel button next to status when status === running;
imports cancelJobAction directly.
- editor/app/jobs/page.tsx + new CancelJobButton: per-row Cancel button
on running rows in the jobs table. Refreshes the route on success.
Cypress:
- editor/cypress/fixtures/bin/fake-ytdlp.mjs honors --cy-slow: a
cancel-test hook that sleeps 30s on stderr before doing any work, so
SIGTERM during the sleep is clean.
- New fixture slow-pipeline-channel with --cy-slow in ytdlpExtraArgs.
- editor/cypress/e2e/cancel.cy.ts (2 specs):
- cancels a running pipeline job from the channel panel and asserts
the run button re-enables and no data dir was written.
- kicks off a job, then cancels it from the /jobs list and waits for
the row to flip to "cancelled".
Verified: 30/30 cypress specs pass; pnpm --filter export run build still
produces the existing static output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat:
10 files changed, 214 insertions(+), 14 deletions(-)
diff --git a/common/components/StreamActionLog.tsx b/common/components/StreamActionLog.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { StreamActionResult } from "../jobs/streamCommand";
type Props = {
@@ -8,6 +8,7 @@ type Props = {
buttonLabel: string;
runningLabel?: string;
testId?: string;
+ cancelAction?: (id: string) => Promise<{ ok: boolean }>;
};
export function StreamActionLog({
@@ -15,21 +16,41 @@ export function StreamActionLog({
buttonLabel,
runningLabel = "Running…",
testId,
+ cancelAction,
}: Props) {
const [running, setRunning] = useState(false);
const [log, setLog] = useState("");
const [error, setError] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
+ const [cancelling, setCancelling] = useState(false);
const preRef = useRef<HTMLPreElement | null>(null);
+ const stickToBottomRef = useRef(true);
- useEffect(() => {
- if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
+ // After every log update, only scroll to the bottom if the user hasn't
+ // scrolled away. useLayoutEffect avoids a visible flicker between paint
+ // and the scroll adjustment.
+ useLayoutEffect(() => {
+ const el = preRef.current;
+ if (!el || !stickToBottomRef.current) return;
+ el.scrollTop = el.scrollHeight;
}, [log]);
+ const handleScroll = useCallback(() => {
+ const el = preRef.current;
+ if (!el) return;
+ const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ stickToBottomRef.current = distFromBottom < 24;
+ }, []);
+
+ useEffect(() => {
+ if (running) stickToBottomRef.current = true;
+ }, [running]);
+
async function handleClick() {
setError(null);
setLog("");
setJobId(null);
+ stickToBottomRef.current = true;
setRunning(true);
try {
const result = await trigger();
@@ -51,9 +72,19 @@ export function StreamActionLog({
}
}
+ async function handleCancel() {
+ if (!jobId || !cancelAction) return;
+ setCancelling(true);
+ try {
+ await cancelAction(jobId);
+ } finally {
+ setCancelling(false);
+ }
+ }
+
return (
<div className="flex flex-col gap-2" data-testid={testId}>
- <div className="flex items-center gap-3">
+ <div className="flex items-center gap-3 flex-wrap">
<button
type="button"
onClick={handleClick}
@@ -62,6 +93,17 @@ export function StreamActionLog({
>
{running ? runningLabel : buttonLabel}
</button>
+ {running && jobId && cancelAction && (
+ <button
+ type="button"
+ onClick={handleCancel}
+ disabled={cancelling}
+ data-testid={testId ? `${testId}-cancel` : undefined}
+ className="px-3 py-2 rounded-md border border-red-300 dark:border-red-800 text-sm font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50"
+ >
+ {cancelling ? "Cancelling…" : "Cancel"}
+ </button>
+ )}
{jobId && (
<span className="text-xs text-zinc-500 font-mono">job {jobId}</span>
)}
@@ -77,8 +119,9 @@ export function StreamActionLog({
{(log || running) && (
<pre
ref={preRef}
+ onScroll={handleScroll}
data-testid={testId ? `${testId}-log` : undefined}
- className="text-xs font-mono bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded p-3 max-h-96 overflow-auto whitespace-pre-wrap"
+ className="text-xs font-mono bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded p-3 h-96 overflow-auto whitespace-pre-wrap"
>
{log || "Waiting for output…"}
</pre>
diff --git a/editor/app/build/page.tsx b/editor/app/build/page.tsx
@@ -1,4 +1,5 @@
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { cancelJobAction } from "../jobs/actions";
import { buildExportAction, buildIndexAction } from "./buildAction";
export default function BuildPage() {
@@ -17,6 +18,7 @@ export default function BuildPage() {
</div>
<StreamActionLog
trigger={buildIndexAction}
+ cancelAction={cancelJobAction}
buttonLabel="Build index"
runningLabel="Building index…"
testId="build-index"
@@ -34,6 +36,7 @@ export default function BuildPage() {
</div>
<StreamActionLog
trigger={buildExportAction}
+ cancelAction={cancelJobAction}
buttonLabel="Build static export"
runningLabel="Building static export…"
testId="build-export"
diff --git a/editor/app/channels/[slug]/_components/PipelinePanel.tsx b/editor/app/channels/[slug]/_components/PipelinePanel.tsx
@@ -1,6 +1,7 @@
"use client";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { cancelJobAction } from "../../../jobs/actions";
import {
downloadAction,
storePlaylistAction,
@@ -30,6 +31,7 @@ export function PipelinePanel({ slug, hasUrl }: Props) {
/>
<StreamActionLog
trigger={() => storePlaylistAction(slug)}
+ cancelAction={cancelJobAction}
buttonLabel="Store playlist"
runningLabel="Storing playlist…"
testId="pipeline-store"
@@ -42,6 +44,7 @@ export function PipelinePanel({ slug, hasUrl }: Props) {
/>
<StreamActionLog
trigger={() => downloadAction(slug)}
+ cancelAction={cancelJobAction}
buttonLabel="Download from playlist"
runningLabel="Downloading…"
testId="pipeline-download"
@@ -54,6 +57,7 @@ export function PipelinePanel({ slug, hasUrl }: Props) {
/>
<StreamActionLog
trigger={() => syncAction(slug)}
+ cancelAction={cancelJobAction}
buttonLabel="Sync"
runningLabel="Syncing…"
testId="pipeline-sync"
diff --git a/editor/app/channels/[slug]/_components/WhisperPanel.tsx b/editor/app/channels/[slug]/_components/WhisperPanel.tsx
@@ -2,6 +2,7 @@
import { useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { cancelJobAction } from "../../../jobs/actions";
import {
retryFailuresAction,
transcribeMissingAction,
@@ -23,6 +24,7 @@ export function WhisperPanel({ slug }: Props) {
/>
<StreamActionLog
trigger={() => transcribeMissingAction(slug)}
+ cancelAction={cancelJobAction}
buttonLabel="Transcribe missing"
runningLabel="Transcribing…"
testId="whisper-all"
@@ -35,6 +37,7 @@ export function WhisperPanel({ slug }: Props) {
/>
<StreamActionLog
trigger={() => retryFailuresAction(slug)}
+ cancelAction={cancelJobAction}
buttonLabel="Retry failures"
runningLabel="Retrying…"
testId="whisper-retry"
diff --git a/editor/app/jobs/[id]/_components/JobLogTail.tsx b/editor/app/jobs/[id]/_components/JobLogTail.tsx
@@ -1,6 +1,13 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { cancelJobAction } from "../../actions";
type Props = {
jobId: string;
@@ -19,13 +26,24 @@ export function JobLogTail({ jobId, initiallyRunning, testId }: Props) {
const [status, setStatus] = useState<string>(
initiallyRunning ? "running" : "loaded",
);
+ const [cancelling, setCancelling] = useState(false);
const offsetRef = useRef(0);
const preRef = useRef<HTMLPreElement | null>(null);
+ const stickToBottomRef = useRef(true);
- useEffect(() => {
- if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
+ useLayoutEffect(() => {
+ const el = preRef.current;
+ if (!el || !stickToBottomRef.current) return;
+ el.scrollTop = el.scrollHeight;
}, [log]);
+ const handleScroll = useCallback(() => {
+ const el = preRef.current;
+ if (!el) return;
+ const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
+ stickToBottomRef.current = dist < 24;
+ }, []);
+
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
@@ -56,18 +74,43 @@ export function JobLogTail({ jobId, initiallyRunning, testId }: Props) {
};
}, [jobId]);
+ async function handleCancel() {
+ setCancelling(true);
+ try {
+ await cancelJobAction(jobId);
+ } finally {
+ setCancelling(false);
+ }
+ }
+
+ const isRunning = status === "running";
+
return (
<div className="flex flex-col gap-2" data-testid={testId}>
- <div className="text-xs text-zinc-500">
- Status:{" "}
- <span data-testid={testId ? `${testId}-status` : undefined}>
- {status}
- </span>
+ <div className="flex items-center gap-3 flex-wrap">
+ <div className="text-xs text-zinc-500">
+ Status:{" "}
+ <span data-testid={testId ? `${testId}-status` : undefined}>
+ {status}
+ </span>
+ </div>
+ {isRunning && (
+ <button
+ type="button"
+ onClick={handleCancel}
+ disabled={cancelling}
+ data-testid={testId ? `${testId}-cancel` : undefined}
+ className="px-3 py-1.5 rounded-md border border-red-300 dark:border-red-800 text-sm font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50"
+ >
+ {cancelling ? "Cancelling…" : "Cancel"}
+ </button>
+ )}
</div>
<pre
ref={preRef}
+ onScroll={handleScroll}
data-testid={testId ? `${testId}-pre` : undefined}
- className="text-xs font-mono bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded p-3 max-h-[28rem] overflow-auto whitespace-pre-wrap"
+ className="text-xs font-mono bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded p-3 h-96 overflow-auto whitespace-pre-wrap"
>
{log || "(empty log)"}
</pre>
diff --git a/editor/app/jobs/_components/CancelJobButton.tsx b/editor/app/jobs/_components/CancelJobButton.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { cancelJobAction } from "../actions";
+
+type Props = {
+ jobId: string;
+ testId?: string;
+};
+
+export function CancelJobButton({ jobId, testId }: Props) {
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ return (
+ <button
+ type="button"
+ onClick={async () => {
+ setBusy(true);
+ try {
+ await cancelJobAction(jobId);
+ router.refresh();
+ } finally {
+ setBusy(false);
+ }
+ }}
+ disabled={busy}
+ data-testid={testId}
+ className="px-2 py-1 rounded border border-red-300 dark:border-red-800 text-xs font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50"
+ >
+ {busy ? "Cancelling…" : "Cancel"}
+ </button>
+ );
+}
diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx
@@ -2,6 +2,7 @@ import Link from "next/link";
import { listAllJobs } from "yt-dlp-transcript-common/jobs/listJobs";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { ClearArchivedButton } from "./_components/ClearArchivedButton";
+import { CancelJobButton } from "./_components/CancelJobButton";
export const dynamic = "force-dynamic";
@@ -56,6 +57,7 @@ export default async function JobsPage() {
<th className="text-left font-medium px-3 py-2">Started</th>
<th className="text-left font-medium px-3 py-2">Duration</th>
<th className="text-right font-medium px-3 py-2">Log size</th>
+ <th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
@@ -94,6 +96,14 @@ export default async function JobsPage() {
<td className="px-3 py-2 text-xs text-zinc-500 text-right">
{j.logSize.toLocaleString()} B
</td>
+ <td className="px-3 py-2 text-right">
+ {j.status === "running" && (
+ <CancelJobButton
+ jobId={j.id}
+ testId={`cancel-${j.id}`}
+ />
+ )}
+ </td>
</tr>
);
})}
diff --git a/editor/cypress/e2e/cancel.cy.ts b/editor/cypress/e2e/cancel.cy.ts
@@ -0,0 +1,42 @@
+describe("Cancel job", () => {
+ it("cancels a running pipeline job from the channel page", () => {
+ cy.resetData("slow-pipeline-channel");
+ cy.visit("/channels/slow-channel");
+ cy.findByTestId("pipeline-sync").find("button").click();
+ // Wait for the slow fake-ytdlp to start logging.
+ cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should(
+ "contain.text",
+ "cypress-slow",
+ );
+ cy.findByTestId("pipeline-sync-cancel").click();
+ // After cancel, the run button should re-enable (Cancel button gone).
+ cy.findByTestId("pipeline-sync-cancel", { timeout: 15_000 }).should(
+ "not.exist",
+ );
+ cy.findByTestId("pipeline-sync")
+ .find("button")
+ .should("not.be.disabled")
+ .and("contain.text", "Sync");
+ // No data dir should have been written.
+ cy.task(
+ "pathExists",
+ "test-transcripts/channels/slow-channel/data",
+ ).then((exists) => expect(exists).to.equal(false));
+ });
+
+ it("cancels a running job from the jobs list", () => {
+ cy.resetData("slow-pipeline-channel");
+ cy.visit("/channels/slow-channel");
+ cy.findByTestId("pipeline-sync").find("button").click();
+ cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should(
+ "contain.text",
+ "cypress-slow",
+ );
+
+ cy.visit("/jobs");
+ cy.contains("running");
+ cy.get('[data-testid^="cancel-"]').first().click();
+ // Row's status flips to cancelled (refresh runs, page rerenders).
+ cy.contains("cancelled", { timeout: 15_000 });
+ });
+});
diff --git a/editor/cypress/fixtures/bin/fake-ytdlp.mjs b/editor/cypress/fixtures/bin/fake-ytdlp.mjs
@@ -120,7 +120,19 @@ async function modeSync(url) {
await downloadOne("https://www.youtube.com/watch?v=fakeSync0001");
}
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
async function main() {
+ // Cypress hook for cancellation tests — sleep before doing any work so
+ // a Cancel click during the sleep can cleanly SIGTERM us.
+ if (has("--cy-slow")) {
+ // Stderr because store-playlist mode buffers stdout for the URL list.
+ process.stderr.write(`[fake-ytdlp] sleeping (cypress-slow)\n`);
+ await sleep(30_000);
+ }
+
if (
has("--flat-playlist") &&
has("--skip-download") &&
diff --git a/editor/cypress/fixtures/test-transcripts/slow-pipeline-channel/channels/slow-channel/config.json b/editor/cypress/fixtures/test-transcripts/slow-pipeline-channel/channels/slow-channel/config.json
@@ -0,0 +1,6 @@
+{
+ "handling": "youtube",
+ "name": "Slow Channel",
+ "url": "https://www.youtube.com/@slow/videos",
+ "ytdlpExtraArgs": ["--cy-slow"]
+}