commit 51444d2cf7bb7e0e63ba73087788e52f9fa6202a
parent 4b6cd617655b4739b09053e9f5dc0a1a26299f34
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 04:10:25 -0400
hub: NO_ARCHIVES_IN_SCOPE draft — "Choose some on the hub's front page to ask.", the phrase a link to it (re-read)
/ask shows no chips, so "Turn one on above" was wrong there. The constant
stays the one place for the operator's copy. Its [bracketed] words become
a link to "/", where the chips are, and dropping the brackets removes the
link. copyWithLink / copyText (hubScopeCopy.ts) split and flatten it for
AskChat and the e2e. The Composer's `blocked` prop is now a ReactNode. The
e2e asserts the flattened text, one line, and the link's href.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 53 insertions(+), 9 deletions(-)
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -1,6 +1,7 @@
"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";
@@ -14,7 +15,7 @@ import { GroundingPalette } from "./GroundingPalette";
import { SavedChatsRow } from "./SavedChatsRow";
import { MessageBubble } from "./MessageBubble";
import { Composer } from "./Composer";
-import { NO_ARCHIVES_IN_SCOPE } from "./hubScopeCopy";
+import { NO_ARCHIVES_IN_SCOPE, copyWithLink } from "./hubScopeCopy";
export default function AskChat() {
const s = useAskChat();
@@ -39,6 +40,7 @@ export default function AskChat() {
!!federation &&
federation.sites.length > 0 &&
federation.sites.every((f) => f.status === "off");
+ const noneLine = copyWithLink(NO_ARCHIVES_IN_SCOPE);
// 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,
@@ -473,7 +475,19 @@ export default function AskChat() {
stop={s.stop}
busy={busy}
summariesReady={summariesReady}
- blocked={noneInScope ? NO_ARCHIVES_IN_SCOPE : undefined}
+ blocked={
+ noneInScope ? (
+ <>
+ {noneLine.before}
+ {noneLine.link && (
+ <Link href="/" className="text-brand underline-offset-2 hover:underline">
+ {noneLine.link}
+ </Link>
+ )}
+ {noneLine.after}
+ </>
+ ) : undefined
+ }
hasKey={!!apiKey.trim()}
markdownOn={markdownOn}
setMarkdownOn={s.setMarkdownOn}
diff --git a/export/app/ask/Composer.tsx b/export/app/ask/Composer.tsx
@@ -1,6 +1,6 @@
"use client";
-import type { RefObject } from "react";
+import type { ReactNode, RefObject } from "react";
type Props = {
input: string;
@@ -11,7 +11,7 @@ type Props = {
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;
+ blocked?: ReactNode;
hasKey: boolean;
markdownOn: boolean;
setMarkdownOn: (v: boolean) => void;
diff --git a/export/app/ask/hubScopeCopy.ts b/export/app/ask/hubScopeCopy.ts
@@ -1,6 +1,24 @@
// 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).
+// and nowhere else (AskChat renders it, the hub e2e asserts it). The words in
+// [brackets] become a link to the hub's front page, where the chips are; drop
+// the brackets for no link.
export const NO_ARCHIVES_IN_SCOPE =
- "No archives selected. Turn one on above to ask.";
+ "No archives selected. Choose some on the [hub's front page] to ask.";
+
+// A copy line split around its one [bracketed] link.
+export function copyWithLink(copy: string): {
+ before: string;
+ link?: string;
+ after: string;
+} {
+ const m = /^([^[]*)\[([^\]]+)\]([\s\S]*)$/.exec(copy);
+ return m ? { before: m[1], link: m[2], after: m[3] } : { before: copy, after: "" };
+}
+
+// The line as a reader sees it (the brackets gone).
+export function copyText(copy: string): string {
+ const { before, link = "", after } = copyWithLink(copy);
+ return before + link + after;
+}
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -1,5 +1,9 @@
import { expect, test, type Page, type Route } from "@playwright/test";
-import { NO_ARCHIVES_IN_SCOPE } from "../app/ask/hubScopeCopy";
+import {
+ NO_ARCHIVES_IN_SCOPE,
+ copyText,
+ copyWithLink,
+} 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
@@ -475,8 +479,16 @@ test.describe("hub federated search — scope, per-archive state, attribution",
.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(blocked).toHaveText(copyText(NO_ARCHIVES_IN_SCOPE));
+ await expect(page.getByTestId("ask-blocked")).toHaveCount(1);
+ // Its [bracketed] words (when the copy has any) link to the front page,
+ // where the chips are.
+ const { link } = copyWithLink(NO_ARCHIVES_IN_SCOPE);
+ 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 }),