Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/components/SearchSessionContext.tsx | 16+++++++++++++++-
Aexport/e2e-hub/ask-grounding.spec.ts | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/ask-chat.spec.ts | 5+++++
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();