commit 8c6de5c245f8d74f550f40f164f0b7d6c15ba2bd
parent 7d6b417159240e3e101628fe4ea9ef8dad4f6f10
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 17:47:41 -0400
Merge one-core/r8-export-norefire — release 8 slice E: the restored search waits for the visitor (no re-run on load; page-life flag keeps hub /ask grounded)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
# editor/CHANGELOG.md
# plans/release-8.md
Diffstat:
6 files changed, 406 insertions(+), 6 deletions(-)
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 {
@@ -393,7 +401,15 @@ function useSearchSessionState() {
() => canonicalHash(committedRoot),
[committedRoot],
);
- const queryDirty = draftHash !== committedHash;
+ // A query restored from localStorage is committed (so the profile, share
+ // link and divergence logic all see the session exactly as it was left) but
+ // HELD: the pipeline does not run it until the visitor acts — Search / Enter,
+ // or a profile load. A restored query re-ran on every visit, refetching
+ // transcript shards (up to 8 MB each) on a cold device for a search nobody
+ // asked for this time. A query that arrives on the URL (`qt=`, legacy `q=`)
+ // is never held: a shared link is the visitor asking.
+ const [runHeld, setRunHeld] = useState(false);
+ const queryDirty = runHeld || draftHash !== committedHash;
const [draftExcludedChannels, setDraftExcludedChannels] = useState<
Set<string>
@@ -789,9 +805,11 @@ function useSearchSessionState() {
[committedTags, postScopeSlugs],
);
+ // False while a restored query is held (see `runHeld`): the results area
+ // shows the browse listing under the restored filters, and nothing fetches.
const hasActiveQuery = useMemo(
- () => isNodeActive(committedRoot),
- [committedRoot],
+ () => !runHeld && isNodeActive(committedRoot),
+ [committedRoot, runHeld],
);
// ─── Run the query tree ────────────────────────────────────────────────────
@@ -835,6 +853,7 @@ function useSearchSessionState() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
committedHash,
+ hasActiveQuery,
transcripts,
filterKey,
needsChatManifests,
@@ -1201,6 +1220,8 @@ function useSearchSessionState() {
}
let resolvedRoot = rootFromUrl;
+ // True only when the query came from the stored snapshot, never the URL.
+ let holdRestored = false;
if (!initialExcluded) {
// Fall back to stored profile / working snapshot.
const snapshot =
@@ -1223,13 +1244,19 @@ function useSearchSessionState() {
initialTags ??= new Set(snapshot?.tg ?? []);
if (!resolvedRoot && snapshot?.query) {
resolvedRoot = parseRoot(snapshot.query);
+ holdRestored =
+ !ranThisPageLife &&
+ resolvedRoot != null &&
+ isNodeActive(resolvedRoot);
}
}
if (resolvedRoot) {
setDraftRoot(resolvedRoot);
setCommittedRoot(resolvedRoot);
- setSearchExecuted(true);
+ if (holdRestored) setRunHeld(true);
+ else setSearchExecuted(true);
+ if (rootFromUrl && isNodeActive(rootFromUrl)) ranThisPageLife = true;
}
setDraftExcludedChannels(initialExcluded);
@@ -1259,7 +1286,9 @@ function useSearchSessionState() {
if (csRaw) {
setChartShape(parseChartShape(csRaw));
} else if (wantsChart) {
- const active = resolvedRoot ? isNodeActive(resolvedRoot) : false;
+ // A held query has no results to chart until it runs.
+ const active =
+ resolvedRoot && !holdRestored ? isNodeActive(resolvedRoot) : false;
setChartShape(defaultChartShape(active ? "search" : "metadata"));
}
if (wantsChart) setView("chart");
@@ -1342,6 +1371,8 @@ function useSearchSessionState() {
const commitSearch = () => {
setSearchExecuted(true);
+ setRunHeld(false);
+ ranThisPageLife = true;
setCommittedRoot(draftRoot);
const nextSnapshot = buildDraftSnapshot();
const current = loadStoredState() ?? emptyStoredState();
@@ -1403,6 +1434,9 @@ function useSearchSessionState() {
const applySnapshot = useCallback(
(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/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,6 +2,7 @@
## [Unreleased]
- **The yt-dlp clip command is back on sites with transcript downloads turned off.** Turning off `transcriptDownloads` (site.json, or the hub's homepage.json) hid three buttons in the transcript viewer. One of them, the yt-dlp button, only copies a `yt-dlp --download-sections` command for a marked clip to the clipboard and serves no file, so it is not a download. It now shows on every site. The switch still hides the Download menu (txt / srt / json) and Copy MD. The site and hub form labels in the editor say so. No setting changed; a site picks this up at its next build and deploy.
+- **Export sites: a search restored from the last visit waits for you.** Opening a site (or `/ask`, or the hub) still loads the last query, the filters and the profile from the browser, but no longer runs the search on the first page of a visit; moving between pages after a search keeps it running, so the hub's chat still grounds in the search just done on its front page. The results show the video listing under those filters, and the bar says "Press Enter or click Search to apply", as for any unapplied edit. Search or Enter runs it; so does loading a profile. A link with a query in it (`?qt=`) still runs on arrival. Going straight to the hub's `/ask` in a new visit leaves the restored search held, and that page has no search bar: search on the hub's front page first. The restored search used to re-fetch transcript shards (up to 8 MB each) on every visit to a device that had not cached them. Needs a rebuild and deploy of every export site.
- **The project homepage is rewritten, and it can be deployed as a preview.** The front page of `archilyzer.pages.dev` now opens on one line and a chart. The chart shows the official instances' transcripts by the month each video was published, from 2009 to now, stacked by instance. It is drawn when the site is built, so no chart script loads. Under it is one strip of numbers for the official instances: hours of speech, transcripts, recordings, channels, instances, and recordings gone at the source but still here. The **Official instances** section has a card for each public site, with its channels, recordings, transcripts and hours. **What it does** is now three short paragraphs. The recent-acquisitions list, "How it works" and "What this isn't" are gone from the page. The homepage summary (`homepage/public/homepage-summary.json`) is version 5. It adds `monthly`, `official` and per-site numbers and removes nothing, so `/stats` is unchanged. `archilyzer deploy homepage --preview <branch>` deploys a preview the way `deploy hub --preview` does. Without the flag it still deploys to production (`main`).
- **A channel's video list shows titles, and you can search by them.** On `/channels/<slug>/videos`, each row now shows the video's title, with its id in smaller type underneath. Search matches the title or the id, ignoring case. The title comes from the transcript index for transcribed videos, from the channel's metadata scan for videos that were listed but never downloaded, and otherwise from the video's `metadata.info.json`. A video none of these name shows its id, as before. Each row's selection checkbox and link are named by the id as before, and the order is unchanged (by id).
- **An undownloaded video's page shows its title and details.** The video page used to show a bare id for any video without a `metadata.info.json`. If the channel's metadata scan has read the video, the page now shows its title, upload date and duration from the scan, marked *from the listing scan — not downloaded*. Any video page with a description, from either source, has a **Description** section, collapsed by default.
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
@@ -531,10 +531,17 @@ test.describe("ask chat", () => {
// Persists across a reload (enrichment stored in the conversation; the live
// search restored from localStorage). The key is only saved on send, so
- // re-enter it after reloading.
+ // re-enter it after reloading. A restored search loads into the bar but
+ // does not run until the visitor submits it (release 8 slice E), so run it.
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();
await expect(page.getByText(/2 excerpts/)).toBeVisible();
diff --git a/export/e2e/restore-no-refire.spec.ts b/export/e2e/restore-no-refire.spec.ts
@@ -0,0 +1,125 @@
+import { expect, test, type Page } from "@playwright/test";
+import { installRoutes } from "./helpers";
+
+// Release 8 slice E. The search session restored from localStorage loads the
+// query and the filters but does NOT run the search on first load: a restored
+// query used to re-run the pipeline on every visit, refetching transcript
+// shards (up to 8 MB each on a cold device) for a search the visitor had not
+// asked for this time. The visitor runs it — Search / Enter. A query on the
+// URL (`qt=`) is a shared link, i.e. the visitor asking, and still runs.
+
+const STORAGE_KEY = "ytdlp-tb:export-filters";
+const SHARD = /\/transcripts\/[^/]+\/page-\d+\.json$/;
+
+function countShards(page: Page): { n: number } {
+ const seen = { n: 0 };
+ page.on("request", (req) => {
+ if (SHARD.test(new URL(req.url()).pathname)) seen.n += 1;
+ });
+ return seen;
+}
+
+const leafInput = (page: Page) =>
+ page.locator('input[data-testid^="leaf-query-"]').first();
+
+// A query that hits every fixture transcript (each has an "alpha line" cue),
+// in the app's own serialization (`stringifyRoot`: the qt= JSON).
+const ALPHA_TREE = { k: "g", o: "AND", c: [{ k: "l", q: "alpha", s: "transcripts" }] };
+
+// Pre-seed the session a previous visit would have left: the query plus a
+// filter (Livestreams off). Written via page.evaluate on a throwaway first load
+// — not addInitScript, which would re-fire on every navigation. Nothing has
+// searched in this browser context, so the IndexedDB layer cache is empty and a
+// search that runs MUST fetch shards.
+async function seedStoredSearch(page: Page) {
+ await page.goto("/");
+ await page.evaluate(
+ ({ key, value }) => window.localStorage.setItem(key, value),
+ {
+ key: STORAGE_KEY,
+ value: JSON.stringify({
+ v: 1,
+ working: {
+ channels: { included: [], excluded: [] },
+ nol: true,
+ query: JSON.stringify(ALPHA_TREE),
+ },
+ profiles: {},
+ activeProfileName: null,
+ }),
+ },
+ );
+}
+
+test.describe("restored search waits for the visitor", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("a restored query loads into the form, fetches no shard, and runs on submit", async ({
+ page,
+ }) => {
+ const shards = countShards(page);
+ await seedStoredSearch(page);
+
+ // A fresh visit with no query on the URL: the session comes from storage.
+ shards.n = 0;
+ await page.goto("/");
+ // The settings load: the query is in the input, the filter is restored…
+ await expect(leafInput(page)).toHaveValue("alpha");
+ await expect(
+ page.getByRole("checkbox", { name: "Livestreams" }),
+ ).not.toBeChecked();
+ // …and the bar says what the visitor has to do, as for any unapplied edit.
+ await expect(page.getByTestId("search-submit")).toHaveAttribute(
+ "data-dirty",
+ "true",
+ );
+ await expect(
+ page.getByText("Press Enter or click Search to apply"),
+ ).toBeVisible();
+ // The results area is the browse listing under the restored filters.
+ await expect(page.getByTestId("results-summary")).toHaveText(
+ "All videos (3)",
+ );
+ await expect(page.getByTestId("browse-hint")).toBeVisible();
+ // Give a would-be pipeline every chance to start.
+ await page.waitForTimeout(1_500);
+ expect(shards.n).toBe(0);
+
+ // The visitor runs it.
+ const fetched = page.waitForRequest(
+ (req) => SHARD.test(new URL(req.url()).pathname),
+ { timeout: 10_000 },
+ );
+ await leafInput(page).press("Enter");
+ await fetched;
+ await expect(page.getByTestId("results-summary")).toContainText(
+ "Matching videos",
+ );
+ await expect(page.getByTestId("search-submit")).toHaveAttribute(
+ "data-dirty",
+ "false",
+ );
+ });
+
+ test("a query on the URL (a shared link) still runs on load", async ({
+ page,
+ }) => {
+ const shards = countShards(page);
+ // A stored session is present too: the URL query is not held, it runs.
+ await seedStoredSearch(page);
+ const link = `/?qt=${encodeURIComponent(JSON.stringify(ALPHA_TREE))}`;
+
+ shards.n = 0;
+ await page.goto(link);
+ await expect(page.getByTestId("results-summary")).toContainText(
+ "Matching videos",
+ );
+ await expect.poll(() => shards.n).toBeGreaterThan(0);
+ await expect(page.getByTestId("search-submit")).toHaveAttribute(
+ "data-dirty",
+ "false",
+ );
+ });
+});
diff --git a/plans/release-8.md b/plans/release-8.md
@@ -306,6 +306,78 @@ runs there.
- `KpiHeader.tsx` and `RecentAdditions.tsx` are unchanged. `/stats` still uses `KpiHeader`. The rail is
unused on `/` but kept, per the brief.
+### Slice E, as shipped — export: the restored search waits for the visitor (2026-09-25)
+
+Branch `one-core/r8-export-norefire` off `main` `1d54b7af`. **The operator's instruction (2026-09-25):** "The
+export homepage pre-loads the last query from localStorage, including firing that search to immediately
+see results. Just load the settings but don't re-fire the search on first load." A restored query re-ran
+the leaf pipeline on every visit. On a cold device that refetched transcript shards (up to 8 MB each); even
+from the service-worker cache it was CPU and memory the visitor had not asked for. Nothing else heavy loads
+on a plain visit (~1.24 MB of summaries).
+
+**The restore-vs-run split** (all in `common/components/SearchSessionContext.tsx`, so `/`, `/ask` and the
+hub get it together). Hydration still COMMITS everything it restores: the query tree (draft and committed),
+the filters, the channel selection, the tag chips, the profile pointer and the grounding selection. The
+profile divergence check, the share link and the persisted `working` snapshot therefore see the session
+exactly as it was left; nothing about what is stored changed. What is new is one boolean, `runHeld`:
+- It is set only when the query came from the stored snapshot (`working` or the active profile), is
+ non-empty, and no search has run yet in this page life (`ranThisPageLife`, module state set by
+ `commitSearch`, `applySnapshot` and a URL query that runs; it survives client-side navigation and resets
+ on a reload or a new visit). That last condition is the review fix: the HUB mounts a separate provider
+ per route, so `/` → `/ask` re-hydrates, and without it the hub chat lost the search just run. A query on the URL (`qt=`, or the legacy `q=`/`m=`/`re=`) is never held: a shared link is the
+ visitor asking, and it runs as before.
+- While held, `hasActiveQuery` is false. The pipeline effect returns early (no leaf pipeline, no shard
+ fetch), the results area is the ordinary browse listing under the restored filters (summaries only, the
+ same `All videos (N)` + `browse-hint` as a visit with no query), chart defaults and `/ask`'s live
+ grounding see "no active query", and the selection self-clean prunes against the browse set, a
+ superset of the search's, so a restored selection survives.
+- `queryDirty` is true while held, so the bar shows what it already shows for any unapplied edit: the
+ filled `Search` button (`data-dirty="true"`) and "Press Enter or click Search to apply". That is the
+ "neutral line": existing UI and copy, nothing new. The browse listing was chosen over an empty results
+ area because it is what the page shows with no query, costs nothing extra, and keeps `browse-all.spec.ts`'s
+ contract.
+- It is cleared by whatever already commits a search: `commitSearch` (Search, Enter, the filters panel's
+ Apply) and `applySnapshot` (loading or reverting a profile). The pipeline effect now also depends on
+ `hasActiveQuery`, so committing the same query that was held (same hash) still starts it.
+
+| sha | what |
+|---|---|
+| `00fb567b` | `SearchSessionContext.tsx`: `runHeld` (set by hydration only for a stored query, cleared by `commitSearch`/`applySnapshot`), gating `hasActiveQuery` and `queryDirty`; the pipeline effect keys on `hasActiveQuery`; a held query does not default a restored chart to "search". New `export/e2e/restore-no-refire.spec.ts` (+2): a pre-seeded query + filter loads into the form with no transcript shard requested in 1.5 s after the browse listing is up, and Enter fetches one and shows `Matching videos`; a `?qt=` link with a stored session present still fetches on load |
+| `501ee3e2` | (review fix R1) `ranThisPageLife` gates the hold, so hub `/` → `/ask` (per-card Ask / "Ask AI", `router.push`, a NEW `SearchSessionProvider` in `AskHub`) keeps grounding in the search just run. New `export/e2e-hub/ask-grounding.spec.ts` (+1): metadata search on hub `/`, per-card Ask, "Whole search" → `Grounded in 1 result from your current search`; with the gate disabled it fails (not even the selection grounding appears). `ask-chat.spec.ts` now asserts the hold on `/ask` (Search `data-dirty="true"`, no `Grounded in 1 result`) before clicking Search |
+| `545c7405` | `ask-chat.spec.ts` "Load context enriches the pin and persists": after the reload on `/ask` it clicks Search before asserting `Grounded in 1 result` (the test encoded the old re-run-on-load). Outside the slice's listed ownership; no other release-8 slice touches `export/e2e/` |
+
+`filter-profile-persistence.spec.ts` is **unchanged**: it seeds no query and asserts only the profile
+pointer and checkbox state across reloads, all of which this slice preserves.
+
+**Gates**, on `545c7405`: tsc clean. Common tests **1816/1816** (unchanged; no common unit test covers the
+hook). `pnpm --filter export exec next build` ok. Export e2e **194 passed, 0 failed, 6.7 min** (192 + 2;
+`e-final-export.log`). `e2e:hub` **8 passed, 18 s**. `e2e:2origin` with `TWO_ORIGIN_REBUILD=1` **3 passed,
+38 s**, with the hub-written `export/public` entries swapped for copies and relinked after, as in
+`final-e2e-r7.sh`. An earlier targeted run on `00fb567b` (six specs, `e-e2e-targeted.log`) was 41 passed,
+1 failed: the ask-chat test fixed in `545c7405`. The editor build, editor unit, test:scripts and mcp were
+not run: the slice touches no file they build or test beyond `common/components/`, which tsc covers
+and the editor does not render (`SearchSessionContext` is export-only). **Numbers: none.**
+
+**Re-gate after the review fix**, on `501ee3e2` (`e-regate.log`): tsc clean. `pnpm --filter export exec next build` ok. Export e2e `restore-no-refire` + `ask-chat` **25 passed, 0 failed, 1.2 min**. `e2e:hub` in full **9 passed** (8 + 1), 22 s. `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`, swap and relink as before) **3 passed**, 43 s. Common tests not re-run: no common unit covers the hook, and the fix is 16 lines inside it.
+
+**Pending, the parent's:** a rebuild and deploy of every export site (and the hub) for the change to reach
+visitors.
+
+**Found and left.**
+- **A cold, direct visit to hub `/ask` still holds the restored query, and nothing there can run it:**
+ `AskHub` has no search bar. The chat answers ungrounded until the visitor searches on `/` and comes
+ back. That is the operator's rule ("don't re-fire the search on first load") applied literally; a
+ single site's `/ask` has the bar and is unaffected.
+- **Saving a profile while a query is held keeps it held.** `handleSaveProfile` / `Save as…` promote the
+ draft FILTERS to committed but have never committed the query tree, so they do not clear the hold; the
+ saved profile still carries the query. The visitor runs it with Search. Left as is: saving is not searching.
+- **A held chat or posts query still loads the subs/posts manifests on first load**, because the DRAFT
+ tree names that scope (`draftHasChatLeaf`), exactly as it does while someone types one. Manifests are
+ small; no shard is fetched.
+- The e2e measures shard requests from the page (`page.on("request")`). A search served wholly from the
+ IndexedDB layer cache would fetch nothing either way, which is why the spec seeds storage in a fresh
+ context that has never searched.
+
### Slice Z, as shipped — the yt-dlp command button is not a download (2026-09-25)
Branch `one-core/r8-ytdlp-button` off `main` `90a6aae6`. **The operator (2026-09-25):** "Oh no yt-dlp