commit bcb66f976f72aa2a6391a2e3733191141596890d
parent 97b65df9216b0ab9a633132d8060d28dd5d51fe3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 4 Aug 2026 21:04:47 -0400
Add loading and error boundaries, and a client router cache
The editor had no error boundary anywhere: a throw in any page blanked the
whole document. Adds a route error.tsx that keeps the sidebar alive and
surfaces the digest, using Next 16.2's `unstable_retry` (which re-fetches)
rather than the older `reset` (which only clears error state).
Also adds loading.tsx skeletons (root + the four heaviest routes), extracts
the sidebar's reclaimable-disk badge behind its own Suspense so a layout's
async work can never block a document again, and sets
experimental.staleTimes.dynamic = 15s.
On loading.tsx, measured rather than assumed: it does NOT show a fallback for
a client-side navigation between two children of the root layout. Verified in
dev and against a production build, under three different ways of stalling the
payload — when the destination is prefetched the click commits from cache with
no pending state, and when it isn't the router waits on the server and stays on
the old page. Next's own loading.js reference says as much ("does not guarantee
instant client-side navigations"); guaranteeing it needs `unstable_instant`,
which requires cacheComponents. So what makes sidebar navigation instant here
is prefetch + staleTimes, and the skeletons cover document loads. The e2e spec
asserts the behaviour that actually holds and documents why, so nobody rewrites
the fictional version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat:
12 files changed, 333 insertions(+), 13 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,6 +2,7 @@
## [Unreleased]
- **The editor is fast now.** Every page in the editor had a floor of about 4.4 seconds on it, and the reason was one line in the sidebar. The reclaimable-disk badge — the little "12.4 GB" pill next to Cleanup — asked for the channel list, and the function it asked was the one that counts the corpus from scratch: a `readdir` for each of the **78,350** video directories plus a digest sidecar read for each of the ~70,000 transcribed ones, **~474,559 files touched, measured at 3,985 ms**, to describe **98 videos**. Every count that walk produced was then thrown away. It sat in the root layout, so *every* document load paid it; the 5-second auto-refresh re-ran it on a timer, on every route, forever; and three widget endpoints called it on each poll. It now reads the 65 per-channel snapshots it could always have read — the same numbers, **68 ms**, a 59× improvement — and the sidebar badge itself is down to ~40 ms. Loading `/channels` went from 4.5 s to roughly a tenth of a second; the dashboard from ~10 s. The corpus-walking function still exists under a name that says what it costs (`listChannelStatsFromDisk`) for the batch jobs that genuinely need ground truth, and a test now fails the build if it ever reappears anywhere the editor renders. **The honest trade:** the video, transcript and download counts on `/channels` and the dashboard now come from each channel's last generated report rather than from disk directly, so a job that just finished can take a moment — the snapshot scheduler's ~1 second debounce — to show up. Verified against the live corpus: those three counts match a full walk **exactly** on all 65 channels. The one field that doesn't is digest coverage, which reads 0 for the 11 channels whose reports predate per-engine digest counts until their next report refresh. `/channels` now prints how old the oldest report on the page is, rather than leaving you to assume the numbers are live.
+- **A page that throws no longer takes the whole editor with it, and moving between pages is instant.** There were **zero** error boundaries in the editor: anything that threw while rendering — a malformed config, a half-written snapshot — blanked the entire document, sidebar and all, with nothing to click and nothing to read. There is now a route error boundary that keeps the chrome alive, shows the error's digest so you can find it in the server log, and offers *Try again* (Next 16.2's `unstable_retry`, which actually re-fetches, rather than the older `reset`, which only clears the error state). Alongside it: `loading.tsx` skeletons for the routes with the most to render, and a 15-second client router cache (`staleTimes`), which is what makes bouncing between two sidebar links immediate instead of a fresh server round trip each way. One honest note, since it's easy to assume otherwise: in Next 16 `loading.tsx` does **not** guarantee a fallback appears during a client-side navigation — the framework's own reference says so, and testing confirmed it. Fast sidebar navigation here comes from prefetching plus that cache, not from the skeletons; the skeletons cover document loads.
- **Cleaning audio now checks the video still exists upstream, and keeps it forever if it doesn't.** The transcribed-audio sweep hard-deletes a video's `audio.*` files once whisper has produced a transcript — `remove()`, no trash, no undo — and nothing had ever asked whether the video was still *there*. So a video YouTube had since removed, privated, or put behind a membership, sitting outside the keep-latest window, got its source audio deleted precisely when that local copy had become the only copy. Before deleting anything, the sweep now resolves each candidate's availability and writes a `do-not-clean.json` marker on any video found permanently gone (`deleted` / `private` / `members_only` — the same rule the keep-latest deletion pass uses, now shared as `isPermanentlyGone`), protecting it from this and every future sweep. The check is **cheap-first, not one probe per video**: a cached availability verdict costs nothing and is the only tier that catches `members_only` (a members-only video stays listed in its channel's playlist, so a listing diff can never flag it); then **one** flat-playlist call per channel narrows the field to candidates that have dropped out of the listing; only those few get a per-video probe, which is also what distinguishes a deleted video from an *unlisted* one that legitimately left the listing and is still fetchable by URL. Anything the check cannot resolve — a probe error, an age-gate, a video with no URL to probe — is **left alone with no marker written** and retried next run: the sweep never deletes on incomplete information, and a rate-limited or offline source therefore cleans nothing rather than cleaning wrongly. The summary line breaks the total down (`Skipped 4 (0 protected, 3 gone-from-source pinned, 1 unverified)`) whenever the check acted. On by default; **Check availability before cleaning audio** in Settings turns it off for an offline setup or channels with no URL, where the check can never resolve and cleanup would otherwise stop deleting anything. Two related fixes ride along: the sweep now shares `isRealAudioFile` with the rest of the app instead of its own hand-rolled filter, so it no longer deletes the `audio.live_chat.json` sidecar or the `.part.good`/`.part.testing` audio-check snapshots (which the reclaim estimate never counted, so the two had quietly drifted); and `runAvailabilityCheck` gains `ignoreShard`, because a saved shard slice on disk would otherwise replace an explicit `onlyIds` list wholesale. Scoped to the primary sweep only — the wrong-format, extra-format and auto-sub purges are unchanged, as are the explicit per-video deletes, which still ignore markers deliberately. See `common/controller/verifyBeforeClean.ts`, `common/controller/cleanAudioFromTranscribed.ts`, `common/lib/availability.ts`, and `editor/e2e/pre-clean-availability.spec.ts`.
- **The monitor widget can now reclaim disk, not just report it.** The widget's cleanable-data strip showed a single global number ("4.2 GB reclaimable") with nothing to act on — reclaiming it meant leaving the widget for `/cleanup` or `/actionable`. A new opt-in **"Needs cleaning"** section (URL flag `cleanlist=1`, plus a **Channels needing cleanup** checkbox in the builder and the in-widget gear) lists the channels actually holding that audio, each with its reclaim estimate (`⌫ 2.5 MB`, the video count in the tooltip), capped at 6 channels with a `+N more` line like the needs-work list. With `controls=1` each row gains the same per-channel **Clean audio** button as the `/actionable` page — the existing `window.confirm` still guards the delete — so a pinned interactive widget clears disk pressure the way it already clears a download backlog. This is also the first surface on which a channel that is *fully downloaded and transcribed* but still holding reclaimable audio is actionable: the needs-work list is fed by a backlog route with a download/transcribe precondition, so such a channel never appeared there. It costs no extra polling — the per-channel rows come from the same `/api/widget/cleanable` snapshot read that already backed the total, and the total is now a sum over those rows so the section and the strip above it can't disagree. The dashboard's needs-work panel and its shared route are untouched. See `editor/app/cleanup/lib/loadCleanup.ts` (`cleanableChannels`), `editor/app/api/widget/cleanable/route.ts`, `editor/app/widget/{lib/config.ts,components/{MonitorWidget,WidgetConfigForm}.tsx}`, and `editor/e2e/widget.spec.ts`.
- **A corpus-wide digest backfill can now be started, left alone, and watched.** The digest layer could generate, but only one channel at a time from that channel's own page — a full-archive pass meant 63 manual launches, and a server restart silently ended it with nothing to say so. There is now a **Start Digest Sweep** control on the dashboard that walks every channel in turn, **heaviest first by remaining audio-hours** (cost is audio, not videos: one VOD channel outweighs every duplicate mirror in the archive combined), and it **survives a restart** — the sweep is re-armed at boot the way the auto-download and auto-transcribe runners already were. It stores no work-list, so a resumed sweep re-does nothing: what still needs generating is re-derived from disk every time, which also means a transcript that finishes mid-sweep, or a duplicate cluster you confirm, is simply picked up on the next pass. A separate **Pause Digests** control holds a running sweep at zero without ending it (the pause flag has existed since the digest layer shipped and nothing could set it). **The digest lane now yields the GPU to transcription**: the two were deliberately on separate queues so they wouldn't serialise, whose unintended consequence was that the model and whisper competed for the same card — measured at 90 seconds per audio-hour against the 27 an idle machine managed. While transcription is working the digest lane steps aside and resumes when the card is free; it can be turned off in Settings. Coverage is now visible — a digest instrument on the dashboard with the corpus percentage (to two decimals, because rounding 0.13% up to 1% flatters an 80-day job), a **No digest** column on the channels table, and a per-channel count on the needs-work rows. Progress bars also **work during a regeneration** for the first time: they re-counted digest files from disk, and a regenerated digest is rewritten in place, so a job that was working sat at 0% for its whole run. Time-remaining estimates for digest work are now computed in **seconds per audio-hour** rather than by averaging videos, which for this archive is wrong by more than an order of magnitude between a VOD channel and a shorts channel. New `common/bin/digest-plan.ts` prices the whole backfill in audio-hours before you commit hardware to it.
diff --git a/editor/app/actionable/loading.tsx b/editor/app/actionable/loading.tsx
@@ -0,0 +1,6 @@
+import { RouteSkeleton } from "../components/RouteSkeleton";
+
+// /actionable is a stack of bucket sections, each a card with rows inside.
+export default function Loading() {
+ return <RouteSkeleton variant="cards" rows={6} />;
+}
diff --git a/editor/app/channels/[slug]/loading.tsx b/editor/app/channels/[slug]/loading.tsx
@@ -0,0 +1,7 @@
+import { RouteSkeleton } from "../../components/RouteSkeleton";
+
+// A channel page is panels above a long video list — the heaviest page in the
+// editor, and the one most worth showing a shape for.
+export default function Loading() {
+ return <RouteSkeleton variant="cards" rows={6} />;
+}
diff --git a/editor/app/channels/loading.tsx b/editor/app/channels/loading.tsx
@@ -0,0 +1,7 @@
+import { RouteSkeleton } from "../components/RouteSkeleton";
+
+// /channels renders a wide sortable table; the generic list skeleton would
+// visibly reflow into it. Table-shaped instead.
+export default function Loading() {
+ return <RouteSkeleton variant="table" rows={8} />;
+}
diff --git a/editor/app/components/CleanableBadge.tsx b/editor/app/components/CleanableBadge.tsx
@@ -0,0 +1,33 @@
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import { cleanableTotalBytes } from "../cleanup/lib/loadCleanup";
+
+// The sidebar's reclaimable-disk pill, as its own async component so the layout
+// can stream it behind a Suspense boundary.
+//
+// ⚠️ Read this before moving the boundary: a Suspense boundary in the ROOT
+// LAYOUT does nothing for sibling navigation. On a client navigation Next only
+// re-renders below the layout the source and destination share — for two
+// sidebar links that shared layout IS this one, so it is not re-rendered at all
+// and this component never re-suspends. The boundary exists for the two cases
+// where the layout DOES render: a full document load, and router.refresh().
+//
+// It matters for those because loading.tsx cannot cover a layout's own async
+// work — "if the layout accesses uncached or runtime data, loading.js will not
+// show a fallback for it; navigation blocks until the layout finishes
+// rendering". This is hardening rather than a fix: after the corpus walk was
+// removed from cleanableTotalBytes it costs ~40 ms, not ~4.4 s. The point is
+// that it can never put the whole document behind it again.
+export async function CleanableBadge() {
+ const bytes = await cleanableTotalBytes(getPaths());
+ if (bytes <= 0) return null;
+ return (
+ <span
+ data-testid="cleanable-badge"
+ aria-label={`${formatBytes(bytes)} reclaimable`}
+ className="ml-auto text-xs rounded-full border border-warning/30 bg-warning-soft text-warning px-2 py-0.5 leading-none tabular-nums"
+ >
+ {formatBytes(bytes)}
+ </span>
+ );
+}
diff --git a/editor/app/components/RouteSkeleton.tsx b/editor/app/components/RouteSkeleton.tsx
@@ -0,0 +1,70 @@
+// The shape every route's loading state is built from.
+//
+// A skeleton, not a spinner, and the distinction is the whole point: a spinner
+// says "stuck", a block of content-shaped placeholders says "arriving". These
+// are only ever shown while a server render is in flight, so they must never
+// shift layout when the real content lands — hence sizes that match the
+// headings and cards they stand in for.
+//
+// `data-testid="route-skeleton"` is load-bearing: editor/e2e/navigation.spec.ts
+// asserts it appears BEFORE the destination heading, which is what pins
+// loading.tsx to the right place in the route tree.
+export function RouteSkeleton({
+ rows = 5,
+ variant = "list",
+}: {
+ rows?: number;
+ variant?: "list" | "table" | "cards";
+}) {
+ return (
+ <div
+ data-testid="route-skeleton"
+ aria-busy="true"
+ aria-live="polite"
+ aria-label="Loading"
+ className="flex flex-col gap-4 animate-pulse"
+ >
+ <span className="sr-only">Loading…</span>
+ {/* Page heading */}
+ <div className="flex items-center justify-between gap-4">
+ <div className="h-8 w-48 rounded-md bg-muted" />
+ <div className="h-9 w-32 rounded-md bg-muted" />
+ </div>
+
+ {variant === "cards" ? (
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
+ {Array.from({ length: rows }, (_, i) => (
+ <div
+ key={i}
+ className="h-28 rounded-lg border border-border bg-card"
+ />
+ ))}
+ </div>
+ ) : variant === "table" ? (
+ <div className="rounded-lg border border-border overflow-hidden">
+ <div className="h-10 bg-muted/60 border-b border-border" />
+ {Array.from({ length: rows }, (_, i) => (
+ <div
+ key={i}
+ className="h-12 border-b border-border last:border-b-0 flex items-center gap-4 px-4"
+ >
+ <div className="h-4 w-40 rounded bg-muted" />
+ <div className="h-4 w-16 rounded bg-muted ml-auto" />
+ <div className="h-4 w-16 rounded bg-muted" />
+ <div className="h-4 w-16 rounded bg-muted" />
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className="flex flex-col gap-3">
+ {Array.from({ length: rows }, (_, i) => (
+ <div
+ key={i}
+ className="h-16 rounded-lg border border-border bg-card"
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/error.tsx b/editor/app/error.tsx
@@ -0,0 +1,72 @@
+"use client"; // Error boundaries must be Client Components.
+
+import { useEffect } from "react";
+import Link from "next/link";
+
+// The editor had ZERO error boundaries: a throw anywhere in any page took out
+// the whole document and left a blank screen with nothing to click. This is the
+// backstop — it keeps the sidebar and chrome alive and gives the error a digest
+// you can match against the server log.
+//
+// Next 16.2 renamed the recovery prop: it is `unstable_retry`, which re-fetches
+// and re-renders the boundary's children. The older `reset` still exists but
+// only clears the error state WITHOUT re-fetching, which is almost never what
+// you want for a page that failed on a server read.
+//
+// Note this cannot catch a throw in the root layout itself (an error boundary
+// does not wrap the layout in its own segment) — that needs global-error.tsx,
+// which must render its own <html>/<body>. The root layout's work is small and
+// synchronous apart from the badge, which is wrapped in its own Suspense.
+export default function Error({
+ error,
+ unstable_retry,
+}: {
+ error: Error & { digest?: string };
+ unstable_retry: () => void;
+}) {
+ useEffect(() => {
+ console.error("[editor] route error", error);
+ }, [error]);
+
+ return (
+ <div
+ role="alert"
+ data-testid="route-error"
+ className="flex flex-col gap-4 max-w-2xl"
+ >
+ <h1 className="text-2xl font-semibold">This page didn’t load</h1>
+ <p className="text-sm text-muted-foreground">
+ Something threw while rendering. The rest of the editor is still
+ running — the sidebar still works, and other pages are unaffected.
+ </p>
+ {error.digest && (
+ <p className="text-xs font-mono text-muted-foreground">
+ digest: <span data-testid="error-digest">{error.digest}</span>{" "}
+ <span className="not-italic">
+ (search the server log for this to find the stack)
+ </span>
+ </p>
+ )}
+ {error.message && (
+ <pre className="text-xs font-mono whitespace-pre-wrap rounded-md border border-border bg-card p-3 overflow-x-auto">
+ {error.message}
+ </pre>
+ )}
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={() => unstable_retry()}
+ className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
+ >
+ Try again
+ </button>
+ <Link
+ href="/"
+ className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted"
+ >
+ Back to dashboard
+ </Link>
+ </div>
+ </div>
+ );
+}
diff --git a/editor/app/jobs/loading.tsx b/editor/app/jobs/loading.tsx
@@ -0,0 +1,7 @@
+import { RouteSkeleton } from "../components/RouteSkeleton";
+
+// Covers /jobs and, as the nearest boundary above them, /jobs/active,
+// /jobs/queue and /jobs/[id] — all of which render row lists.
+export default function Loading() {
+ return <RouteSkeleton variant="table" rows={10} />;
+}
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -4,7 +4,6 @@ import { Suspense } from "react";
import Link from "next/link";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { formatBytes } from "yt-dlp-transcript-common/lib/format";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { getLatestChangelogDate } from "yt-dlp-transcript-common/lib/changelog";
import { listSites } from "yt-dlp-transcript-common/lib/site";
@@ -20,7 +19,7 @@ import { ChangelogNavLink } from "./components/ChangelogNavLink";
import { SiteScopeSelect } from "./components/SiteScopeSelect";
import { Toaster } from "yt-dlp-transcript-common/components/ui/sonner";
import { NAV_GROUPS, type NavLink } from "./lib/nav";
-import { cleanableTotalBytes } from "./cleanup/lib/loadCleanup";
+import { CleanableBadge } from "./components/CleanableBadge";
import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
@@ -57,9 +56,6 @@ export default async function RootLayout({
(j) => j.status === "running" || j.status === "queued",
).length;
const runningJobs = allJobs.filter((j) => j.status === "running").length;
- // Aggregate reclaimable disk across channels not excluded from the total.
- // Recomputes on each AutoRefresh tick (same cadence as the job pills).
- const cleanableBytes = await cleanableTotalBytes(getPaths());
const latestChangelogDate = readChangelogLatestDate();
const sites = listSites().map((s) => ({
siteId: s.siteId,
@@ -91,14 +87,12 @@ export default async function RootLayout({
<Link key={link.href} href={link.href} className={navItemClass}>
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span>{link.label}</span>
- {cleanableBytes > 0 && (
- <span
- aria-label={`${formatBytes(cleanableBytes)} reclaimable`}
- className="ml-auto text-xs rounded-full border border-warning/30 bg-warning-soft text-warning px-2 py-0.5 leading-none tabular-nums"
- >
- {formatBytes(cleanableBytes)}
- </span>
- )}
+ {/* Streamed: the nav renders immediately and the pill fills in. See
+ CleanableBadge for why this boundary does nothing for sibling
+ navigation and everything for document loads. */}
+ <Suspense fallback={null}>
+ <CleanableBadge />
+ </Suspense>
</Link>
);
}
diff --git a/editor/app/loading.tsx b/editor/app/loading.tsx
@@ -0,0 +1,17 @@
+import { RouteSkeleton } from "./components/RouteSkeleton";
+
+// The default loading state for every route that doesn't declare its own.
+//
+// Loading UI components take no props (Next 16: "Loading UI components do not
+// accept any parameters"), so this is deliberately generic; routes whose real
+// content is shaped very differently declare their own loading.tsx so the
+// skeleton doesn't cause a visible jump when the page arrives.
+//
+// What this buys: the sidebar, theme controls and command palette stay mounted
+// and interactive while a server render is in flight — only <main>'s contents
+// are replaced. Before this file existed there was no loading boundary anywhere
+// in the app, so a slow page left the browser sitting on the OLD page with no
+// indication anything was happening.
+export default function Loading() {
+ return <RouteSkeleton />;
+}
diff --git a/editor/e2e/navigation.spec.ts b/editor/e2e/navigation.spec.ts
@@ -0,0 +1,97 @@
+import { test, expect } from "@playwright/test";
+import { resetData } from "./helpers";
+
+// Navigation contract for the editor after the corpus walk was removed from
+// every render path.
+//
+// ── What this suite deliberately does NOT assert, and why ──────────────────
+//
+// The obvious test to write here is "clicking a sidebar link shows the
+// route-skeleton before the destination heading". It was written, and it does
+// not hold in Next 16 — not in dev, not against a production build, and not
+// under three different ways of stalling the payload. Two measured reasons:
+//
+// 1. When the destination IS prefetched (the normal case — Next prefetches
+// every sidebar link on hydration, and staleTimes.dynamic keeps the entry
+// warm for 15s), the click commits from the router cache with no pending
+// state at all. There is nothing for a fallback to cover. This is the win,
+// not a gap: instrumenting the click showed the Channels markup present
+// immediately, with the RSC request following as revalidation.
+//
+// 2. When the destination is NOT prefetched, the router waits on the server
+// response before committing and the browser stays on the OLD page — no
+// fallback renders. Next's own loading.js reference says so outright:
+// "loading.js provides fallback UI but does not guarantee instant
+// client-side navigations. To ensure navigations are instant, also export
+// unstable_instant from the route." `unstable_instant` requires the
+// cacheComponents flag, which this app has not adopted.
+//
+// So loading.tsx here earns its place on document loads and streaming, and as
+// the conventional home for this UI if cacheComponents is ever adopted — but
+// asserting it on a client navigation would be asserting a fiction. What IS
+// worth pinning is the thing users actually feel, and what regressed before:
+// that these routes arrive quickly and the chrome never locks up.
+
+// Sidebar links that used to cost seconds each. /channels and /actionable were
+// the worst (4.5 s and 5.4 s) because each rendered a full corpus walk.
+const HEAVY_ROUTES = [
+ { link: "Channels", heading: "Channels", path: "/channels" },
+ { link: "Actionable", heading: "Actionable items", path: "/actionable" },
+ { link: "Jobs", heading: "Jobs", path: "/jobs" },
+] as const;
+
+test.describe("navigation", () => {
+ test.beforeEach(async () => {
+ await resetData("channel-with-counts");
+ });
+
+ test("the heavy routes load and keep the sidebar interactive", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ const sidebar = page.locator("aside");
+ await expect(sidebar).toBeVisible();
+
+ for (const route of HEAVY_ROUTES) {
+ await sidebar
+ .getByRole("link", { name: route.link, exact: true })
+ .first()
+ .click();
+ await expect(page).toHaveURL(new RegExp(`${route.path}(\\?|$)`));
+ await expect(
+ page.getByRole("heading", { name: route.heading, level: 1 }),
+ ).toBeVisible();
+ // The chrome is rendered by the root layout, which is NOT re-rendered for
+ // a navigation between two of its own children — so it must survive every
+ // hop with its links still clickable.
+ await expect(
+ sidebar.getByRole("link", { name: "Jobs", exact: true }).first(),
+ ).toBeEnabled();
+ }
+ });
+
+ test("a direct document load renders without a stuck skeleton", async ({
+ page,
+ }) => {
+ for (const route of HEAVY_ROUTES) {
+ await page.goto(route.path);
+ await expect(
+ page.getByRole("heading", { name: route.heading, level: 1 }),
+ ).toBeVisible();
+ // The skeleton is a transient streaming state; if one is still on screen
+ // once the heading has rendered, a boundary is stuck open.
+ await expect(page.getByTestId("route-skeleton")).toHaveCount(0);
+ }
+ });
+
+ test("the channels table discloses how fresh its counts are", async ({
+ page,
+ }) => {
+ // Counts come from each channel's last snapshot rather than a corpus walk,
+ // so the page has to say so rather than imply they're live. This is the UI
+ // half of that trade; the projection itself is covered by
+ // common/controller/channelProjection.test.ts.
+ await page.goto("/channels");
+ await expect(page.getByTestId("channels-freshness")).toBeVisible();
+ });
+});
diff --git a/editor/next.config.ts b/editor/next.config.ts
@@ -31,6 +31,15 @@ const nextConfig: NextConfig = {
serverActions: {
bodySizeLimit: "10mb",
},
+ // Client router cache lifetimes, in SECONDS. `dynamic` defaults to 0 (as of
+ // Next 15 — it used to be 30), which means every back-and-forth between two
+ // sidebar links is a fresh server round trip even when you were just there.
+ //
+ // With loading.tsx present a prefetch returns the layout-to-loading-boundary
+ // shell, so 15s makes bouncing between two pages feel instant while keeping
+ // job state honest. DO NOT RAISE IT: /jobs, /jobs/active and /jobs/queue are
+ // live operational views, and a stale queue is worse than a slow one.
+ staleTimes: { dynamic: 15, static: 180 },
},
// Serve the built export artifacts (stats/summaries/transcripts) through a
// route handler so the charts authoring tab can preview real data, mirroring