commit cb607466e6c2478ae1a73367d6d459a317dc4f93
parent 533fa0affd829440ceb8c353dd48911d6005647a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 02:34:01 -0400
hub: /ask waits for the hub's own list and says what it searches; a member's missing live chat is noted on its chip with a live-chat-only Retry — MultiSiteDataProvider listed + federation.listed / retryLiveChat / liveChatMissing (the subs rule keys on errorUpdateCount, so the archive stays ready through the Retry); three DRAFT copy constants in hubScopeCopy.ts (NO_ARCHIVES_ON_HUB, LIVE_CHAT_MISSING, ASK_SCOPE_LINE); AskScope, LinkedCopy; e2e: ask.spec (a hub with no archives, x3), federated-search.spec (the list, the scope line, the live-chat Retry)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
11 files changed, 444 insertions(+), 78 deletions(-)
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -106,13 +106,26 @@ export type FederatedSiteState = {
count?: number;
// Why it failed, when it did.
error?: string;
+ // Ready only: its live-chat (subs) manifest could not be read, even after its
+ // retry, so its videos are searched WITHOUT their live chat. `retrying` while
+ // a live-chat Retry (FederationState.retryLiveChat) is in flight; `error` is
+ // the last failure's reason.
+ liveChatMissing?: { retrying: boolean; error?: string };
};
export type FederationState = {
// In registry order, off archives included.
sites: FederatedSiteState[];
+ // Whether `sites` is the hub's whole list (the provider's `listed` prop):
+ // false while the hub's own list of archives is still arriving, so an empty
+ // `sites` then means "not yet", not "none".
+ listed: boolean;
// Refetch everything of this archive that failed.
retry: (origin: string) => void;
+ // Refetch this archive's live-chat (subs) manifest alone — the Retry beside
+ // a ready archive's `liveChatMissing` note. Its videos stay in the search
+ // while it runs.
+ retryLiveChat: (origin: string) => void;
};
// Single-site mode has no provenance accents; a module constant keeps the
@@ -284,13 +297,22 @@ function errorText(e: unknown): string | undefined {
// ready (the hub's search page, where results should not wait for the slowest
// member). Without it, summariesReady waits until every in-scope archive has
// settled, ready or failed (the /ask chat, which grounds in what it was given).
+//
+// `listed` (default true): whether `sites` is the hub's WHOLE list yet. /ask
+// passes false until the hub's own list (`/hub-sites.json`) has been answered:
+// the archives a browser added are known at once, the hub's own a moment later,
+// and a non-progressive surface is not ready over the first few alone. Its
+// archives are fetched meanwhile; only readiness waits. `federation.listed`
+// carries it, so an empty list can be told apart from one not in yet.
export function MultiSiteDataProvider({
sites,
progressive = false,
+ listed = true,
children,
}: {
sites: FederatedSite[];
progressive?: boolean;
+ listed?: boolean;
children: ReactNode;
}) {
const queryClient = useQueryClient();
@@ -353,6 +375,9 @@ export function MultiSiteDataProvider({
// is retried once and then counts as SETTLED: the archive is ready without
// its live chat. Live chat is the auxiliary layer; its failure never takes an
// archive's videos out of the search or makes it read as "did not answer".
+ // The archive's chip notes it instead (`liveChatMissing`), with a Retry for
+ // the live chat alone, and the archive stays ready while that runs (the rule
+ // is in step 3).
const subsQueries = useQueries({
queries: sites.map((s) => ({
queryKey: ["subs-manifest", s.origin],
@@ -415,15 +440,36 @@ export function MultiSiteDataProvider({
// Subs and posts manifests resolve a 404 to an empty manifest; posts never
// errors. A subs query that still errors after its retry counts as settled
// (the archive is ready without its live chat) and never as a failure.
- const settled = (q: { isSuccess: boolean; isError: boolean; isFetching: boolean } | undefined) =>
- !!q && (q.isSuccess || (q.isError && !q.isFetching));
+ //
+ // "Has errored" is `errorUpdateCount > 0`, not `isError`: the count moves
+ // only once the query's own retry is spent, and it survives a refetch —
+ // TanStack puts an errored query with no data back to `pending` while it
+ // refetches, so `isError` would take the archive (and its videos) out of
+ // the search for the length of a live-chat Retry. With the count, the
+ // archive stays ready through it, still noted as missing its live chat.
+ const posts = postsQueries[i];
+ const postsSettled = !!posts && (posts.isSuccess || posts.isError);
+ const subs = subsQueries[i];
+ const subsErrored = !!subs && subs.data === undefined && subs.errorUpdateCount > 0;
+ const subsSettled = !!subs && (subs.data !== undefined || subsErrored);
if (
m?.isSuccess &&
pages.length === m.data.pageCount &&
pages.every((p) => p.isSuccess) &&
- settled(subsQueries[i]) &&
- settled(postsQueries[i])
+ subsSettled &&
+ postsSettled
) {
+ if (subsErrored) {
+ const why = errorText(subs.error);
+ return {
+ ...withCount,
+ status: "ready",
+ liveChatMissing: {
+ retrying: subs.isFetching,
+ ...(why ? { error: why } : {}),
+ },
+ };
+ }
return { ...withCount, status: "ready" };
}
const fetching = !!m?.isFetching || pages.some((p) => p.isFetching);
@@ -438,7 +484,11 @@ export function MultiSiteDataProvider({
return { ...withCount, status: "loading" };
});
const statusKey = rawStates
- .map((s) => `${s.origin}|${s.siteTitle}|${s.accent ?? ""}|${s.status}|${s.count ?? ""}|${s.error ?? ""}`)
+ .map((s) => {
+ const chat = s.liveChatMissing;
+ const chatKey = chat ? `${chat.retrying ? "retrying" : "missing"}:${chat.error ?? ""}` : "";
+ return `${s.origin}|${s.siteTitle}|${s.accent ?? ""}|${s.status}|${s.count ?? ""}|${s.error ?? ""}|${chatKey}`;
+ })
.join("\n");
const siteStates = useMemo(
() => rawStates,
@@ -453,14 +503,18 @@ export function MultiSiteDataProvider({
);
const readyKey = Array.from(readyOrigins).join("\u0000");
const scoped = siteStates.filter((s) => s.status !== "off");
- // Nothing in scope — every archive switched off, or the hub's list not
- // arrived yet — is NOT ready for a surface that answers from the whole
- // federation (not `progressive`: the /ask chat). There is nothing to ground a
- // question in, and "ready" there would send one over zero records. The
- // progressive front page settles on an empty scope instead, so its results
- // read "no videos" rather than loading forever; its chips and "Searching 0
- // archives" say why.
+ // Nothing in scope — every archive switched off, the hub's list not arrived
+ // yet, or a hub with no archives at all — is NOT ready for a surface that
+ // answers from the whole federation (not `progressive`: the /ask chat). There
+ // is nothing to ground a question in, and "ready" there would send one over
+ // zero records. Nor is a list that is not yet the whole list (`listed` false:
+ // the archives this browser added are in, the hub's own still arriving). /ask
+ // says which it is once the list is in (`federation.listed`). The progressive
+ // front page settles on an empty scope instead, so its results read "no
+ // videos" rather than loading forever; its chips and "Searching 0 archives"
+ // say why.
const allSettled =
+ listed &&
(progressive || scoped.length > 0) &&
scoped.every((s) => s.status === "ready" || s.status === "failed");
const summariesReady = allSettled || (progressive && readyOrigins.size > 0);
@@ -730,9 +784,20 @@ export function MultiSiteDataProvider({
},
[queryClient],
);
+ // The live chat alone: a ready archive's subs manifest, which settled in
+ // error. Nothing else of the archive is refetched, and it stays ready.
+ const retryLiveChat = useCallback(
+ (origin: string) => {
+ void queryClient.refetchQueries({
+ queryKey: ["subs-manifest", origin],
+ exact: true,
+ });
+ },
+ [queryClient],
+ );
const federation = useMemo<FederationState>(
- () => ({ sites: siteStates, retry }),
- [siteStates, retry],
+ () => ({ sites: siteStates, listed, retry, retryLiveChat }),
+ [siteStates, listed, retry, retryLiveChat],
);
const value = useMemo<SearchDataValue>(
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
-import Link from "next/link";
import { ArrowDownIcon, PlusIcon } from "lucide-react";
import { usePlayerOptional } from "yt-dlp-transcript-common/components/PlayerProvider";
import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
@@ -15,7 +14,8 @@ import { GroundingPalette } from "./GroundingPalette";
import { SavedChatsRow } from "./SavedChatsRow";
import { MessageBubble } from "./MessageBubble";
import { Composer } from "./Composer";
-import { NO_ARCHIVES_IN_SCOPE, copyWithLink } from "./hubScopeCopy";
+import { NO_ARCHIVES_IN_SCOPE, NO_ARCHIVES_ON_HUB } from "./hubScopeCopy";
+import { LinkedCopy } from "./LinkedCopy";
export default function AskChat() {
const s = useAskChat();
@@ -31,16 +31,19 @@ export default function AskChat() {
markdownOn,
} = s;
- // Hub only: this browser switched every archive off with the front page's
- // scope chips. The provider then never reads ready (nothing to ground in), and
- // the composer says why instead of "Loading transcripts…". Single-site has no
- // `federation`.
+ // Hub only (single-site has no `federation`): nothing to search. The
+ // provider never reads ready over an empty scope, so once the hub's list is
+ // in the composer says why instead of "Loading transcripts…" — the hub has
+ // no archives at all, or this browser switched every one off with the front
+ // page's scope chips. Before the list is in, it is still loading.
const { federation } = useSearchData();
- const noneInScope =
- !!federation &&
- federation.sites.length > 0 &&
- federation.sites.every((f) => f.status === "off");
- const noneLine = copyWithLink(NO_ARCHIVES_IN_SCOPE);
+ const blockedCopy = !federation?.listed
+ ? undefined
+ : federation.sites.length === 0
+ ? NO_ARCHIVES_ON_HUB
+ : federation.sites.every((f) => f.status === "off")
+ ? NO_ARCHIVES_IN_SCOPE
+ : undefined;
// Opening a citation seeks the shared transcript modal to the cited line. It
// writes ?v=&t=&vm= via replaceState on this same /ask route (no navigation,
@@ -475,19 +478,7 @@ export default function AskChat() {
stop={s.stop}
busy={busy}
summariesReady={summariesReady}
- blocked={
- noneInScope ? (
- <>
- {noneLine.before}
- {noneLine.link && (
- <Link href="/" className="text-brand underline-offset-2 hover:underline">
- {noneLine.link}
- </Link>
- )}
- {noneLine.after}
- </>
- ) : undefined
- }
+ blocked={blockedCopy ? <LinkedCopy copy={blockedCopy} /> : undefined}
hasKey={!!apiKey.trim()}
markdownOn={markdownOn}
setMarkdownOn={s.setMarkdownOn}
diff --git a/export/app/ask/AskHub.tsx b/export/app/ask/AskHub.tsx
@@ -8,8 +8,11 @@
// with its chip on the front page is not fetched here either, so the chat
// grounds in what the search showed. Not progressive: the chat waits until
// every archive in scope has settled, so it never answers from a half-loaded
-// federation — and with NONE in scope (every chip off, or the list not in yet)
-// it is not ready at all; AskChat then says so (NO_ARCHIVES_IN_SCOPE).
+// federation — nor before the hub's own list is in (`listed`: the archives a
+// browser added are known at once, /hub-sites.json's a moment later). With
+// NONE in scope it is not ready at all; once the list is in AskChat says why
+// (NO_ARCHIVES_ON_HUB, NO_ARCHIVES_IN_SCOPE), and AskScope says how many of
+// the hub's archives the chat searches (ASK_SCOPE_LINE).
import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
@@ -18,6 +21,7 @@ import { MultiSiteDataProvider } from "yt-dlp-transcript-common/components/Searc
import { SearchSessionProvider } from "yt-dlp-transcript-common/components/SearchSessionContext";
import { useFederatedSites } from "../components/hub/useHubSites";
import AskChat from "./AskChat";
+import AskScope from "./AskScope";
// `transcriptDownloads` comes from the server parent's currentSite() (a client
// component cannot read site.json): false hides the modal's per-video export
@@ -27,7 +31,7 @@ export default function AskHub({
}: {
transcriptDownloads?: boolean;
}) {
- const { federated } = useFederatedSites();
+ const { federated, listed } = useFederatedSites();
// The whole provider stack, in the order SiteWorkspace mounts it for a single
// site — PlayerProvider, then the data source, then the session — because
@@ -41,7 +45,8 @@ export default function AskHub({
// the stack, never an opt-out of prerendering.
return (
<PlayerProvider features={{ transcriptDownloads }}>
- <MultiSiteDataProvider sites={federated}>
+ <MultiSiteDataProvider sites={federated} listed={listed}>
+ <AskScope />
<SearchSessionProvider>
<AskChat />
</SearchSessionProvider>
diff --git a/export/app/ask/AskScope.tsx b/export/app/ask/AskScope.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+// Hub /ask only: one line under the page's header saying what the chat
+// searches — how many of the hub's archives are in scope, the rest switched off
+// with the front page's chips (ASK_SCOPE_LINE, the operator's copy). Nothing
+// until the hub's list is in (the count would move), and nothing with none in
+// scope: the composer's blocked line says that.
+
+import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
+import { ASK_SCOPE_LINE } from "./hubScopeCopy";
+import { LinkedCopy } from "./LinkedCopy";
+
+export default function AskScope() {
+ const { federation } = useSearchData();
+ if (!federation?.listed) return null;
+ const total = federation.sites.length;
+ const inScope = federation.sites.filter((s) => s.status !== "off").length;
+ if (inScope === 0) return null;
+ const line = ASK_SCOPE_LINE(inScope, total);
+ if (!line) return null;
+ return (
+ <p data-testid="ask-scope" className="-mt-2 text-sm text-muted-foreground">
+ <LinkedCopy copy={line} />
+ </p>
+ );
+}
diff --git a/export/app/ask/Composer.tsx b/export/app/ask/Composer.tsx
@@ -10,7 +10,8 @@ type Props = {
busy: boolean;
summariesReady: boolean;
// Why the chat cannot ask at all, shown as one line under the box (the box is
- // disabled). Set on the hub when every archive is switched off.
+ // disabled). Set on the hub when it has no archives, or every one is switched
+ // off.
blocked?: ReactNode;
hasKey: boolean;
markdownOn: boolean;
diff --git a/export/app/ask/LinkedCopy.tsx b/export/app/ask/LinkedCopy.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+// A copy line from hubScopeCopy.ts as a reader sees it: its one [bracketed]
+// phrase, when it has one, a link to the hub's front page.
+
+import Link from "next/link";
+import { copyWithLink } from "./hubScopeCopy";
+
+export function LinkedCopy({ copy }: { copy: string }) {
+ const { before, link, after } = copyWithLink(copy);
+ return (
+ <>
+ {before}
+ {link && (
+ <Link href="/" className="text-brand underline-offset-2 hover:underline">
+ {link}
+ </Link>
+ )}
+ {after}
+ </>
+ );
+}
diff --git a/export/app/ask/hubScopeCopy.ts b/export/app/ask/hubScopeCopy.ts
@@ -7,6 +7,32 @@
export const NO_ARCHIVES_IN_SCOPE =
"No archives selected. Choose some on the [hub's front page] to ask.";
+// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling
+// The hub's /ask line for a hub with NO archives at all: its /hub-sites.json
+// lists none (a fresh self-hosted hub) or could not be read, and this browser
+// has added none. The composer is disabled and this line says why, instead of
+// "Loading transcripts…" forever. The [bracketed] words link to the hub's front
+// page, where the form to add an archive is; drop the brackets for no link.
+export const NO_ARCHIVES_ON_HUB =
+ "This hub has no archives yet. Add one on the [hub's front page] to ask.";
+
+// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling
+// The note on a hub scope chip (the front page) when that archive's live chat
+// could not be read: its videos are searched, its live chat is not. A Retry
+// for the live chat alone sits beside it. Chip notes are lower case, like the
+// chip's own "loading…" and "failed".
+export const LIVE_CHAT_MISSING = "live chat didn't load";
+
+// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling
+// The one line at the top of the hub's /ask that says what the chat searches:
+// `inScope` of the hub's `total` archives, the rest switched off with the
+// front page's scope chips. The [bracketed] words link to the front page;
+// drop the brackets for no link. Return null to show no line — e.g.
+// `inScope === total ? null : …` to show it only when some archives are off.
+// Not shown when none are in scope (NO_ARCHIVES_IN_SCOPE says so instead).
+export const ASK_SCOPE_LINE = (inScope: number, total: number): string | null =>
+ `Searching ${inScope} of ${total} ${total === 1 ? "archive" : "archives"} — [change on the front page].`;
+
// A copy line split around its one [bracketed] link.
export function copyWithLink(copy: string): {
before: string;
diff --git a/export/app/components/hub/HubScope.tsx b/export/app/components/hub/HubScope.tsx
@@ -6,12 +6,16 @@
// archive is doing — loading, its record count once ready (its summaries
// manifest's totalCount, the number the results header counts), or failed with
// a Retry beside it — so a slow or dead member is visible, not a silent gap.
+// A ready archive whose live chat could not be read says that too
+// (LIVE_CHAT_MISSING), with a Retry for its live chat alone: its videos stay
+// in the search meanwhile.
import { cn } from "yt-dlp-transcript-common/lib/utils";
import {
useSearchData,
type FederatedSiteState,
} from "yt-dlp-transcript-common/components/SearchDataContext";
+import { LIVE_CHAT_MISSING } from "../../ask/hubScopeCopy";
function detail(s: FederatedSiteState): string {
switch (s.status) {
@@ -33,7 +37,7 @@ export default function HubScope({
}) {
const { federation } = useSearchData();
if (!federation || federation.sites.length === 0) return null;
- const { sites, retry } = federation;
+ const { sites, retry, retryLiveChat } = federation;
return (
<div
@@ -96,6 +100,30 @@ export default function HubScope({
Retry
</button>
)}
+ {s.status === "ready" && s.liveChatMissing && (
+ <>
+ <span
+ data-testid="hub-scope-live-chat"
+ className="flex items-center border-l border-border px-2.5 py-1.5 text-xs text-muted-foreground"
+ >
+ {LIVE_CHAT_MISSING}
+ </span>
+ <button
+ type="button"
+ onClick={() => retryLiveChat(s.origin)}
+ disabled={s.liveChatMissing.retrying}
+ aria-label={`Retry live chat from ${s.siteTitle}`}
+ title={
+ s.liveChatMissing.error
+ ? `${s.liveChatMissing.error} — try again`
+ : "Try again"
+ }
+ className="border-l border-border px-2.5 py-1.5 text-xs text-brand hover:bg-accent disabled:opacity-50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
+ >
+ Retry
+ </button>
+ </>
+ )}
</span>
);
})}
diff --git a/export/app/components/hub/useHubSites.ts b/export/app/components/hub/useHubSites.ts
@@ -13,6 +13,12 @@
// requested together, so the wait is the gap between two requests already in
// flight. The archives a visitor added follow, in the order they were added,
// each in its own accent or none.
+//
+// `listed` says the list is the hub's WHOLE list: `/hub-sites.json` has been
+// answered (siteRegistry's `builtinsLoaded`) and the summary has settled. Until
+// then it may hold only the archives this browser added (they are read from
+// storage at once). A surface that must not act on half the hub — /ask —
+// waits for it; the front page does not.
import { useMemo } from "react";
import {
@@ -25,8 +31,9 @@ import { useHubScope } from "./useHubScope";
import { useHubSummary } from "./useHubSummary";
export function useHubSites() {
- const { sites, removeSite } = useRegistry();
+ const { sites, removeSite, builtinsLoaded } = useRegistry();
const { summary, settled } = useHubSummary();
+ const listed = builtinsLoaded && settled;
return useMemo(() => {
const official = settled
? officialInstances(
@@ -40,18 +47,20 @@ export function useHubSites() {
...official.map((o) => ({ ...o.site, accent: o.accent })),
...added,
];
- return { official, added, all, summary, removeSite };
- }, [sites, summary, settled, removeSite]);
+ return { official, added, all, summary, removeSite, listed };
+ }, [sites, summary, settled, removeSite, listed]);
}
// What the hub's search surfaces hand MultiSiteDataProvider: every archive in
// the one order, with its colour and this browser's scope (the chips on the
-// front page; an archive switched off there is not fetched).
+// front page; an archive switched off there is not fetched), and whether that
+// is the whole list yet (`listed`, above).
export function useFederatedSites(): {
federated: FederatedSite[];
toggle: (origin: string) => void;
+ listed: boolean;
} {
- const { all } = useHubSites();
+ const { all, listed } = useHubSites();
const { isOn, toggle } = useHubScope();
const federated = useMemo<FederatedSite[]>(
() =>
@@ -63,5 +72,5 @@ export function useFederatedSites(): {
})),
[all, isOn],
);
- return { federated, toggle };
+ return { federated, toggle, listed };
}
diff --git a/export/e2e-hub/ask.spec.ts b/export/e2e-hub/ask.spec.ts
@@ -1,4 +1,9 @@
-import { expect, test, type Page } from "@playwright/test";
+import { expect, test, type Page, type Route } from "@playwright/test";
+import {
+ NO_ARCHIVES_ON_HUB,
+ copyText,
+ copyWithLink,
+} from "../app/ask/hubScopeCopy";
// The hub's /ask route.
//
@@ -11,15 +16,21 @@ import { expect, test, type Page } from "@playwright/test";
// other half, covered by the 2-origin suite which builds the hub for real.
// No built-in pool: this route has to stand up on a hub with no archives,
-// which is what a fresh hub is.
-async function stubBuiltins(page: Page) {
- await page.route("**/hub-sites.json", (r) =>
- r.fulfill({
- status: 200,
- contentType: "application/json",
- headers: { "access-control-allow-origin": "*" },
- body: "[]",
- }),
+// which is what a fresh hub is. `answer` is how /hub-sites.json replies: an
+// empty list, or a failure (the registry never asks twice).
+async function stubBuiltins(
+ page: Page,
+ answer: "empty" | "error" | "abort" = "empty",
+) {
+ await page.route("**/hub-sites.json", (r: Route) =>
+ answer === "abort"
+ ? r.abort()
+ : r.fulfill({
+ status: answer === "error" ? 500 : 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: answer === "error" ? "" : "[]",
+ }),
);
}
@@ -38,13 +49,12 @@ test.describe("hub /ask", () => {
).toBeVisible();
// The composer is AskChat's whole point, and it only renders once the
- // provider stack AskHub now mounts is there. Located by its placeholder,
- // the way ask-chat.spec.ts does it — the first textbox on the page is the
- // provider panel's API-key field, not this.
- const box = page.getByPlaceholder(
- /Ask about the transcripts|Loading transcripts/,
- );
- await expect(box).toBeVisible();
+ // provider stack AskHub now mounts is there. On a hub with no archives it
+ // is there but blocked (below), so it is found by its form.
+ const composer = page
+ .locator("form")
+ .filter({ has: page.getByRole("button", { name: "Ask", exact: true }) });
+ await expect(composer.locator("textarea")).toBeVisible();
await expect(
page.getByRole("button", { name: "Ask", exact: true }),
).toBeVisible();
@@ -52,4 +62,39 @@ test.describe("hub /ask", () => {
// A missing provider surfaces as a client-side throw, not a blank page.
expect(errors).toEqual([]);
});
+
+ // Release 11 (O1): a hub with no archives at all used to read "Loading
+ // transcripts…" forever on /ask. Once /hub-sites.json has answered — an
+ // empty list, or a failure the registry does not retry — and this browser
+ // has added none, the composer says so instead (NO_ARCHIVES_ON_HUB).
+ for (const answer of ["empty", "error", "abort"] as const) {
+ test(`a hub with no archives (hub-sites.json ${answer}) says so and cannot ask`, async ({
+ page,
+ }) => {
+ await stubBuiltins(page, answer);
+ await page.goto("/ask");
+
+ const composer = page
+ .locator("form")
+ .filter({ has: page.getByRole("button", { name: "Ask", exact: true }) });
+ const blocked = composer.getByTestId("ask-blocked");
+ await expect(blocked).toHaveText(copyText(NO_ARCHIVES_ON_HUB));
+ await expect(blocked).toHaveAttribute("role", "status");
+ // Its [bracketed] words (when the copy has any) link to the front page,
+ // where the form to add an archive is.
+ const { link } = copyWithLink(NO_ARCHIVES_ON_HUB);
+ if (link) {
+ await expect(
+ blocked.getByRole("link", { name: link, exact: true }),
+ ).toHaveAttribute("href", "/");
+ }
+ await expect(composer.locator("textarea")).toBeDisabled();
+ await expect(
+ composer.getByRole("button", { name: "Ask", exact: true }),
+ ).toBeDisabled();
+ await expect(page.getByPlaceholder("Loading transcripts…")).toHaveCount(0);
+ // No archives, so no "Searching N of M" line either.
+ await expect(page.getByTestId("ask-scope")).toHaveCount(0);
+ });
+ }
});
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -1,5 +1,7 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import {
+ ASK_SCOPE_LINE,
+ LIVE_CHAT_MISSING,
NO_ARCHIVES_IN_SCOPE,
copyText,
copyWithLink,
@@ -20,6 +22,12 @@ import {
// accent wears ONE colour — its homepage card's — on its card, its chip and its
// results; /ask searches only the archives the chips leave in, and with every
// archive switched off it says so and cannot ask.
+// Release 11 (O1): /ask says how many of the hub's archives it searches
+// (ASK_SCOPE_LINE), and is not ready until the hub's own list is in — the
+// archives a browser added are known at once, /hub-sites.json's a moment
+// later; a member whose live chat cannot be read says so on its chip
+// (LIVE_CHAT_MISSING), with a Retry for the live chat alone that keeps its
+// videos in the search while it runs.
const ORIGIN_A = "http://localhost:4598";
const ORIGIN_B = "http://localhost:4599";
@@ -76,17 +84,33 @@ function pageOf(m: Member) {
// Every request to a member origin is counted; `pages` decides how its
// summaries page is answered, `subs` whether it has a subs manifest at all
-// ("missing": a 404) or cannot serve it ("error": a 500).
+// ("missing": a 404), cannot serve it ("error": a 500) or answers only when
+// the test says so ("hold": parked in `heldSubs`).
type PageMode = "ok" | "abort" | "hold";
type MemberMock = {
requests: string[];
pages: PageMode;
- subs: "ok" | "missing" | "error";
+ subs: "ok" | "missing" | "error" | "hold";
held: Route[];
+ heldSubs: Route[];
+};
+
+const SUBS_OK = {
+ version: 4,
+ channels: [],
+ totalCount: 0,
+ liveChatTotalCount: 0,
+ generatedAt: "2026-01-01T00:00:00.000Z",
};
async function mockMember(page: Page, m: Member): Promise<MemberMock> {
- const mock: MemberMock = { requests: [], pages: "ok", subs: "ok", held: [] };
+ const mock: MemberMock = {
+ requests: [],
+ pages: "ok",
+ subs: "ok",
+ held: [],
+ heldSubs: [],
+ };
await page.route(`${m.origin}/**`, async (route) => {
const url = new URL(route.request().url());
mock.requests.push(url.pathname);
@@ -104,14 +128,12 @@ async function mockMember(page: Page, m: Member): Promise<MemberMock> {
if (url.pathname === "/subs/manifest.json" && mock.subs === "error") {
return route.fulfill({ status: 500, headers: CORS, body: "" });
}
+ if (url.pathname === "/subs/manifest.json" && mock.subs === "hold") {
+ mock.heldSubs.push(route);
+ return;
+ }
if (url.pathname === "/subs/manifest.json" && mock.subs === "ok") {
- return fulfillJson(route, {
- version: 4,
- channels: [],
- totalCount: 0,
- liveChatTotalCount: 0,
- generatedAt: "2026-01-01T00:00:00.000Z",
- });
+ return fulfillJson(route, SUBS_OK);
}
// No posts, no aliases (and, when `subs` is "missing", no live chat): a
// clean 404 (a member without that corpus is not a failure).
@@ -398,6 +420,21 @@ test.describe("hub federated search — scope, per-archive state, attribution",
expect(mocks.a.requests).toContain("/summaries/page-0000.json");
expect(mocks.b.requests).toEqual([]);
+ // And it says so: one line, its [bracketed] words a link to the chips.
+ const scope = page.getByTestId("ask-scope");
+ const oneOfTwo = ASK_SCOPE_LINE(1, 2);
+ if (oneOfTwo === null) {
+ await expect(scope).toHaveCount(0);
+ } else {
+ await expect(scope).toHaveText(copyText(oneOfTwo));
+ const { link } = copyWithLink(oneOfTwo);
+ if (link) {
+ await expect(
+ scope.getByRole("link", { name: link, exact: true }),
+ ).toHaveAttribute("href", "/");
+ }
+ }
+
// Back on (on the front page), /ask reads it again.
await page.goto("/");
await chip(page, B).getByRole("button", { name: /Origin B/ }).click();
@@ -409,6 +446,75 @@ test.describe("hub federated search — scope, per-archive state, attribution",
.toContain("/summaries/manifest.json");
await expect(ready).toBeVisible();
expect(mocks.b.requests).toContain("/summaries/page-0000.json");
+ const twoOfTwo = ASK_SCOPE_LINE(2, 2);
+ if (twoOfTwo === null) await expect(scope).toHaveCount(0);
+ else await expect(scope).toHaveText(copyText(twoOfTwo));
+ });
+
+ test("/ask is not ready until the hub's own list is in, even with an added archive ready", async ({
+ page,
+ }) => {
+ // Origin B is an archive this browser added (read from storage at once);
+ // Origin A is the hub's own, from /hub-sites.json — held here.
+ const mocks = await setup(page, [A]);
+ const heldList: Route[] = [];
+ await page.route("**/hub-sites.json", (r) => {
+ heldList.push(r);
+ });
+ await page.addInitScript((origin) => {
+ window.localStorage.setItem(
+ "ytdlp-tb:hub-sites",
+ JSON.stringify([
+ {
+ origin,
+ siteId: "added-b",
+ siteTitle: "Origin B",
+ pwa: false,
+ kind: "external",
+ contract: 1,
+ addedAt: 1,
+ },
+ ]),
+ );
+ }, B.origin);
+
+ await page.goto("/ask");
+ // Origin B is fetched meanwhile — every feed of it answered...
+ await expect
+ .poll(() => mocks.b.requests)
+ .toEqual(
+ expect.arrayContaining([
+ "/summaries/manifest.json",
+ "/summaries/page-0000.json",
+ "/subs/manifest.json",
+ "/posts/manifest.json",
+ ]),
+ );
+ await expect.poll(() => heldList.length).toBeGreaterThan(0);
+ // ...but it is not the hub: no question may ground in it alone. Give a
+ // ready provider time to show itself, then check it did not.
+ await page.waitForTimeout(1_000);
+ await expect(page.getByPlaceholder("Loading transcripts…")).toBeVisible();
+ await expect(page.getByTestId("ask-blocked")).toHaveCount(0);
+ await expect(page.getByTestId("ask-scope")).toHaveCount(0);
+
+ // The hub's list arrives: Origin A is fetched, and the chat is ready over
+ // both.
+ for (const r of heldList.splice(0)) {
+ await fulfillJson(r, [
+ { siteId: "origin0", siteTitle: A.title, siteUrl: A.origin, pwa: false, contract: 1 },
+ ]);
+ }
+ await expect
+ .poll(() => mocks.a.requests)
+ .toContain("/summaries/page-0000.json");
+ await expect(
+ page.getByPlaceholder(/^Ask about the transcripts/),
+ ).toBeVisible();
+ const both = ASK_SCOPE_LINE(2, 2);
+ if (both !== null) {
+ await expect(page.getByTestId("ask-scope")).toHaveText(copyText(both));
+ }
});
test("a subs manifest that cannot be read is retried once and never fails its archive", async ({
@@ -425,7 +531,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
await expect(resultFrom(page, B)).toHaveCount(1);
await expect(resultFrom(page, A)).toHaveCount(1);
await expect(
- chip(page, B).getByRole("button", { name: "Retry Origin B" }),
+ chip(page, B).getByRole("button", { name: "Retry Origin B", exact: true }),
).toHaveCount(0);
await expect(status(page)).toHaveCount(0);
// Ready waits for the retry to settle, so the count is final here.
@@ -434,6 +540,48 @@ test.describe("hub federated search — scope, per-archive state, attribution",
).toBe(2);
});
+ test("a member's missing live chat is noted on its chip, and its Retry refetches the live chat alone", async ({
+ page,
+ }) => {
+ const mocks = await setup(page);
+ mocks.b.subs = "error";
+ await page.goto("/");
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(resultFrom(page, B)).toHaveCount(1);
+
+ // The note, on Origin B's chip only, with its own Retry.
+ const note = chip(page, B).getByTestId("hub-scope-live-chat");
+ await expect(note).toHaveText(LIVE_CHAT_MISSING);
+ await expect(chip(page, A).getByTestId("hub-scope-live-chat")).toHaveCount(0);
+ const retryChat = chip(page, B).getByRole("button", {
+ name: "Retry live chat from Origin B",
+ exact: true,
+ });
+ await expect(retryChat).toBeEnabled();
+
+ // Retry, answered only when the test says: while it is in flight Origin B
+ // stays ready and its video stays in the results, the note stays with its
+ // Retry disabled, and nothing but the live chat is asked for again.
+ mocks.b.subs = "hold";
+ const asked = mocks.b.requests.length;
+ await retryChat.click();
+ await expect.poll(() => mocks.b.heldSubs.length).toBe(1);
+ await expect(retryChat).toBeDisabled();
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(note).toHaveText(LIVE_CHAT_MISSING);
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ expect(mocks.b.requests.slice(asked)).toEqual(["/subs/manifest.json"]);
+
+ // It answers: the note and its Retry go; the archive never left.
+ for (const r of mocks.b.heldSubs.splice(0)) await fulfillJson(r, SUBS_OK);
+ await expect(note).toHaveCount(0);
+ await expect(retryChat).toHaveCount(0);
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ expect(mocks.b.requests.slice(asked)).toEqual(["/subs/manifest.json"]);
+ });
+
test("/ask with every archive switched off says so and cannot ask; with one back on, it asks", async ({
page,
}) => {