commit 46c2fcdb77d7282daf5e3fbf0db224c2de5d3c23
parent c3f0e4bdc2b5068589d898ed2c0a51526fcccae2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 03:33:04 -0400
hub: /ask with every archive switched off is not ready and says why (review fix 1)
With L1's scope on /ask, every chip off left an empty in-scope list, which
counted as settled: the composer enabled and a question went out over zero
records with nothing on the page saying why. MultiSiteDataProvider now
counts an empty scope as NOT ready unless it is progressive. /ask (not
progressive) therefore stays disabled, which also closes the instant before
the hub's list arrives. The front page (progressive) keeps settling on an
empty scope, so its results read "no videos" rather than loading forever.
AskChat shows one line in the composer, NO_ARCHIVES_IN_SCOPE
(export/app/ask/hubScopeCopy.ts, draft copy for the operator), in place of
the placeholder and the key hint. e2e: both chips off gives the line once,
a disabled box and button, and no member fetches; one back on, and the
question reaches the provider.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 133 insertions(+), 15 deletions(-)
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -453,9 +453,16 @@ export function MultiSiteDataProvider({
);
const readyKey = Array.from(readyOrigins).join("\u0000");
const scoped = siteStates.filter((s) => s.status !== "off");
- const allSettled = scoped.every(
- (s) => s.status === "ready" || s.status === "failed",
- );
+ // 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.
+ const allSettled =
+ (progressive || scoped.length > 0) &&
+ scoped.every((s) => s.status === "ready" || s.status === "failed");
const summariesReady = allSettled || (progressive && readyOrigins.size > 0);
const loadedPages = pageQueries.filter((q) => q.data).length;
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowDownIcon, PlusIcon } from "lucide-react";
import { usePlayerOptional } from "yt-dlp-transcript-common/components/PlayerProvider";
+import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
import { useMediaQuery } from "yt-dlp-transcript-common/lib/useMediaQuery";
import { DEFAULT_SWEEP_DIRECTIVE, useAskChat } from "./useAskChat";
import { ProviderSettings } from "./ProviderSettings";
@@ -13,6 +14,7 @@ import { GroundingPalette } from "./GroundingPalette";
import { SavedChatsRow } from "./SavedChatsRow";
import { MessageBubble } from "./MessageBubble";
import { Composer } from "./Composer";
+import { NO_ARCHIVES_IN_SCOPE } from "./hubScopeCopy";
export default function AskChat() {
const s = useAskChat();
@@ -28,6 +30,16 @@ 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`.
+ const { federation } = useSearchData();
+ const noneInScope =
+ !!federation &&
+ federation.sites.length > 0 &&
+ federation.sites.every((f) => f.status === "off");
+
// 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,
// no history push) and doesn't touch `messages`, so the bottom-scroll effect
@@ -461,6 +473,7 @@ export default function AskChat() {
stop={s.stop}
busy={busy}
summariesReady={summariesReady}
+ blocked={noneInScope ? NO_ARCHIVES_IN_SCOPE : undefined}
hasKey={!!apiKey.trim()}
markdownOn={markdownOn}
setMarkdownOn={s.setMarkdownOn}
diff --git a/export/app/ask/AskHub.tsx b/export/app/ask/AskHub.tsx
@@ -8,7 +8,8 @@
// 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.
+// 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).
import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
diff --git a/export/app/ask/Composer.tsx b/export/app/ask/Composer.tsx
@@ -9,6 +9,9 @@ type Props = {
stop: () => void;
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.
+ blocked?: string;
hasKey: boolean;
markdownOn: boolean;
setMarkdownOn: (v: boolean) => void;
@@ -23,6 +26,7 @@ export function Composer(props: Props) {
stop,
busy,
summariesReady,
+ blocked,
hasKey,
markdownOn,
setMarkdownOn,
@@ -54,11 +58,13 @@ export function Composer(props: Props) {
}}
rows={2}
placeholder={
- summariesReady
- ? "Ask about the transcripts… (Enter to send · Shift+Enter for a new line)"
- : "Loading transcripts…"
+ blocked
+ ? undefined
+ : summariesReady
+ ? "Ask about the transcripts… (Enter to send · Shift+Enter for a new line)"
+ : "Loading transcripts…"
}
- disabled={!summariesReady}
+ disabled={!summariesReady || !!blocked}
// text-base below md: iOS zooms the page on focus for anything under
// 16px, and it never zooms back out.
onFocus={(e) => e.currentTarget.scrollIntoView({ block: "end" })}
@@ -67,7 +73,7 @@ export function Composer(props: Props) {
<div className="flex flex-wrap items-center gap-2">
<button
type="submit"
- disabled={busy || !summariesReady || !input.trim() || !hasKey}
+ disabled={busy || !summariesReady || !!blocked || !input.trim() || !hasKey}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-brand-strong disabled:opacity-50"
>
{busy ? "Thinking…" : "Ask"}
@@ -81,10 +87,16 @@ export function Composer(props: Props) {
Stop
</button>
)}
- {!hasKey && (
- <span className="text-xs text-muted-foreground">
- Set an API key above to start.
+ {blocked ? (
+ <span role="status" data-testid="ask-blocked" className="text-xs text-muted-foreground">
+ {blocked}
</span>
+ ) : (
+ !hasKey && (
+ <span className="text-xs text-muted-foreground">
+ Set an API key above to start.
+ </span>
+ )
)}
{/* Formatted / Plain escape hatch for Markdown rendering. */}
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
diff --git a/export/app/ask/hubScopeCopy.ts b/export/app/ask/hubScopeCopy.ts
@@ -0,0 +1,6 @@
+// The hub's /ask line for a browser that has switched EVERY archive off with
+// the front page's scope chips: the chat has nothing to search, so its composer
+// is disabled and this one line says why. The operator's copy — change it here
+// and nowhere else (AskChat renders it, the hub e2e asserts it).
+export const NO_ARCHIVES_IN_SCOPE =
+ "No archives selected. Turn one on above to ask.";
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
+import { NO_ARCHIVES_IN_SCOPE } from "../app/ask/hubScopeCopy";
// The hub's federated search, per archive: two official members (hub-sites.json)
// served by route mocks WITH CORS, each with one video. Proves the scope chips
@@ -12,7 +13,8 @@ import { expect, test, type Page, type Route } from "@playwright/test";
// fails its archive with Retry; the official archives follow the homepage's
// order (hub-summary.json's) on the cards and the chips, and an archive with no
// 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.
+// results; /ask searches only the archives the chips leave in, and with every
+// archive switched off it says so and cannot ask.
const ORIGIN_A = "http://localhost:4598";
const ORIGIN_B = "http://localhost:4599";
@@ -378,8 +380,8 @@ test.describe("hub federated search — scope, per-archive state, attribution",
// A fresh page life on /ask: the scope is this browser's, so Origin B is
// not fetched there either, and the chat is ready once Origin A is in.
- // (The composer also reads ready for the instant before the hub's list
- // loads — nothing to wait for yet — so wait for Origin A's fetch first.)
+ // (An empty scope is never ready on /ask — not even for the instant before
+ // the hub's list loads — so "ready" below means Origin A is in.)
const ready = page.getByPlaceholder(/^Ask about the transcripts/);
mocks.a.requests.length = 0;
mocks.b.requests.length = 0;
@@ -429,4 +431,81 @@ test.describe("hub federated search — scope, per-archive state, attribution",
await expect(resultFrom(page, B)).toHaveCount(1);
await expect(status(page)).toHaveCount(0);
});
+
+ test("/ask with every archive switched off says so and cannot ask; with one back on, it asks", async ({
+ page,
+ }) => {
+ const mocks = await setup(page);
+ // The AI provider, in Scripted mode: every call is one SSE text reply, and
+ // every question it is sent is recorded. "Asks" means the question reaches
+ // the provider — these member mocks serve no transcript tree, so the chat's
+ // own search over them never finishes and no answer is awaited.
+ const AI_CORS = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-headers": "*",
+ "access-control-allow-methods": "*",
+ };
+ const sent: string[] = [];
+ await page.route("https://api.anthropic.com/**", async (route) => {
+ if (route.request().method() === "OPTIONS") {
+ return route.fulfill({ status: 204, headers: AI_CORS });
+ }
+ sent.push(route.request().postData() ?? "");
+ const text = "DONE";
+ await route.fulfill({
+ status: 200,
+ headers: { ...AI_CORS, "content-type": "text/event-stream" },
+ body: [
+ `data: ${JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text } })}\n\n`,
+ `data: ${JSON.stringify({ type: "message_stop" })}\n\n`,
+ ].join(""),
+ });
+ });
+
+ await page.goto("/");
+ for (const m of [A, B]) {
+ await chip(page, m).getByRole("button", { name: new RegExp(m.title) }).click();
+ await expect(chip(page, m)).toHaveAttribute("data-status", "off");
+ }
+
+ // Nothing in scope: one line says why, the box is disabled, and nothing is
+ // fetched from either archive.
+ mocks.a.requests.length = 0;
+ mocks.b.requests.length = 0;
+ 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(NO_ARCHIVES_IN_SCOPE);
+ await expect(page.getByText(NO_ARCHIVES_IN_SCOPE)).toHaveCount(1);
+ await expect(composer.locator("textarea")).toBeDisabled();
+ await expect(
+ composer.getByRole("button", { name: "Ask", exact: true }),
+ ).toBeDisabled();
+ await expect(page.getByPlaceholder("Loading transcripts…")).toHaveCount(0);
+ expect(mocks.a.requests).toEqual([]);
+ expect(mocks.b.requests).toEqual([]);
+
+ // Origin A back on: the line goes, and a question is asked and answered.
+ await page.goto("/");
+ await chip(page, A).getByRole("button", { name: /Origin A/ }).click();
+ await expect(chip(page, A)).toHaveAttribute("data-status", "ready");
+ await page.goto("/ask");
+ await page.getByRole("button", { name: "Scripted" }).click();
+ const key = page.locator('input[placeholder^="sk-ant"]');
+ await key.fill("sk-ant-test");
+ await expect(key).toHaveValue("sk-ant-test");
+ await expect
+ .poll(() => mocks.a.requests)
+ .toContain("/summaries/manifest.json");
+ const box = page.getByPlaceholder(/^Ask about the transcripts/);
+ await expect(box).toBeEnabled();
+ await expect(page.getByTestId("ask-blocked")).toHaveCount(0);
+ await box.fill("what did they say?");
+ await page.getByRole("button", { name: "Ask", exact: true }).click();
+ await expect
+ .poll(() => sent.some((body) => body.includes("what did they say?")))
+ .toBe(true);
+ });
});