commit 8ac7cd784908570f937de63941258b258b28f957
parent 5803ac8b5b0c9433a8e4982ad319f90740f88a90
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 17:01:56 -0400
common: (review fix) hold a restored search only on the page life's first load
The hub mounts a separate search session per route (HubHome on /, AskHub on
/ask), so the per-card Ask / "Ask AI" (router.push("/ask/")) re-hydrated from
localStorage and held the search the visitor had just run, and hub /ask has
no search bar to release it: the chat lost its grounding. A module-scoped
`ranThisPageLife`, set by commitSearch, applySnapshot and a URL query that
runs, now gates the hold. It survives client-side navigation and resets on a
reload or new visit, which is exactly "first load".
New export/e2e-hub/ask-grounding.spec.ts: search on hub /, per-card Ask,
assert /ask grounds in the search (fails without the flag: the grounding
panel never appears). ask-chat.spec.ts now also asserts the hold on /ask
(Search dirty, no "Grounded in 1 result") before it clicks Search.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 181 insertions(+), 1 deletion(-)
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -194,6 +194,14 @@ export type GroundingMode = "search" | "selection";
// export-filter parser.
const SELECTION_KEY = "ytdlp-tb:selection";
+// Whether a search has run in this page life: a commit, a profile load, or a
+// query arriving on the URL. Module state, so it survives client-side
+// navigation and resets on a reload or a new visit. A stored query is held
+// (see `runHeld`) only on the page life's FIRST load — the hub mounts a fresh
+// provider per route, and `/` → `/ask` must keep grounding in the search the
+// visitor just ran there.
+let ranThisPageLife = false;
+
function loadSelection(): string[] {
if (typeof window === "undefined") return [];
try {
@@ -1236,7 +1244,10 @@ function useSearchSessionState() {
initialTags ??= new Set(snapshot?.tg ?? []);
if (!resolvedRoot && snapshot?.query) {
resolvedRoot = parseRoot(snapshot.query);
- holdRestored = resolvedRoot != null && isNodeActive(resolvedRoot);
+ holdRestored =
+ !ranThisPageLife &&
+ resolvedRoot != null &&
+ isNodeActive(resolvedRoot);
}
}
@@ -1245,6 +1256,7 @@ function useSearchSessionState() {
setCommittedRoot(resolvedRoot);
if (holdRestored) setRunHeld(true);
else setSearchExecuted(true);
+ if (rootFromUrl && isNodeActive(rootFromUrl)) ranThisPageLife = true;
}
setDraftExcludedChannels(initialExcluded);
@@ -1360,6 +1372,7 @@ function useSearchSessionState() {
const commitSearch = () => {
setSearchExecuted(true);
setRunHeld(false);
+ ranThisPageLife = true;
setCommittedRoot(draftRoot);
const nextSnapshot = buildDraftSnapshot();
const current = loadStoredState() ?? emptyStoredState();
@@ -1423,6 +1436,7 @@ function useSearchSessionState() {
(snapshot: FilterSnapshot | null) => {
// Loading a profile commits it, which runs it — as it always has.
setRunHeld(false);
+ ranThisPageLife = true;
applyDraftSnapshot(snapshot);
const excluded = snapshotToExcluded(
snapshot,
diff --git a/export/e2e-hub/ask-grounding.spec.ts b/export/e2e-hub/ask-grounding.spec.ts
@@ -0,0 +1,161 @@
+import { expect, test, type Page, type Route } from "@playwright/test";
+
+// Release 8 slice E (review fix R1). A search restored from localStorage is
+// HELD on a page life's first load — but the hub mounts a separate search
+// session per route (HubHome on `/`, AskHub on `/ask`), so `/` -> `/ask` by the
+// per-card Ask re-hydrates from storage. The hold must not apply there: the
+// visitor has just run that search, and hub `/ask` has no search bar to run
+// it again. The chat must still ground in it.
+//
+// Origin B is route-mocked exactly as in federation.spec.ts.
+
+const ORIGIN_B = "http://localhost:4599";
+const CHANNEL_B = "Channel B";
+const CHANNEL_B_SLUG = "chan-b";
+
+const CORS = { "access-control-allow-origin": "*" };
+
+async function fulfillJson(route: Route, body: unknown, cors = true) {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: cors ? CORS : {},
+ body: JSON.stringify(body),
+ });
+}
+
+function descriptorB() {
+ return {
+ contract: 1,
+ siteId: "originb",
+ siteTitle: "Origin B",
+ siteDescription: "A federated archive.",
+ headerTitle: "Origin B",
+ homeTagline: "",
+ accent: "#c026d3",
+ siteUrl: ORIGIN_B,
+ pwa: false,
+ socialLinks: [],
+ groups: [{ id: "gb", name: "Origin B", selectedByDefault: true }],
+ defaultGroupId: "gb",
+ channels: [
+ { slug: CHANNEL_B_SLUG, name: CHANNEL_B, count: 1, groupId: "gb" },
+ ],
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ summariesVersion: 3,
+ };
+}
+
+function manifestB() {
+ return {
+ version: 3,
+ totalCount: 1,
+ pageSize: 1000,
+ pageCount: 1,
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ channels: [
+ { name: CHANNEL_B, count: 1, slug: CHANNEL_B_SLUG, groupId: "gb" },
+ ],
+ groups: [{ id: "gb", name: "Origin B", selectedByDefault: true }],
+ defaultGroupId: "gb",
+ siteId: "originb",
+ };
+}
+
+function summariesB() {
+ return [
+ {
+ slug: `${CHANNEL_B_SLUG}/vidb1`,
+ id: "vidb1",
+ channelSlug: CHANNEL_B_SLUG,
+ title: "Bravo Video",
+ uploadDate: "20260101",
+ date: "2026-01-01",
+ duration: "5:00",
+ channel: CHANNEL_B,
+ isLivestream: false,
+ ageRestricted: false,
+ isDeleted: false,
+ isUnlisted: false,
+ platform: "youtube" as const,
+ webpageUrl: `${ORIGIN_B}/vidb1`,
+ },
+ ];
+}
+
+// Route-mock Origin B's federation contract. `cors` toggles the ACAO header on
+// the descriptor to simulate a site that isn't sharing its data.
+async function mockOriginB(page: Page, { cors = true }: { cors?: boolean } = {}) {
+ await page.route(`${ORIGIN_B}/site.json`, (r) =>
+ fulfillJson(r, descriptorB(), cors),
+ );
+ await page.route(`${ORIGIN_B}/summaries/manifest.json`, (r) =>
+ fulfillJson(r, manifestB()),
+ );
+ await page.route(`${ORIGIN_B}/summaries/page-*.json`, (r) =>
+ fulfillJson(r, summariesB()),
+ );
+ // No subs on Origin B.
+ await page.route(`${ORIGIN_B}/subs/manifest.json`, (r) =>
+ fulfillJson(r, {
+ version: 4,
+ channels: [],
+ totalCount: 0,
+ liveChatTotalCount: 0,
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ }),
+ );
+}
+
+// The hub has no built-in pool in these tests — keep it empty so only the
+// added origin is exercised (and avoid a 404 on the missing file).
+async function stubBuiltins(page: Page) {
+ await page.route("**/hub-sites.json", (r) => fulfillJson(r, []));
+}
+
+async function addArchive(page: Page, url: string) {
+ await page.getByLabel("Archive URL").fill(url);
+ await page.getByRole("button", { name: "Add", exact: true }).click();
+}
+
+test("hub: a search run on / still grounds the chat after the per-card Ask", async ({
+ page,
+}) => {
+ await stubBuiltins(page);
+ await mockOriginB(page);
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: "Your archives" })).toBeVisible();
+ await addArchive(page, ORIGIN_B);
+ await expect(
+ page.getByTestId("shelf-spine").filter({ hasText: "Origin B" }),
+ ).toBeVisible();
+
+ // Run a metadata search for "bravo" (Origin B serves no transcript shards).
+ await page.getByTestId("query-builder").waitFor();
+ await page
+ .locator('[data-testid^="leaf-scope-"]')
+ .first()
+ .selectOption({ label: "Title / channel" });
+ const input = page.locator('[data-testid^="leaf-query-"]').first();
+ await input.click();
+ await input.fill("bravo");
+ await input.press("Enter");
+ await page.waitForURL(/[?&]qt=/);
+ const card = page.locator("[data-result-slug]").first();
+ await expect(card).toBeVisible();
+
+ // The per-card Ask: selects the video and client-navigates to /ask/.
+ await card.getByRole("button", { name: "Ask", exact: true }).click();
+ await page.waitForURL(/\/ask\/?$/);
+
+ const summary = page.getByText("Grounding: 1 selected video");
+ await expect(summary).toBeVisible();
+ const whole = page.getByRole("tab", { name: "Whole search" });
+ if (!(await whole.isVisible())) await summary.click();
+ // Switch to the whole search. Held, the session would have no active query
+ // here and no search grounding at all.
+ await whole.click();
+ await expect(
+ page.getByText("Grounded in 1 result from your current search"),
+ ).toBeVisible();
+});
diff --git a/export/e2e/ask-chat.spec.ts b/export/e2e/ask-chat.spec.ts
@@ -536,6 +536,11 @@ test.describe("ask chat", () => {
await page.waitForTimeout(600);
await page.reload();
await keyIn(page);
+ await expect(page.getByTestId("search-submit")).toHaveAttribute(
+ "data-dirty",
+ "true",
+ );
+ await expect(page.getByText(/Grounded in 1 result/)).toHaveCount(0);
await page.getByTestId("search-submit").click();
await expect(page.getByText(/Grounded in 1 result/)).toBeVisible();
await page.getByRole("button", { name: /Show the 1 video/ }).click();