commit 0363ab70173abcb1174b7396d63eb6a423b74b94
parent f18a3e0d531b9f930791878ef283f4eea65ead5b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 16 Jul 2026 17:45:17 -0400
Unify search + Ask into one workspace shell (persistent search bar)
/ (search) and /ask (chat) were separate pages; navigating between them
threw away the search. They now share a route-group layout ((workspace)/)
that mounts the corpus data source, a shared SearchSession, and a PERSISTENT
search bar above the routed content — so the bar and its committed search
survive / <-> /ask navigation. A Results/Chat switch routes between the two
views.
The committed search state, async pipeline, URL sync, profiles, and derived
results were lifted out of the 2613-line TranscriptSearch monolith into:
- common/components/SearchSessionContext.tsx (SearchSessionProvider + hook)
- common/components/WorkspaceSearchBar.tsx (query-builder form + filters)
- common/components/SearchResults.tsx (results / chart view)
TranscriptSearch is now a 28-line composition of those three (kept for hub's
HubHome, which mounts it under MultiSiteDataProvider). Hub mode is otherwise
untouched — its layout passes children straight through.
The /ask chat still reads the one-shot sessionStorage handoff (unchanged);
wiring it to read the live SearchSession is a follow-up.
Verified: tsc clean (export + common); no new lint errors; full e2e
106/106 green (104 existing + 2 new workspace-shell persistence/active-view
tests). The ask-chat keyIn helper was hardened to prove hydration before the
mode click (the persistent bar widened the /ask hydration window under dev).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
14 files changed, 3196 insertions(+), 2698 deletions(-)
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -0,0 +1,548 @@
+"use client";
+
+// The results half of the old TranscriptSearch monolith: the results/chart
+// section plus its virtualized card list. Everything it renders comes from the
+// shared SearchSession, so it stays in lockstep with the persistent search bar
+// even though the two now live in different slots of the workspace shell.
+
+import React, { memo, useEffect, useMemo, useRef, useState } from "react";
+import { useWindowVirtualizer } from "@tanstack/react-virtual";
+import { VirtualRow } from "./VirtualRow";
+import type { LayerHit } from "./searchPipeline";
+import {
+ AgeRestrictedBadge,
+ LivestreamBadge,
+ VodExpiredBadge,
+} from "./badges";
+import { vodExpiry } from "../lib/vodExpiry";
+import { Button } from "./ui/button";
+import { LayerSwatch } from "./LayerSwatch";
+import { ChartShapeControls } from "./charts/ChartShapeControls";
+import { SearchChartPanel } from "./charts/SearchChartPanel";
+import {
+ useSearchSession,
+ type LeafInfo,
+ type ResultGroup,
+} from "./SearchSessionContext";
+
+// Rough first-paint guess for one full result card (header + a couple of
+// leaf sections + a handful of hits). After mount, ResizeObserver measures
+// the card's actual height and the virtualizer re-positions later cards.
+const CARD_ROW_ESTIMATE_PX = 120;
+
+export default function SearchResults() {
+ const {
+ hasActiveQuery,
+ resultGroups,
+ totalHits,
+ capped,
+ indexLoading,
+ summariesLoading,
+ indexProgress,
+ searching,
+ processed,
+ totalToProcess,
+ pipelineActive,
+ view,
+ setView,
+ showChartView,
+ effectiveShape,
+ setChartShape,
+ chartSlugs,
+ treeProgress,
+ globalScopeSlugs,
+ leavesById,
+ orderedLeafIds,
+ activeVideo,
+ activeTime,
+ openWithMode,
+ setHitLimit,
+ hitBatchSize,
+ resultsCopied,
+ copyResultsContext,
+ askAboutResults,
+ } = useSearchSession();
+
+ return (
+ <section data-testid="results-section">
+ <h2 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-2 flex-wrap">
+ <span data-testid="results-summary">
+ {hasActiveQuery ? (
+ <>
+ Matching videos{" "}
+ {resultGroups.length > 0
+ ? `(${resultGroups.length} video${
+ resultGroups.length === 1 ? "" : "s"
+ }${totalHits > 0 ? `, ${totalHits}${capped ? "+" : ""} hit${totalHits === 1 ? "" : "s"}` : ""})`
+ : "(0)"}
+ </>
+ ) : (
+ `All videos (${resultGroups.length})`
+ )}
+ </span>
+ {(indexLoading || (!hasActiveQuery && summariesLoading)) && (
+ <span className="text-xs text-muted-foreground font-normal">
+ loading index{indexProgress ? ` (${indexProgress})` : ""}…
+ </span>
+ )}
+ {searching && (
+ <span className="text-xs text-muted-foreground font-normal">
+ searched {processed}/{totalToProcess}
+ {pipelineActive ? "…" : ""}
+ </span>
+ )}
+ <span
+ data-testid="view-toggle"
+ className="ml-auto inline-flex overflow-hidden rounded-md border border-border bg-muted"
+ >
+ <button
+ type="button"
+ onClick={() => setView("results")}
+ className={`px-2.5 py-1 text-xs font-normal ${
+ view === "results"
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ }`}
+ >
+ Results
+ </button>
+ <button
+ type="button"
+ onClick={showChartView}
+ className={`px-2.5 py-1 text-xs font-normal ${
+ view === "chart"
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ }`}
+ >
+ Chart
+ </button>
+ </span>
+ {hasActiveQuery && resultGroups.length > 0 && (
+ <span className="inline-flex items-center gap-2">
+ <button
+ type="button"
+ onClick={copyResultsContext}
+ title="Copy these results as context to paste into an AI chat"
+ className="inline-flex items-center rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-normal text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ >
+ {resultsCopied ? "✓ Copied" : "Copy for AI"}
+ </button>
+ <button
+ type="button"
+ onClick={askAboutResults}
+ title="Open the AI chat grounded in these search results"
+ className="inline-flex items-center rounded-md bg-primary px-2.5 py-1 text-xs font-normal text-primary-foreground hover:opacity-90"
+ >
+ Ask AI about these results
+ </button>
+ </span>
+ )}
+ </h2>
+
+ {view === "chart" ? (
+ <div className="flex flex-col gap-4">
+ <details
+ data-testid="chart-options"
+ open
+ className="rounded-lg border border-border px-3 py-2"
+ >
+ <summary className="cursor-pointer text-sm font-medium text-foreground">
+ Chart options
+ </summary>
+ <div className="pt-3">
+ <ChartShapeControls shape={effectiveShape} onChange={setChartShape} />
+ </div>
+ </details>
+ <SearchChartPanel
+ slugs={chartSlugs}
+ hitsBySlug={treeProgress?.hits ?? null}
+ scopeSlugs={globalScopeSlugs}
+ shape={effectiveShape}
+ loading={pipelineActive}
+ />
+ </div>
+ ) : (
+ <>
+ {!hasActiveQuery && !summariesLoading && resultGroups.length > 0 && (
+ <p className="text-sm text-muted-foreground mb-2" data-testid="browse-hint">
+ Showing all videos — type a query above to narrow.
+ </p>
+ )}
+ {hasActiveQuery
+ ? !indexLoading &&
+ resultGroups.length === 0 &&
+ !pipelineActive && (
+ <p className="text-sm text-muted-foreground">No matching videos.</p>
+ )
+ : !summariesLoading &&
+ resultGroups.length === 0 && (
+ <p className="text-sm text-muted-foreground">
+ No videos match the current filters.
+ </p>
+ )}
+ {resultGroups.length > 0 && (
+ <VirtualResultList
+ resultGroups={resultGroups}
+ leavesById={leavesById}
+ orderedLeafIds={orderedLeafIds}
+ activeVideo={activeVideo}
+ activeTime={activeTime}
+ openWithMode={openWithMode}
+ />
+ )}
+ {capped && (
+ <div className="mt-3 flex justify-center">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setHitLimit((l) => l + hitBatchSize)}
+ >
+ Load more results
+ </Button>
+ </div>
+ )}
+ </>
+ )}
+ </section>
+ );
+}
+
+// ─── Result rendering ────────────────────────────────────────────────────────
+type ResultRow =
+ | { kind: "card"; cardIndex: number; group: ResultGroup }
+ | { kind: "gap"; cardIndex: number };
+
+const GAP_ROW_PX = 12;
+
+function buildResultRows(
+ resultGroups: ReadonlyArray<ResultGroup>,
+): ResultRow[] {
+ const out: ResultRow[] = [];
+ resultGroups.forEach((g, cardIndex) => {
+ out.push({ kind: "card", cardIndex, group: g });
+ if (cardIndex < resultGroups.length - 1) {
+ out.push({ kind: "gap", cardIndex });
+ }
+ });
+ return out;
+}
+
+function rowKey(row: ResultRow): string {
+ return row.kind === "card" ? `c${row.cardIndex}` : `c${row.cardIndex}:gap`;
+}
+
+function VirtualResultList({
+ resultGroups,
+ leavesById,
+ orderedLeafIds,
+ activeVideo,
+ activeTime,
+ openWithMode,
+}: {
+ resultGroups: ReadonlyArray<ResultGroup>;
+ leavesById: ReadonlyMap<string, LeafInfo>;
+ orderedLeafIds: ReadonlyArray<string>;
+ activeVideo: string | null;
+ activeTime: number | null;
+ openWithMode: (slug: string, hit?: LayerHit) => void;
+}) {
+ const flatRows = useMemo(() => buildResultRows(resultGroups), [resultGroups]);
+
+ const listRef = useRef<HTMLDivElement | null>(null);
+ const [scrollMargin, setScrollMargin] = useState(0);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const update = () => {
+ const el = listRef.current;
+ if (!el) return;
+ setScrollMargin(el.getBoundingClientRect().top + window.scrollY);
+ };
+ update();
+ const ro = new ResizeObserver(update);
+ if (listRef.current) ro.observe(listRef.current);
+ ro.observe(document.body);
+ window.addEventListener("resize", update);
+ return () => {
+ ro.disconnect();
+ window.removeEventListener("resize", update);
+ };
+ }, []);
+
+ const rowVirtualizer = useWindowVirtualizer({
+ count: flatRows.length,
+ // Card heights vary a lot (header-only tombstone vs. a card with
+ // hundreds of hits), so estimateSize is only a rough first-paint
+ // guess; ResizeObserver measures each card's actual height on mount
+ // and the virtualizer re-positions later cards. The unit here is a
+ // whole card, not a row inside one — there is no intra-card row
+ // positioning to get wrong.
+ estimateSize: (index) =>
+ flatRows[index]?.kind === "gap" ? GAP_ROW_PX : CARD_ROW_ESTIMATE_PX,
+ // Cards are large; ~4 above/below the viewport is plenty of buffer.
+ overscan: 4,
+ scrollMargin,
+ });
+
+ const virtualItems = rowVirtualizer.getVirtualItems();
+ const totalSize = rowVirtualizer.getTotalSize();
+
+ return (
+ <div ref={listRef} className="relative" style={{ height: totalSize }}>
+ {virtualItems.map((vi) => {
+ const row = flatRows[vi.index];
+ return (
+ <VirtualRow
+ key={rowKey(row)}
+ measureRef={rowVirtualizer.measureElement}
+ index={vi.index}
+ offset={vi.start - scrollMargin}
+ >
+ {row.kind === "gap" ? (
+ <div style={{ height: GAP_ROW_PX }} aria-hidden="true" />
+ ) : (
+ <ResultCard
+ group={row.group}
+ leavesById={leavesById}
+ orderedLeafIds={orderedLeafIds}
+ activeVideo={activeVideo}
+ activeTime={activeTime}
+ openWithMode={openWithMode}
+ />
+ )}
+ </VirtualRow>
+ );
+ })}
+ </div>
+ );
+}
+
+const ResultCard = memo(function ResultCard({
+ group,
+ leavesById,
+ orderedLeafIds,
+ activeVideo,
+ activeTime,
+ openWithMode,
+}: {
+ group: ResultGroup;
+ leavesById: ReadonlyMap<string, LeafInfo>;
+ orderedLeafIds: ReadonlyArray<string>;
+ activeVideo: string | null;
+ activeTime: number | null;
+ openWithMode: (slug: string, hit?: LayerHit) => void;
+}) {
+ // Bucket hits per contributing leaf so the user sees one section per
+ // layer rather than an interleaved mishmash.
+ const buckets = useMemo(() => {
+ const m = new Map<string, LayerHit[]>();
+ for (const id of orderedLeafIds) m.set(id, []);
+ for (const h of group.hits) {
+ const arr = m.get(h.leafId);
+ if (arr) arr.push(h);
+ }
+ return m;
+ }, [group.hits, orderedLeafIds]);
+
+ return (
+ <div
+ data-result-slug={group.slug}
+ data-card-header=""
+ className="border border-border rounded-lg overflow-hidden bg-card"
+ // Provenance: a left edge in the source archive's accent (hub mode only).
+ style={
+ group.accent
+ ? { borderLeftColor: group.accent, borderLeftWidth: "3px" }
+ : undefined
+ }
+ >
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => openWithMode(group.slug)}
+ className="w-full h-auto justify-start text-left items-baseline gap-2 px-4 py-2 rounded-none border-b border-border bg-muted font-normal"
+ >
+ <span className="font-medium truncate flex-1 min-w-0">
+ {group.title}
+ </span>
+ {group.isLivestream && <LivestreamBadge />}
+ {group.ageRestricted && <AgeRestrictedBadge />}
+ {(() => {
+ const e = vodExpiry(group.platform, group.uploadDate);
+ return e?.likelyExpired ? (
+ <VodExpiredBadge tooltip={e.tooltip} />
+ ) : null;
+ })()}
+ <span className="text-xs text-muted-foreground shrink-0">
+ {group.channel && `${group.channel} · `}
+ {group.date}
+ {group.hits.length > 0 &&
+ ` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`}
+ </span>
+ </Button>
+ <ul className="flex flex-col divide-y divide-border">
+ {Array.from(buckets.entries()).map(([leafId, hits]) => {
+ if (hits.length === 0) return null;
+ const leafInfo = leavesById.get(leafId);
+ if (!leafInfo) return null;
+ return (
+ <li key={leafId}>
+ <div
+ className="flex items-center gap-2 px-4 py-1.5 bg-muted/40 border-b border-border"
+ data-leaf-section={leafId}
+ >
+ <LayerSwatch leafId={leafId} size="xs" />
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
+ {leafInfo.scope === "metadata"
+ ? "Title / channel"
+ : leafInfo.scope === "chat"
+ ? "Live chat"
+ : "Transcripts"}
+ </span>
+ <span className="font-mono text-xs text-muted-foreground truncate">
+ {leafInfo.query}
+ </span>
+ <span className="ml-auto text-[10px] text-muted-foreground">
+ {hits.length} hit{hits.length === 1 ? "" : "s"}
+ </span>
+ </div>
+ <ul className="flex flex-col divide-y divide-border/60">
+ {hits.map((h, i) => (
+ <HitRow
+ key={i}
+ slug={group.slug}
+ hit={h}
+ query={leafInfo.query}
+ useRegex={leafInfo.useRegex}
+ isActive={
+ activeVideo === group.slug && activeTime === h.start
+ }
+ onOpen={openWithMode}
+ />
+ ))}
+ </ul>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ );
+});
+ResultCard.displayName = "ResultCard";
+
+// One hit row inside a card. Memoized so that (a) scroll-frame re-renders of
+// the parent list don't re-run highlight() for every hit, and (b) when the
+// modal target changes only the previously-active and newly-active rows
+// re-render — every other row bails out on shallow-equal props.
+const HitRow = memo(function HitRow({
+ slug,
+ hit,
+ query,
+ useRegex,
+ isActive,
+ onOpen,
+}: {
+ slug: string;
+ hit: LayerHit;
+ query: string;
+ useRegex: boolean;
+ isActive: boolean;
+ onOpen: (slug: string, hit?: LayerHit) => void;
+}) {
+ return (
+ <li>
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => onOpen(slug, hit)}
+ className={`w-full h-auto justify-start text-left flex gap-3 px-4 py-2 rounded-none font-normal whitespace-normal ${
+ isActive ? "bg-warning-soft ring-1 ring-inset ring-warning/60" : ""
+ }`}
+ >
+ <span className="text-xs font-mono text-muted-foreground shrink-0 w-16">
+ {hit.scope === "metadata" ? "—" : formatSeconds(hit.start)}
+ </span>
+ {hit.track && hit.track !== "live_chat" && (
+ <TrackBadge track={hit.track} />
+ )}
+ <span className="flex-1 min-w-0 text-sm">
+ {highlight(hit.text, query, useRegex)}
+ </span>
+ </Button>
+ </li>
+ );
+});
+HitRow.displayName = "HitRow";
+
+function TrackBadge({ track }: { track: string }) {
+ const label = track === "live_chat" ? "live chat" : track;
+ return (
+ <span className="shrink-0 text-[10px] uppercase tracking-wide font-medium px-1.5 py-0.5 rounded bg-muted text-muted-foreground self-center">
+ {label}
+ </span>
+ );
+}
+
+function formatSeconds(s: number): string {
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ const mm = String(m).padStart(2, "0");
+ const ss = String(sec).padStart(2, "0");
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
+}
+
+function highlight(text: string, q: string, useRegex = false) {
+ if (!q) return text;
+ const parts: React.ReactNode[] = [];
+
+ if (useRegex) {
+ let re: RegExp;
+ try {
+ re = new RegExp(q, "gi");
+ } catch {
+ return text;
+ }
+ let last = 0;
+ let m: RegExpExecArray | null;
+ let key = 0;
+ while ((m = re.exec(text)) !== null) {
+ if (m.index > last) parts.push(text.slice(last, m.index));
+ parts.push(
+ <mark
+ key={key++}
+ className="bg-warning-soft text-inherit"
+ >
+ {m[0]}
+ </mark>,
+ );
+ last = m.index + m[0].length;
+ if (m[0].length === 0) re.lastIndex++;
+ }
+ if (last < text.length) parts.push(text.slice(last));
+ return <>{parts}</>;
+ }
+
+ const lower = text.toLowerCase();
+ const ql = q.toLowerCase();
+ let i = 0;
+ while (i < text.length) {
+ const idx = lower.indexOf(ql, i);
+ if (idx === -1) {
+ parts.push(text.slice(i));
+ break;
+ }
+ if (idx > i) parts.push(text.slice(i, idx));
+ parts.push(
+ <mark
+ key={idx}
+ className="bg-warning-soft text-inherit"
+ >
+ {text.slice(idx, idx + q.length)}
+ </mark>,
+ );
+ i = idx + q.length;
+ }
+ return <>{parts}</>;
+}
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -0,0 +1,1605 @@
+"use client";
+
+// The committed search session. This owns everything that used to live inside
+// the TranscriptSearch monolith's component body: the committed query tree +
+// filters, the draft the user is editing, the async search pipeline, URL sync,
+// saved profiles, and all the derived result state. It publishes that state
+// through context so the persistent search BAR (WorkspaceSearchBar) and the
+// RESULTS view (SearchResults) can render from ONE shared session — the two
+// halves of the old monolith, now split across the workspace shell so the bar
+// survives navigation between `/` (results) and `/ask` (chat).
+//
+// It must sit UNDER a SearchData provider (single-site or hub) since it reads
+// the corpus via useSearchData().
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { useRouter } from "next/navigation";
+import { usePlayer } from "./PlayerProvider";
+import { useChannelSubsManifests } from "./subsCache";
+import { useSearchData } from "./SearchDataContext";
+import type { LayerHit } from "./searchPipeline";
+import {
+ runQueryTree,
+ type GroupState,
+ type LeafState,
+ type TreeController,
+ type TreeProgress,
+} from "../lib/searchEval";
+import {
+ canonicalHash,
+ emptyRoot,
+ forEachLeaf,
+ isNodeActive,
+ parseRoot,
+ rootFromLegacy,
+ stringifyRoot,
+ type GroupNode,
+ type LayerScope,
+} from "../lib/searchQuery";
+import { useUrlParams, writeUrlParams } from "./urlState";
+import {
+ buildShareSearchParams,
+ hasShareV1,
+ parseShareV1,
+ stripAllFilterParamsFromUrl,
+ type ShareSelection,
+} from "./shareUrl";
+import {
+ emptySnapshot,
+ emptyStoredState,
+ loadStoredState,
+ saveStoredState,
+ snapshotsEqual,
+ type FilterSnapshot,
+ type StoredState,
+} from "./exportFilterStorage";
+import { loadAdvanced, saveAdvanced } from "./exportAdvancedStorage";
+import {
+ defaultChartShape,
+ parseChartShape,
+ stringifyChartShape,
+ type ChartShape,
+} from "../lib/chartShare";
+import type { DisplaySummary, Platform } from "../lib/transcripts";
+import { makeId, splitId } from "./originId";
+import { sortGroups, type ChannelGroup } from "../lib/channelGroups";
+import { AI_HANDOFF_KEY, buildSearchHandoff } from "../lib/aiHandoff";
+
+type Summary = DisplaySummary;
+
+// Serialize the current result set as markdown to paste into an AI chat as
+// context: the search terms, then each matching video with its timestamped hit
+// snippets. Bounded so the clipboard payload stays reasonable.
+function buildResultsContextMarkdown(
+ groups: ResultGroup[],
+ queries: string[],
+ opts: { maxVideos?: number; maxHitsPerVideo?: number } = {},
+): string {
+ const maxVideos = opts.maxVideos ?? 30;
+ const maxHits = opts.maxHitsPerVideo ?? 8;
+ const hms = (s: number): string => {
+ const n = Math.max(0, Math.floor(s));
+ const h = Math.floor(n / 3600);
+ const m = Math.floor((n % 3600) / 60);
+ const ss = n % 60;
+ const mm = String(m).padStart(2, "0");
+ const sss = String(ss).padStart(2, "0");
+ return h > 0 ? `${h}:${mm}:${sss}` : `${m}:${sss}`;
+ };
+ const lines: string[] = [];
+ lines.push(
+ queries.length
+ ? `# Transcript search results for: ${queries.join(" AND ")}`
+ : "# Transcript search results",
+ );
+ lines.push("");
+ const shown = groups.slice(0, maxVideos);
+ for (const g of shown) {
+ lines.push(`## ${g.title} — ${g.channel}${g.date ? ` (${g.date})` : ""}`);
+ const hits = g.hits.slice(0, maxHits);
+ for (const h of hits) {
+ const stamp = h.start > 0 ? `[${hms(h.start)}] ` : "";
+ lines.push(`- ${stamp}${h.text.trim().replace(/\s+/g, " ")}`);
+ }
+ if (g.hits.length > hits.length) {
+ lines.push(`- …and ${g.hits.length - hits.length} more hit(s)`);
+ }
+ lines.push("");
+ }
+ if (groups.length > shown.length) {
+ lines.push(`_(showing ${shown.length} of ${groups.length} matching videos)_`);
+ }
+ return lines.join("\n") + "\n";
+}
+
+// One matched video plus its per-leaf hits. Shared with SearchResults, which
+// renders the cards.
+export type ResultGroup = {
+ slug: string;
+ title: string;
+ channel: string;
+ date: string;
+ isLivestream: boolean;
+ ageRestricted: boolean;
+ // Carried through so the card can flag likely-expired stream VODs (Kick,
+ // Twitch) via vodExpiry(platform, uploadDate).
+ platform: Platform;
+ uploadDate: string;
+ hits: LayerHit[];
+ // Provenance accent of the source origin (hub mode only); undefined
+ // single-site, so no marker renders.
+ accent?: string;
+};
+
+// A leaf's original terms so result rows can look up the query for <mark>
+// highlighting. Shared with SearchResults.
+export type LeafInfo = {
+ query: string;
+ useRegex: boolean;
+ scope: LayerScope;
+};
+
+// Advanced-pipeline defaults, surfaced so the bar's Advanced-options controls
+// (and their "Reset to defaults" button) share the exact same constants.
+export const DEFAULT_MAX_HITS = 500;
+export const DEFAULT_FETCH_CONCURRENCY = 6;
+export const DEFAULT_FLUSH_INTERVAL_MS = 120;
+
+// Canonical hash of a freshly-constructed empty root — used to disable
+// "Reset layers" when the draft is already at the empty default. Module-level
+// so we don't reallocate a node every render; `canonicalHash` strips IDs so
+// the value is stable.
+const EMPTY_ROOT_HASH = canonicalHash(emptyRoot());
+
+// Legacy URL keys still parsed for hydration (old share-links), but stripped
+// on the first commit so the modern `qt=` is canonical going forward.
+const FILTER_URL_KEYS = [
+ "ch",
+ "nov",
+ "nol",
+ "naa",
+ "nar",
+ "nav",
+ "nd",
+ "nu",
+ "m",
+ "tk",
+] as const;
+
+function urlHasAnyFilterParam(): boolean {
+ if (typeof window === "undefined") return false;
+ const p = new URLSearchParams(window.location.search);
+ return FILTER_URL_KEYS.some((k) => p.has(k));
+}
+
+// Resolve a stored snapshot's per-channel deltas (plus group defaults) into
+// the concrete set of EXCLUDED channel names.
+function snapshotToExcluded(
+ snapshot: FilterSnapshot | null,
+ channelOptions: ReadonlyArray<string>,
+ defaultSelectedChannels: ReadonlySet<string>,
+): Set<string> {
+ const included = new Set(snapshot?.channels.included ?? []);
+ const excluded = new Set(snapshot?.channels.excluded ?? []);
+ const out = new Set<string>();
+ for (const name of channelOptions) {
+ if (included.has(name)) continue;
+ if (excluded.has(name)) {
+ out.add(name);
+ continue;
+ }
+ if (!defaultSelectedChannels.has(name)) out.add(name);
+ }
+ return out;
+}
+
+function buildSnapshotDeltas(
+ excludedSet: ReadonlySet<string>,
+ channelOptions: ReadonlyArray<string>,
+ defaultSelectedChannels: ReadonlySet<string>,
+): { included: string[]; excluded: string[] } {
+ const included: string[] = [];
+ const excluded: string[] = [];
+ for (const name of channelOptions) {
+ const isSelected = !excludedSet.has(name);
+ const defaultSelected = defaultSelectedChannels.has(name);
+ if (isSelected === defaultSelected) continue;
+ if (isSelected) included.push(name);
+ else excluded.push(name);
+ }
+ return { included: included.sort(), excluded: excluded.sort() };
+}
+
+function anyLeafHasScope(root: GroupNode, scope: LayerScope): boolean {
+ let found = false;
+ forEachLeaf(root, (leaf) => {
+ if (leaf.scope === scope && leaf.query.trim()) found = true;
+ });
+ return found;
+}
+
+function sameSet<T>(a: Set<T>, b: Set<T>): boolean {
+ if (a.size !== b.size) return false;
+ for (const v of a) if (!b.has(v)) return false;
+ return true;
+}
+
+// Write `qt=` directly. `writeUrlParams` doesn't know about the param — keep
+// that helper focused on the legacy/share keys and use a small dedicated
+// mutator here.
+function writeQtParam(value: string): void {
+ if (typeof window === "undefined") return;
+ const params = new URLSearchParams(window.location.search);
+ if (value) params.set("qt", value);
+ else params.delete("qt");
+ const qs = params.toString();
+ const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
+ if (next === window.location.pathname + window.location.search) return;
+ window.history.replaceState(window.history.state, "", next);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+}
+
+// Mirror writeQtParam for the chart view/shape params. Keeps the address bar in
+// sync with the current chart so refresh/Back restore it (same dedicated-mutator
+// pattern as qt=).
+function writeViewChartParams(
+ view: "results" | "chart",
+ shape: ChartShape | null,
+): void {
+ if (typeof window === "undefined") return;
+ const params = new URLSearchParams(window.location.search);
+ if (view === "chart") params.set("view", "chart");
+ else params.delete("view");
+ if (view === "chart" && shape) params.set("cs", stringifyChartShape(shape));
+ else params.delete("cs");
+ const qs = params.toString();
+ const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
+ if (next === window.location.pathname + window.location.search) return;
+ window.history.replaceState(window.history.state, "", next);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+}
+
+// ─── The session state hook ─────────────────────────────────────────────────
+// This is the verbatim body of the old TranscriptSearch component (state +
+// pipeline + URL sync + profiles + derived results), ending in a big value
+// object instead of JSX. `SearchSessionValue` is inferred from what it returns
+// so the two consumer components stay in lockstep with it.
+function useSearchSessionState() {
+ const {
+ q: legacyQ,
+ re: legacyRe,
+ v: activeVideo,
+ t: activeTime,
+ mode: legacyMode,
+ } = useUrlParams();
+
+ // Committed filter state lives in React (not the URL) since filters are
+ // no longer auto-tracked.
+ const [committedExcludedChannels, setCommittedExcludedChannels] = useState<
+ Set<string>
+ >(() => new Set());
+ const [committedNov, setCommittedNov] = useState(false);
+ const [committedNol, setCommittedNol] = useState(false);
+ const [committedNaa, setCommittedNaa] = useState(false);
+ const [committedNar, setCommittedNar] = useState(false);
+ const [committedNav, setCommittedNav] = useState(false);
+ const [committedNd, setCommittedNd] = useState(false);
+ const [committedNu, setCommittedNu] = useState(false);
+ // Inclusive upload-date bounds ("YYYYMMDD"); "" => unbounded on that end.
+ const [committedDateFrom, setCommittedDateFrom] = useState("");
+ const [committedDateTo, setCommittedDateTo] = useState("");
+ const committedChannelsKey = useMemo(
+ () => Array.from(committedExcludedChannels).sort().join(" "),
+ [committedExcludedChannels],
+ );
+ const committedChannels = committedExcludedChannels;
+
+ // Query tree: draft (what the user is editing) vs. committed (what is
+ // currently being searched). `committedHash` is a fingerprint so React
+ // effects can dep on it without recomputing on identity churn.
+ const [draftRoot, setDraftRoot] = useState<GroupNode>(emptyRoot);
+ const [committedRoot, setCommittedRoot] = useState<GroupNode>(emptyRoot);
+ const draftHash = useMemo(() => canonicalHash(draftRoot), [draftRoot]);
+ const committedHash = useMemo(
+ () => canonicalHash(committedRoot),
+ [committedRoot],
+ );
+ const queryDirty = draftHash !== committedHash;
+
+ const [draftExcludedChannels, setDraftExcludedChannels] = useState<
+ Set<string>
+ >(() => new Set());
+ const [draftNov, setDraftNov] = useState(false);
+ const [draftNol, setDraftNol] = useState(false);
+ const [draftNaa, setDraftNaa] = useState(false);
+ const [draftNar, setDraftNar] = useState(false);
+ const [draftNav, setDraftNav] = useState(false);
+ const [draftNd, setDraftNd] = useState(false);
+ const [draftNu, setDraftNu] = useState(false);
+ const [draftDateFrom, setDraftDateFrom] = useState("");
+ const [draftDateTo, setDraftDateTo] = useState("");
+
+ const [hydrated, setHydrated] = useState(false);
+ const [profiles, setProfiles] = useState<Record<string, FilterSnapshot>>({});
+ const [activeProfileName, setActiveProfileName] = useState<string | null>(
+ null,
+ );
+ const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
+ () => new Set(),
+ );
+ const [filtersCollapsed, setFiltersCollapsed] = useState<boolean>(false);
+
+ // Results vs. chart view of the current search. `chartShape` is created
+ // lazily (never during the server pass) to keep deterministic-id hydration
+ // quiet — the same reason the QueryBuilder is gated behind `mounted`.
+ const [view, setView] = useState<"results" | "chart">("results");
+ const [chartShape, setChartShape] = useState<ChartShape | null>(null);
+
+ // Tracks whether the user has committed at least once this session. Used
+ // by the placeholder copy below — until the user has searched, we show a
+ // "tip" hint rather than the "no matching videos" empty state.
+ const [, setSearchExecuted] = useState<boolean>(() => {
+ if (typeof window === "undefined") return true;
+ const params = new URLSearchParams(window.location.search);
+ return !(
+ params.has("v") &&
+ !params.has("qt") &&
+ (params.get("q") ?? "") === ""
+ );
+ });
+
+ // Defer rendering of the QueryBuilder to the client. The builder's leaf
+ // IDs come from a module-scoped counter that's necessarily out of sync
+ // between the long-running server process and a fresh client module load,
+ // which would otherwise produce a hydration warning on every page load.
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+
+ // Data feeds come from the SearchData provider (single-site or, in hub mode,
+ // merged across origins) so this component is mode-agnostic. `channels` is the
+ // selection model keyed by a stable channelKey (= the channel name in single-
+ // site mode, so name-keyed filter state / share links / profiles round-trip).
+ const {
+ summariesState,
+ subsManifest,
+ channels: channelModel,
+ groups: manifestGroups,
+ channelKeyOf,
+ accentOf,
+ } = useSearchData();
+ const { summaries, loadedPages, pageCount, summariesReady } =
+ summariesState;
+ const transcripts: Summary[] | null = summariesReady ? summaries : null;
+
+ // We need subs manifests whenever any leaf has scope=chat OR a chat leaf
+ // could be added (we eagerly load when the tab existed in v1). For v2 we
+ // only load subs manifests when a chat leaf is actually in the tree.
+ const liveChatTotalCount = subsManifest?.liveChatTotalCount ?? 0;
+ const hasSubs = subsManifest !== null && liveChatTotalCount > 0;
+ const draftHasChatLeaf = useMemo(
+ () => anyLeafHasScope(draftRoot, "chat"),
+ [draftRoot],
+ );
+ const committedHasChatLeaf = useMemo(
+ () => anyLeafHasScope(committedRoot, "chat"),
+ [committedRoot],
+ );
+ const needsChatManifests = draftHasChatLeaf || committedHasChatLeaf;
+ // Origin-qualified refs. In single-site mode the manifest slugs are bare, so
+ // splitId yields {origin:"", channelSlug} and the refs (and the chat-scope
+ // ids built below) are byte-identical to before. In hub mode the merged
+ // manifest carries makeId(origin, slug) slugs, so each ref fetches from the
+ // right origin and the chat-scope ids match the origin-qualified summaries.
+ const subsRefs = useMemo(
+ () =>
+ subsManifest
+ ? subsManifest.channels.map((c) => {
+ const { origin, slug } = splitId(c.slug);
+ return { origin, channelSlug: slug };
+ })
+ : [],
+ [subsManifest],
+ );
+ const channelSubsQueries = useChannelSubsManifests(
+ needsChatManifests ? subsRefs : [],
+ );
+ // Set of origin-qualified (channelSlug/videoId) slugs that have live_chat
+ // content, resolved across all per-channel subs manifests.
+ const chatScopeSlugs = useMemo<Set<string>>(() => {
+ const set = new Set<string>();
+ if (!needsChatManifests) return set;
+ for (let i = 0; i < channelSubsQueries.length; i++) {
+ const q = channelSubsQueries[i];
+ const ref = subsRefs[i];
+ const data = q.data;
+ if (!data || !ref) continue;
+ for (const id of Object.keys(data.slugToPage)) {
+ set.add(makeId(ref.origin, `${ref.channelSlug}/${id}`));
+ }
+ }
+ return set;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ needsChatManifests,
+ subsRefs,
+ channelSubsQueries.map((q) => (q.data ? 1 : 0)).join(""),
+ ]);
+ const subsManifestReady =
+ !needsChatManifests ||
+ (subsManifest !== null &&
+ channelSubsQueries.length > 0 &&
+ channelSubsQueries.every((q) => q.data));
+
+ const [treeProgress, setTreeProgress] = useState<TreeProgress | null>(null);
+
+ // Advanced options — configurable from the UI, pre-filled with defaults.
+ const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS);
+ const [hitBatchUnlimited, setHitBatchUnlimited] = useState(false);
+ const [fetchConcurrency, setFetchConcurrency] = useState(
+ DEFAULT_FETCH_CONCURRENCY,
+ );
+ const [flushIntervalMs, setFlushIntervalMs] = useState(
+ DEFAULT_FLUSH_INTERVAL_MS,
+ );
+ const hitBatchSize = hitBatchUnlimited ? Infinity : hitBatchValue;
+
+ const [advancedHydrated, setAdvancedHydrated] = useState(false);
+ useEffect(() => {
+ if (advancedHydrated) return;
+ const snap = loadAdvanced();
+ if (snap) {
+ setHitBatchValue(snap.hitBatchValue);
+ setHitBatchUnlimited(snap.hitBatchUnlimited);
+ setFetchConcurrency(snap.fetchConcurrency);
+ setFlushIntervalMs(snap.flushIntervalMs);
+ }
+ setAdvancedHydrated(true);
+ }, [advancedHydrated]);
+
+ useEffect(() => {
+ if (!advancedHydrated) return;
+ saveAdvanced({
+ hitBatchValue,
+ hitBatchUnlimited,
+ fetchConcurrency,
+ flushIntervalMs,
+ });
+ }, [
+ advancedHydrated,
+ hitBatchValue,
+ hitBatchUnlimited,
+ fetchConcurrency,
+ flushIntervalMs,
+ ]);
+
+ const [hitLimit, setHitLimit] = useState(DEFAULT_MAX_HITS);
+ const pipelineRef = useRef<TreeController | null>(null);
+ const { openTranscript } = usePlayer();
+
+ // Reset the live hit cap on each new committed query.
+ useEffect(() => {
+ setHitLimit(hitBatchSize);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [committedHash]);
+
+ // ─── Channel-grouping setup ────────────────────────────────────────────────
+ // Selection identity is the stable channelKey from the provider's model. In
+ // single-site mode key === the channel name, so all name-keyed filter state,
+ // share links, and saved profiles round-trip exactly as before; in hub mode
+ // the key is origin-qualified so same-named channels from different sites
+ // don't collide. `channelOptions` holds keys; labels come from
+ // `channelLabelByKey` for display only.
+ const channelOptions = useMemo<string[]>(
+ () => channelModel.map((c) => c.key),
+ [channelModel],
+ );
+ const channelLabelByKey = useMemo<Map<string, string>>(
+ () => new Map(channelModel.map((c) => [c.key, c.name])),
+ [channelModel],
+ );
+
+ const channelGroupings = useMemo<
+ { group: ChannelGroup; channels: string[] }[]
+ >(() => {
+ if (channelModel.length === 0) return [];
+ const byId = new Map<string, string[]>();
+ for (const c of channelModel) {
+ const list = byId.get(c.groupId) ?? [];
+ list.push(c.key);
+ byId.set(c.groupId, list);
+ }
+ const labelOf = (k: string) => channelLabelByKey.get(k) ?? k;
+ const sorted = sortGroups(manifestGroups);
+ const out: { group: ChannelGroup; channels: string[] }[] = [];
+ for (const g of sorted) {
+ const keys = byId.get(g.id);
+ if (!keys || keys.length === 0) continue;
+ out.push({
+ group: g,
+ channels: keys
+ .slice()
+ .sort((a, b) => labelOf(a).localeCompare(labelOf(b))),
+ });
+ }
+ return out;
+ }, [channelModel, channelLabelByKey, manifestGroups]);
+
+ const defaultSelectedChannels = useMemo<Set<string>>(() => {
+ const out = new Set<string>();
+ const groupById = new Map(manifestGroups.map((g) => [g.id, g]));
+ for (const c of channelModel) {
+ if (groupById.get(c.groupId)?.selectedByDefault) out.add(c.key);
+ }
+ return out;
+ }, [channelModel, manifestGroups]);
+
+ const selectedChannelNames = useMemo(
+ () => channelOptions.filter((key) => !draftExcludedChannels.has(key)),
+ [channelOptions, draftExcludedChannels],
+ );
+ const selectedChannelsLabel = useMemo(
+ () =>
+ selectedChannelNames
+ .map((k) => channelLabelByKey.get(k) ?? k)
+ .join(", "),
+ [selectedChannelNames, channelLabelByKey],
+ );
+
+ const passesFilter = useMemo(() => {
+ return (t: Summary) => {
+ if (committedChannels.has(channelKeyOf(t))) return false;
+ if (committedDateFrom && t.uploadDate < committedDateFrom) return false;
+ if (committedDateTo && t.uploadDate > committedDateTo) return false;
+ if (t.isLivestream ? committedNol : committedNov) return false;
+ if (t.ageRestricted ? committedNar : committedNaa) return false;
+ // Availability is a three-way split: deleted / unlisted / available.
+ // Unlisted has its own bucket, so excluding "Available" no longer hides
+ // unlisted videos.
+ if (t.isDeleted) {
+ if (committedNd) return false;
+ } else if (t.isUnlisted) {
+ if (committedNu) return false;
+ } else if (committedNav) {
+ return false;
+ }
+ return true;
+ };
+ }, [
+ committedChannels,
+ channelKeyOf,
+ committedDateFrom,
+ committedDateTo,
+ committedNov,
+ committedNol,
+ committedNaa,
+ committedNar,
+ committedNav,
+ committedNd,
+ committedNu,
+ ]);
+
+ const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedNav ? 1 : 0}|${committedNd ? 1 : 0}|${committedNu ? 1 : 0}|${committedDateFrom}|${committedDateTo}`;
+
+ const globalScopeSlugs = useMemo<string[]>(() => {
+ if (!transcripts) return [];
+ const out: string[] = [];
+ for (const t of transcripts) if (passesFilter(t)) out.push(t.slug);
+ return out;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [transcripts, filterKey]);
+
+ const hasActiveQuery = useMemo(
+ () => isNodeActive(committedRoot),
+ [committedRoot],
+ );
+
+ // ─── Run the query tree ────────────────────────────────────────────────────
+ // Re-run whenever the committed root changes, filters change, or the
+ // chat-scope slug set finishes loading. hitLimit is *not* a dep — bumped
+ // via setHitLimit on the controller so "Show more" resumes without
+ // restarting traversal.
+ useEffect(() => {
+ pipelineRef.current?.cancel();
+ pipelineRef.current = null;
+
+ if (!hasActiveQuery) {
+ setTreeProgress(null);
+ return;
+ }
+ if (!transcripts) return;
+ if (needsChatManifests && !subsManifestReady) return;
+
+ const controller = runQueryTree({
+ root: committedRoot,
+ globalScope: globalScopeSlugs,
+ summaries: transcripts,
+ chatScopeSlugs: needsChatManifests ? chatScopeSlugs : null,
+ initialHitLimit: hitLimit,
+ concurrency: fetchConcurrency,
+ flushIntervalMs,
+ emit: (p) => setTreeProgress(p),
+ });
+ pipelineRef.current = controller;
+ return () => {
+ controller.cancel();
+ if (pipelineRef.current === controller) pipelineRef.current = null;
+ };
+ // hitLimit intentionally omitted — pushed via setHitLimit on the
+ // controller below. globalScopeSlugs identity already tracked via
+ // filterKey + transcripts. chatScopeSlugs identity ditto via
+ // chatScopeSlugsKey.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ committedHash,
+ transcripts,
+ filterKey,
+ needsChatManifests,
+ subsManifestReady,
+ fetchConcurrency,
+ flushIntervalMs,
+ ]);
+
+ useEffect(() => {
+ pipelineRef.current?.setHitLimit(hitLimit);
+ }, [hitLimit]);
+
+ // ─── Build the result list ────────────────────────────────────────────────
+ // Walk the summaries in their natural (newest-first) order, surfacing only
+ // those slugs that survived the query tree. For each, attach the per-leaf
+ // hits collected by searchEval. Hits are already sorted by start time
+ // within each video.
+ const resultGroups = useMemo<ResultGroup[]>(() => {
+ if (!transcripts) return [];
+ if (!hasActiveQuery) {
+ // Browse mode: list every video passing the committed filters, in the
+ // summaries' natural (newest-first) order, with no hits. No transcript
+ // fetches happen — all the card metadata is already in the summaries.
+ const out: ResultGroup[] = [];
+ for (const t of transcripts) {
+ if (!passesFilter(t)) continue;
+ out.push({
+ slug: t.slug,
+ title: t.title,
+ channel: t.channel,
+ date: t.date,
+ isLivestream: t.isLivestream,
+ ageRestricted: t.ageRestricted,
+ platform: t.platform,
+ uploadDate: t.uploadDate,
+ hits: [],
+ accent: accentOf(splitId(t.slug).origin),
+ });
+ }
+ return out;
+ }
+ if (!treeProgress) return [];
+ const matched = treeProgress.slugs;
+ const hitsBySlug = treeProgress.hits;
+ const out: ResultGroup[] = [];
+ for (const t of transcripts) {
+ if (!matched.has(t.slug)) continue;
+ out.push({
+ slug: t.slug,
+ title: t.title,
+ channel: t.channel,
+ date: t.date,
+ isLivestream: t.isLivestream,
+ ageRestricted: t.ageRestricted,
+ platform: t.platform,
+ uploadDate: t.uploadDate,
+ hits: hitsBySlug.get(t.slug) ?? [],
+ accent: accentOf(splitId(t.slug).origin),
+ });
+ }
+ return out;
+ }, [hasActiveQuery, transcripts, treeProgress, passesFilter, accentOf]);
+
+ const leafStates = treeProgress?.leafStates ?? new Map<string, LeafState>();
+ const groupStates =
+ treeProgress?.groupStates ?? new Map<string, GroupState>();
+ const totalHits = useMemo(() => {
+ let n = 0;
+ for (const list of treeProgress?.hits?.values() ?? []) n += list.length;
+ return n;
+ }, [treeProgress]);
+ const capped = treeProgress?.capped ?? false;
+ const pipelineDone = treeProgress?.done ?? !hasActiveQuery;
+ const totalToProcess = globalScopeSlugs.length;
+ const processed = useMemo(() => {
+ if (!treeProgress) return 0;
+ // For "1/N searched" the user really wants to know root-level progress.
+ // Use the maximum over all leaves' `processed` since AND chains evaluate
+ // sequentially and the bottleneck leaf is the front-runner indicator.
+ let max = 0;
+ for (const s of treeProgress.leafStates.values()) {
+ if (s.totalToProcess === 0) continue;
+ const frac = s.processed / s.totalToProcess;
+ max = Math.max(max, frac * totalToProcess);
+ }
+ return Math.round(max);
+ }, [treeProgress, totalToProcess]);
+
+ const indexLoading =
+ hasActiveQuery &&
+ (!summariesReady || (needsChatManifests && !subsManifestReady));
+ // Browse mode still needs the summaries before it can list anything.
+ const summariesLoading = !summariesReady;
+ const searching = hasActiveQuery && totalToProcess > 0;
+ const pipelineActive = searching && !pipelineDone;
+ const indexProgress =
+ pageCount > 0 ? `${loadedPages}/${pageCount} pages` : "";
+
+ const filtersDirty =
+ !sameSet(draftExcludedChannels, committedChannels) ||
+ draftNov !== committedNov ||
+ draftNol !== committedNol ||
+ draftNaa !== committedNaa ||
+ draftNar !== committedNar ||
+ draftNav !== committedNav ||
+ draftNd !== committedNd ||
+ draftNu !== committedNu ||
+ draftDateFrom !== committedDateFrom ||
+ draftDateTo !== committedDateTo;
+
+ const committedSnapshot = useMemo<FilterSnapshot>(() => {
+ const deltas = buildSnapshotDeltas(
+ committedChannels,
+ channelOptions,
+ defaultSelectedChannels,
+ );
+ const snap: FilterSnapshot = { channels: deltas };
+ if (committedNov) snap.nov = true;
+ if (committedNol) snap.nol = true;
+ if (committedNaa) snap.naa = true;
+ if (committedNar) snap.nar = true;
+ if (committedNav) snap.nav = true;
+ if (committedNd) snap.nd = true;
+ if (committedNu) snap.nu = true;
+ if (committedDateFrom) snap.dateFrom = committedDateFrom;
+ if (committedDateTo) snap.dateTo = committedDateTo;
+ snap.query = stringifyRoot(committedRoot);
+ return snap;
+ }, [
+ committedChannels,
+ channelOptions,
+ defaultSelectedChannels,
+ committedNov,
+ committedNol,
+ committedNaa,
+ committedNar,
+ committedNav,
+ committedNd,
+ committedNu,
+ committedDateFrom,
+ committedDateTo,
+ committedRoot,
+ ]);
+
+ const buildDraftSnapshot = useCallback((): FilterSnapshot => {
+ const snap: FilterSnapshot = {
+ channels: buildSnapshotDeltas(
+ draftExcludedChannels,
+ channelOptions,
+ defaultSelectedChannels,
+ ),
+ };
+ if (draftNov) snap.nov = true;
+ if (draftNol) snap.nol = true;
+ if (draftNaa) snap.naa = true;
+ if (draftNar) snap.nar = true;
+ if (draftNav) snap.nav = true;
+ if (draftNd) snap.nd = true;
+ if (draftNu) snap.nu = true;
+ if (draftDateFrom) snap.dateFrom = draftDateFrom;
+ if (draftDateTo) snap.dateTo = draftDateTo;
+ snap.query = stringifyRoot(draftRoot);
+ return snap;
+ }, [
+ draftExcludedChannels,
+ channelOptions,
+ defaultSelectedChannels,
+ draftNov,
+ draftNol,
+ draftNaa,
+ draftNar,
+ draftNav,
+ draftNd,
+ draftNu,
+ draftDateFrom,
+ draftDateTo,
+ draftRoot,
+ ]);
+
+ const promoteDraftsToCommitted = useCallback(() => {
+ setCommittedExcludedChannels(new Set(draftExcludedChannels));
+ setCommittedNov(draftNov);
+ setCommittedNol(draftNol);
+ setCommittedNaa(draftNaa);
+ setCommittedNar(draftNar);
+ setCommittedNav(draftNav);
+ setCommittedNd(draftNd);
+ setCommittedNu(draftNu);
+ setCommittedDateFrom(draftDateFrom);
+ setCommittedDateTo(draftDateTo);
+ stripAllFilterParamsFromUrl();
+ }, [
+ draftExcludedChannels,
+ draftNov,
+ draftNol,
+ draftNaa,
+ draftNar,
+ draftNav,
+ draftNd,
+ draftNu,
+ draftDateFrom,
+ draftDateTo,
+ ]);
+
+ // ─── Hydration ────────────────────────────────────────────────────────────
+ // Resolution order:
+ // 1. `qt=` in URL (modern composite-query share).
+ // 2. Legacy `q/m/re` URL (auto-migrate to a single-leaf root).
+ // 3. Share-link v1 schema (`?fv=1&fc=…`) for global filters only.
+ // 4. Legacy URL filter params (`?ch=&nov=…`).
+ // 5. Otherwise, active profile / working snapshot in localStorage.
+ useEffect(() => {
+ if (hydrated) return;
+ if (channelOptions.length === 0) return;
+ const stored = loadStoredState();
+ if (stored) {
+ setProfiles(stored.profiles);
+ setActiveProfileName(stored.activeProfileName);
+ setCollapsedGroups(new Set(stored.collapsedGroups ?? []));
+ if (typeof stored.filtersCollapsed === "boolean") {
+ setFiltersCollapsed(stored.filtersCollapsed);
+ }
+ }
+ const search = typeof window !== "undefined" ? window.location.search : "";
+ const params = new URLSearchParams(search);
+
+ // Query tree.
+ const qt = params.get("qt");
+ let rootFromUrl: GroupNode | null = null;
+ if (qt) {
+ rootFromUrl = parseRoot(qt);
+ } else if (legacyQ || legacyMode === "subs") {
+ rootFromUrl = rootFromLegacy(legacyQ, legacyMode, legacyRe);
+ }
+
+ // Global filters (share-v1 wins over legacy keys wins over snapshot).
+ let initialExcluded: Set<string> | null = null;
+ let initialNov = false;
+ let initialNol = false;
+ let initialNaa = false;
+ let initialNar = false;
+ let initialNav = false;
+ let initialNd = false;
+ let initialNu = false;
+ let initialDateFrom = "";
+ let initialDateTo = "";
+
+ if (hasShareV1(search)) {
+ const sel = parseShareV1(search, channelOptions);
+ const excluded = new Set<string>();
+ for (const name of channelOptions) {
+ if (!sel.selectedChannels.has(name)) excluded.add(name);
+ }
+ initialExcluded = excluded;
+ initialNov = !sel.videos;
+ initialNol = !sel.livestreams;
+ initialNaa = !sel.allAges;
+ initialNar = !sel.restricted;
+ initialNav = !sel.available;
+ initialNd = !sel.deleted;
+ initialNu = !sel.unlisted;
+ initialDateFrom = sel.dateFrom ?? "";
+ initialDateTo = sel.dateTo ?? "";
+ } else if (urlHasAnyFilterParam()) {
+ const excluded = new Set(params.getAll("ch"));
+ initialExcluded = excluded;
+ initialNov = params.get("nov") === "1";
+ initialNol = params.get("nol") === "1";
+ initialNaa = params.get("naa") === "1";
+ initialNar = params.get("nar") === "1";
+ initialNav = params.get("nav") === "1";
+ initialNd = params.get("nd") === "1";
+ initialNu = params.get("nu") === "1";
+ }
+
+ let resolvedRoot = rootFromUrl;
+ if (!initialExcluded) {
+ // Fall back to stored profile / working snapshot.
+ const snapshot =
+ stored?.activeProfileName != null
+ ? (stored.profiles[stored.activeProfileName] ?? stored.working)
+ : (stored?.working ?? null);
+ const excluded = snapshotToExcluded(
+ snapshot,
+ channelOptions,
+ defaultSelectedChannels,
+ );
+ initialExcluded = excluded;
+ initialNov = snapshot?.nov === true;
+ initialNol = snapshot?.nol === true;
+ initialNaa = snapshot?.naa === true;
+ initialNar = snapshot?.nar === true;
+ initialNav = snapshot?.nav === true;
+ initialNd = snapshot?.nd === true;
+ initialNu = snapshot?.nu === true;
+ initialDateFrom = snapshot?.dateFrom ?? "";
+ initialDateTo = snapshot?.dateTo ?? "";
+ if (!resolvedRoot && snapshot?.query) {
+ resolvedRoot = parseRoot(snapshot.query);
+ }
+ }
+
+ if (resolvedRoot) {
+ setDraftRoot(resolvedRoot);
+ setCommittedRoot(resolvedRoot);
+ setSearchExecuted(true);
+ }
+
+ setDraftExcludedChannels(initialExcluded);
+ setDraftNov(initialNov);
+ setDraftNol(initialNol);
+ setDraftNaa(initialNaa);
+ setDraftNar(initialNar);
+ setDraftNav(initialNav);
+ setDraftNd(initialNd);
+ setDraftNu(initialNu);
+ setDraftDateFrom(initialDateFrom);
+ setDraftDateTo(initialDateTo);
+ setCommittedExcludedChannels(initialExcluded);
+ setCommittedNov(initialNov);
+ setCommittedNol(initialNol);
+ setCommittedNaa(initialNaa);
+ setCommittedNar(initialNar);
+ setCommittedNav(initialNav);
+ setCommittedNd(initialNd);
+ setCommittedNu(initialNu);
+ setCommittedDateFrom(initialDateFrom);
+ setCommittedDateTo(initialDateTo);
+
+ // Chart view + shape (after query/filters so the default data source can
+ // key off whether a query was restored).
+ const csRaw = params.get("cs");
+ const wantsChart = params.get("view") === "chart";
+ if (csRaw) {
+ setChartShape(parseChartShape(csRaw));
+ } else if (wantsChart) {
+ const active = resolvedRoot ? isNodeActive(resolvedRoot) : false;
+ setChartShape(defaultChartShape(active ? "search" : "metadata"));
+ }
+ if (wantsChart) setView("chart");
+
+ setHydrated(true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [channelOptions.length === 0, hydrated]);
+
+ // Reflect the chart view + shape in the URL live (post-hydration only), so the
+ // address bar always carries the current chart — mirrors how qt= is written.
+ useEffect(() => {
+ if (!hydrated) return;
+ writeViewChartParams(view, view === "chart" ? chartShape : null);
+ }, [hydrated, view, chartShape]);
+
+ // Persist UI-only collapse state immediately on toggle. Loads + merges +
+ // saves so concurrent writes to other top-level fields aren't clobbered.
+ const persistUiCollapse = useCallback(
+ (
+ patch: Partial<Pick<StoredState, "collapsedGroups" | "filtersCollapsed">>,
+ ) => {
+ const current = loadStoredState() ?? emptyStoredState();
+ if (patch.collapsedGroups !== undefined) {
+ current.collapsedGroups = patch.collapsedGroups;
+ }
+ if (patch.filtersCollapsed !== undefined) {
+ current.filtersCollapsed = patch.filtersCollapsed;
+ }
+ saveStoredState(current);
+ },
+ [],
+ );
+
+ // React mounts <details open={…}> after the manifest loads — that transition
+ // fires a spurious `toggle` event whose handler can clobber the persisted
+ // state with the default "open" value. Guard the persist call so it only
+ // runs when the state actually changes.
+ const toggleGroupCollapsed = useCallback(
+ (groupId: string, isOpen: boolean) => {
+ setCollapsedGroups((prev) => {
+ const next = new Set(prev);
+ if (isOpen) next.delete(groupId);
+ else next.add(groupId);
+ if (
+ next.size === prev.size &&
+ Array.from(next).every((v) => prev.has(v))
+ ) {
+ return prev;
+ }
+ persistUiCollapse({ collapsedGroups: Array.from(next).sort() });
+ return next;
+ });
+ },
+ [persistUiCollapse],
+ );
+
+ const toggleFiltersCollapsed = useCallback(() => {
+ setFiltersCollapsed((prev) => {
+ const next = !prev;
+ persistUiCollapse({ filtersCollapsed: next });
+ return next;
+ });
+ }, [persistUiCollapse]);
+
+ const commitSearch = () => {
+ setSearchExecuted(true);
+ setCommittedRoot(draftRoot);
+ const nextSnapshot = buildDraftSnapshot();
+ const current = loadStoredState() ?? emptyStoredState();
+ current.working = nextSnapshot;
+ current.profiles = profiles;
+ // If the user committed changes that diverge from the active profile,
+ // drop the profile pointer. Otherwise hydration would prefer the
+ // (unchanged) profile snapshot on reload and the user's commit would
+ // silently revert.
+ const activeProfileSnap =
+ activeProfileName != null ? profiles[activeProfileName] : null;
+ const profileNowDirty =
+ activeProfileSnap != null &&
+ !snapshotsEqual(activeProfileSnap, nextSnapshot);
+ if (profileNowDirty) {
+ setActiveProfileName(null);
+ current.activeProfileName = null;
+ } else {
+ current.activeProfileName = activeProfileName;
+ }
+ saveStoredState(current);
+ promoteDraftsToCommitted();
+ // Write the URL: `qt=` is canonical. Strip legacy `q`/`m`/`re` so old
+ // params don't clobber the next reload's tree parse.
+ const serialized = stringifyRoot(draftRoot);
+ if (isNodeActive(draftRoot)) {
+ writeUrlParams({ q: "", re: false, mode: "transcripts" });
+ writeQtParam(serialized);
+ } else {
+ writeUrlParams({ q: "", re: false, mode: "transcripts" });
+ writeQtParam("");
+ }
+ };
+
+ const applyDraftSnapshot = useCallback(
+ (snapshot: FilterSnapshot | null) => {
+ const excluded = snapshotToExcluded(
+ snapshot,
+ channelOptions,
+ defaultSelectedChannels,
+ );
+ setDraftExcludedChannels(excluded);
+ setDraftNov(snapshot?.nov === true);
+ setDraftNol(snapshot?.nol === true);
+ setDraftNaa(snapshot?.naa === true);
+ setDraftNar(snapshot?.nar === true);
+ setDraftNav(snapshot?.nav === true);
+ setDraftNd(snapshot?.nd === true);
+ setDraftNu(snapshot?.nu === true);
+ setDraftDateFrom(snapshot?.dateFrom ?? "");
+ setDraftDateTo(snapshot?.dateTo ?? "");
+ if (snapshot?.query) {
+ const parsed = parseRoot(snapshot.query);
+ if (parsed) setDraftRoot(parsed);
+ }
+ },
+ [channelOptions, defaultSelectedChannels],
+ );
+
+ const applySnapshot = useCallback(
+ (snapshot: FilterSnapshot | null) => {
+ applyDraftSnapshot(snapshot);
+ const excluded = snapshotToExcluded(
+ snapshot,
+ channelOptions,
+ defaultSelectedChannels,
+ );
+ setCommittedExcludedChannels(excluded);
+ setCommittedNov(snapshot?.nov === true);
+ setCommittedNol(snapshot?.nol === true);
+ setCommittedNaa(snapshot?.naa === true);
+ setCommittedNar(snapshot?.nar === true);
+ setCommittedNav(snapshot?.nav === true);
+ setCommittedNd(snapshot?.nd === true);
+ setCommittedNu(snapshot?.nu === true);
+ setCommittedDateFrom(snapshot?.dateFrom ?? "");
+ setCommittedDateTo(snapshot?.dateTo ?? "");
+ if (snapshot?.query) {
+ const parsed = parseRoot(snapshot.query);
+ if (parsed) setCommittedRoot(parsed);
+ }
+ stripAllFilterParamsFromUrl();
+ },
+ [applyDraftSnapshot, channelOptions, defaultSelectedChannels],
+ );
+
+ const writeStorage = useCallback(
+ (mut: (s: StoredState) => void) => {
+ const current = loadStoredState() ?? emptyStoredState();
+ current.profiles = profiles;
+ current.activeProfileName = activeProfileName;
+ current.collapsedGroups = Array.from(collapsedGroups).sort();
+ mut(current);
+ saveStoredState(current);
+ },
+ [profiles, activeProfileName, collapsedGroups],
+ );
+
+ const handleLoadProfile = useCallback(
+ (name: string | "") => {
+ if (name === "") {
+ setActiveProfileName(null);
+ writeStorage((s) => {
+ s.activeProfileName = null;
+ });
+ const current = loadStoredState();
+ applySnapshot(current?.working ?? null);
+ return;
+ }
+ const snap = profiles[name];
+ if (!snap) return;
+ setActiveProfileName(name);
+ writeStorage((s) => {
+ s.activeProfileName = name;
+ s.working = { ...snap };
+ });
+ applySnapshot(snap);
+ },
+ [profiles, applySnapshot, writeStorage],
+ );
+
+ const handleSaveProfile = useCallback(() => {
+ if (!activeProfileName) return;
+ const snap = buildDraftSnapshot();
+ promoteDraftsToCommitted();
+ setProfiles((prev) => {
+ const next = { ...prev, [activeProfileName]: snap };
+ writeStorage((s) => {
+ s.profiles = next;
+ s.working = snap;
+ });
+ return next;
+ });
+ }, [
+ activeProfileName,
+ buildDraftSnapshot,
+ promoteDraftsToCommitted,
+ writeStorage,
+ ]);
+
+ const handleSaveAsProfile = useCallback(() => {
+ if (typeof window === "undefined") return;
+ const raw = window.prompt("Save current filters as a new profile named:");
+ if (raw == null) return;
+ const name = raw.trim();
+ if (!name) return;
+ if (Object.prototype.hasOwnProperty.call(profiles, name)) {
+ window.alert(`A profile named "${name}" already exists.`);
+ return;
+ }
+ const snap = buildDraftSnapshot();
+ promoteDraftsToCommitted();
+ setProfiles((prev) => {
+ const next = { ...prev, [name]: snap };
+ writeStorage((s) => {
+ s.profiles = next;
+ s.activeProfileName = name;
+ s.working = snap;
+ });
+ return next;
+ });
+ setActiveProfileName(name);
+ }, [profiles, buildDraftSnapshot, promoteDraftsToCommitted, writeStorage]);
+
+ const handleRenameProfile = useCallback(() => {
+ if (typeof window === "undefined") return;
+ if (!activeProfileName) return;
+ const raw = window.prompt("Rename profile:", activeProfileName);
+ if (raw == null) return;
+ const next = raw.trim();
+ if (!next || next === activeProfileName) return;
+ if (Object.prototype.hasOwnProperty.call(profiles, next)) {
+ window.alert(`A profile named "${next}" already exists.`);
+ return;
+ }
+ setProfiles((prev) => {
+ const copy: Record<string, FilterSnapshot> = {};
+ for (const [k, v] of Object.entries(prev)) {
+ copy[k === activeProfileName ? next : k] = v;
+ }
+ writeStorage((s) => {
+ s.profiles = copy;
+ s.activeProfileName = next;
+ });
+ return copy;
+ });
+ setActiveProfileName(next);
+ }, [activeProfileName, profiles, writeStorage]);
+
+ const handleDeleteProfile = useCallback(() => {
+ if (typeof window === "undefined") return;
+ if (!activeProfileName) return;
+ if (!window.confirm(`Delete profile "${activeProfileName}"?`)) return;
+ setProfiles((prev) => {
+ const copy = { ...prev };
+ delete copy[activeProfileName];
+ writeStorage((s) => {
+ s.profiles = copy;
+ s.activeProfileName = null;
+ });
+ return copy;
+ });
+ setActiveProfileName(null);
+ }, [activeProfileName, writeStorage]);
+
+ const profileDirty =
+ activeProfileName != null &&
+ profiles[activeProfileName] != null &&
+ !snapshotsEqual(profiles[activeProfileName], committedSnapshot);
+
+ const handleResetChannels = useCallback(() => {
+ const excluded = new Set<string>();
+ for (const name of channelOptions) {
+ if (!defaultSelectedChannels.has(name)) excluded.add(name);
+ }
+ setDraftExcludedChannels(excluded);
+ }, [channelOptions, defaultSelectedChannels]);
+
+ const handleResetAllFilters = useCallback(() => {
+ setActiveProfileName(null);
+ writeStorage((s) => {
+ s.working = emptySnapshot();
+ s.activeProfileName = null;
+ });
+ applyDraftSnapshot(null);
+ }, [applyDraftSnapshot, writeStorage]);
+
+ const handleResetLayers = useCallback(() => {
+ setDraftRoot(emptyRoot());
+ }, []);
+
+ const handleResetEverything = useCallback(() => {
+ handleResetLayers();
+ handleResetAllFilters();
+ }, [handleResetLayers, handleResetAllFilters]);
+
+ const handleRevertProfile = useCallback(() => {
+ if (!activeProfileName) return;
+ const snap = profiles[activeProfileName];
+ if (!snap) return;
+ applySnapshot(snap);
+ }, [activeProfileName, profiles, applySnapshot]);
+
+ const layersResetDisabled = draftHash === EMPTY_ROOT_HASH;
+
+ const [shareCopied, setShareCopied] = useState(false);
+ const shareResetRef = useRef<number | null>(null);
+ const handleShareCurrentSearch = useCallback(async () => {
+ const selectedChannels = new Set<string>();
+ for (const name of channelOptions) {
+ if (!committedExcludedChannels.has(name)) selectedChannels.add(name);
+ }
+ const selection: ShareSelection = {
+ selectedChannels,
+ videos: !committedNov,
+ livestreams: !committedNol,
+ allAges: !committedNaa,
+ restricted: !committedNar,
+ available: !committedNav,
+ deleted: !committedNd,
+ unlisted: !committedNu,
+ tracks: new Set(),
+ dateFrom: committedDateFrom || undefined,
+ dateTo: committedDateTo || undefined,
+ };
+ const params = buildShareSearchParams(selection, {
+ q: "",
+ mode: "transcripts",
+ regex: false,
+ });
+ if (isNodeActive(committedRoot)) {
+ params.set("qt", stringifyRoot(committedRoot));
+ }
+ // Carry the chart view + its shape so a shared link opens straight to the
+ // same chart (query/filters stay in qt= and the share params above).
+ if (view === "chart") {
+ params.set("view", "chart");
+ if (chartShape) params.set("cs", stringifyChartShape(chartShape));
+ }
+ const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
+ try {
+ await navigator.clipboard.writeText(url);
+ setShareCopied(true);
+ if (shareResetRef.current !== null) {
+ window.clearTimeout(shareResetRef.current);
+ }
+ shareResetRef.current = window.setTimeout(
+ () => setShareCopied(false),
+ 1800,
+ );
+ } catch {
+ /* clipboard unavailable */
+ }
+ }, [
+ channelOptions,
+ committedExcludedChannels,
+ committedNov,
+ committedNol,
+ committedNaa,
+ committedNar,
+ committedNav,
+ committedNd,
+ committedDateFrom,
+ committedDateTo,
+ committedRoot,
+ view,
+ chartShape,
+ ]);
+
+ // Switch to the chart view, creating the chart shape on first use. Defaults
+ // its data source to the current search (or metadata when there's no query).
+ const showChartView = useCallback(() => {
+ setChartShape((s) => s ?? defaultChartShape(hasActiveQuery ? "search" : "metadata"));
+ setView("chart");
+ }, [hasActiveQuery]);
+
+ const setSelected = useCallback(
+ (targets: ReadonlyArray<string>, selected: boolean) => {
+ setDraftExcludedChannels((prev) => {
+ const next = new Set(prev);
+ if (selected) {
+ for (const name of targets) next.delete(name);
+ } else {
+ for (const name of targets) next.add(name);
+ }
+ return next;
+ });
+ },
+ [],
+ );
+
+ const openWithMode = useCallback(
+ (slug: string, hit?: LayerHit) => {
+ const modalMode = hit?.scope === "chat" ? "chat" : "transcript";
+ openTranscript(slug, hit?.start, { mode: modalMode });
+ },
+ [openTranscript],
+ );
+
+ // Map of leafId → leaf (and its query) so result-row rendering can look
+ // up the original search terms for `<mark>` highlighting.
+ const leavesById = useMemo(() => {
+ const m = new Map<
+ string,
+ {
+ query: string;
+ useRegex: boolean;
+ scope: LayerScope;
+ }
+ >();
+ forEachLeaf(committedRoot, (leaf) =>
+ m.set(leaf.id, {
+ query: leaf.query,
+ useRegex: leaf.useRegex,
+ scope: leaf.scope,
+ }),
+ );
+ return m;
+ }, [committedRoot]);
+
+ const router = useRouter();
+ // Hand the current result set to the /ask chat as its grounding: serialize the
+ // matched videos + snippets, stash them for the chat to pick up, and navigate.
+ const askAboutResults = useCallback(() => {
+ const queries = Array.from(leavesById.values())
+ .map((l) => l.query.trim())
+ .filter(Boolean);
+ const bySlug = new Map(summaries.map((s) => [s.slug, s]));
+ const handoff = buildSearchHandoff(
+ resultGroups,
+ (slug) => {
+ const s = bySlug.get(slug);
+ return s ? { id: s.id, webpageUrl: s.webpageUrl } : undefined;
+ },
+ queries,
+ { searchCapped: capped },
+ );
+ if (handoff.videos.length === 0) return;
+ try {
+ sessionStorage.setItem(AI_HANDOFF_KEY, JSON.stringify(handoff));
+ } catch {
+ /* storage unavailable — the chat just won't receive the pin */
+ }
+ router.push("/ask/");
+ }, [leavesById, resultGroups, summaries, capped, router]);
+
+ const [resultsCopied, setResultsCopied] = useState(false);
+ const resultsCopiedResetRef = useRef<number | null>(null);
+ const copyResultsContext = useCallback(async () => {
+ const queries = Array.from(leavesById.values())
+ .map((l) => l.query.trim())
+ .filter(Boolean);
+ const md = buildResultsContextMarkdown(resultGroups, queries);
+ try {
+ await navigator.clipboard.writeText(md);
+ setResultsCopied(true);
+ if (resultsCopiedResetRef.current) {
+ window.clearTimeout(resultsCopiedResetRef.current);
+ }
+ resultsCopiedResetRef.current = window.setTimeout(
+ () => setResultsCopied(false),
+ 1500,
+ );
+ } catch {
+ /* clipboard blocked — no-op */
+ }
+ }, [leavesById, resultGroups]);
+
+ // Hits split by leaf, in the order leaves appear in the tree, so the UI
+ // sections render in a stable order regardless of the order findHitsInCues
+ // discovered them.
+ const orderedLeafIds = useMemo<string[]>(() => {
+ const ids: string[] = [];
+ forEachLeaf(committedRoot, (leaf) => {
+ if (leaf.contributeHits) ids.push(leaf.id);
+ });
+ return ids;
+ }, [committedRoot]);
+
+ // The chart plots the live search results when there's a query, otherwise the
+ // in-scope (filter-passing) videos. `effectiveShape` guards the null shape on
+ // the server pass / before lazy init.
+ const effectiveShape =
+ chartShape ?? defaultChartShape(hasActiveQuery ? "search" : "metadata");
+ const chartSlugs: Iterable<string> = hasActiveQuery
+ ? treeProgress?.slugs ?? []
+ : globalScopeSlugs;
+
+ return {
+ // ── Query builder / draft ──
+ draftRoot,
+ setDraftRoot,
+ committedRoot,
+ queryDirty,
+ filtersDirty,
+ mounted,
+ leafStates,
+ groupStates,
+ // ── Profiles row ──
+ profiles,
+ activeProfileName,
+ profileDirty,
+ shareCopied,
+ handleLoadProfile,
+ handleSaveProfile,
+ handleSaveAsProfile,
+ handleRenameProfile,
+ handleDeleteProfile,
+ handleRevertProfile,
+ handleShareCurrentSearch,
+ // ── Commit / resets ──
+ commitSearch,
+ handleResetLayers,
+ layersResetDisabled,
+ handleResetChannels,
+ handleResetAllFilters,
+ handleResetEverything,
+ // ── Live-chat hint ──
+ hasSubs,
+ liveChatTotalCount,
+ // ── Channel selection / filters (draft) ──
+ channelOptions,
+ channelLabelByKey,
+ channelGroupings,
+ collapsedGroups,
+ filtersCollapsed,
+ toggleFiltersCollapsed,
+ toggleGroupCollapsed,
+ selectedChannelNames,
+ selectedChannelsLabel,
+ draftExcludedChannels,
+ setDraftExcludedChannels,
+ setSelected,
+ draftNov,
+ setDraftNov,
+ draftNol,
+ setDraftNol,
+ draftNaa,
+ setDraftNaa,
+ draftNar,
+ setDraftNar,
+ draftNav,
+ setDraftNav,
+ draftNd,
+ setDraftNd,
+ draftNu,
+ setDraftNu,
+ draftDateFrom,
+ setDraftDateFrom,
+ draftDateTo,
+ setDraftDateTo,
+ // ── Advanced options ──
+ hitBatchValue,
+ setHitBatchValue,
+ hitBatchUnlimited,
+ setHitBatchUnlimited,
+ fetchConcurrency,
+ setFetchConcurrency,
+ flushIntervalMs,
+ setFlushIntervalMs,
+ hitBatchSize,
+ setHitLimit,
+ // ── Results ──
+ hasActiveQuery,
+ resultGroups,
+ totalHits,
+ capped,
+ indexLoading,
+ summariesLoading,
+ indexProgress,
+ searching,
+ processed,
+ totalToProcess,
+ pipelineActive,
+ view,
+ setView,
+ showChartView,
+ effectiveShape,
+ setChartShape,
+ chartSlugs,
+ treeProgress,
+ globalScopeSlugs,
+ leavesById,
+ orderedLeafIds,
+ activeVideo,
+ activeTime,
+ openWithMode,
+ summaries,
+ // ── Results → AI handoff (buttons live in the results header) ──
+ askAboutResults,
+ copyResultsContext,
+ resultsCopied,
+ };
+}
+
+export type SearchSessionValue = ReturnType<typeof useSearchSessionState>;
+
+const SearchSessionContext = createContext<SearchSessionValue | null>(null);
+
+// Owns the committed search for the whole workspace shell. Mount it once, under
+// a SearchData provider, so the bar + results (and navigation between `/` and
+// `/ask`) all read one session that survives route changes.
+export function SearchSessionProvider({ children }: { children: ReactNode }) {
+ const value = useSearchSessionState();
+ return (
+ <SearchSessionContext.Provider value={value}>
+ {children}
+ </SearchSessionContext.Provider>
+ );
+}
+
+export function useSearchSession(): SearchSessionValue {
+ const value = useContext(SearchSessionContext);
+ if (!value) {
+ throw new Error(
+ "useSearchSession must be used within a SearchSessionProvider",
+ );
+ }
+ return value;
+}
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -1,2613 +1,28 @@
"use client";
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useRouter } from "next/navigation";
-import { useWindowVirtualizer } from "@tanstack/react-virtual";
-import { usePlayer } from "./PlayerProvider";
-import { VirtualRow } from "./VirtualRow";
-import { useChannelSubsManifests } from "./subsCache";
-import { useSearchData } from "./SearchDataContext";
-import type { LayerHit } from "./searchPipeline";
-import {
- runQueryTree,
- type GroupState,
- type LeafState,
- type TreeController,
- type TreeProgress,
-} from "../lib/searchEval";
-import {
- canonicalHash,
- emptyRoot,
- forEachLeaf,
- isNodeActive,
- parseRoot,
- rootFromLegacy,
- stringifyRoot,
- type GroupNode,
- type LayerScope,
-} from "../lib/searchQuery";
-import { useUrlParams, writeUrlParams } from "./urlState";
-import {
- buildShareSearchParams,
- hasShareV1,
- parseShareV1,
- stripAllFilterParamsFromUrl,
- type ShareSelection,
-} from "./shareUrl";
-import {
- emptySnapshot,
- emptyStoredState,
- loadStoredState,
- saveStoredState,
- snapshotsEqual,
- type FilterSnapshot,
- type StoredState,
-} from "./exportFilterStorage";
-import {
- clearAdvanced,
- loadAdvanced,
- saveAdvanced,
-} from "./exportAdvancedStorage";
-import {
- AgeRestrictedBadge,
- LivestreamBadge,
- VodExpiredBadge,
-} from "./badges";
-import { vodExpiry } from "../lib/vodExpiry";
-import { Button } from "./ui/button";
-import { Checkbox } from "./ui/checkbox";
-import QueryBuilder from "./QueryBuilder";
-import {
- defaultChartShape,
- parseChartShape,
- stringifyChartShape,
- type ChartShape,
-} from "../lib/chartShare";
-import { ChartShapeControls } from "./charts/ChartShapeControls";
-import { SearchChartPanel } from "./charts/SearchChartPanel";
-import { LayerSwatch } from "./LayerSwatch";
-import { ymdToInput, inputToYmd } from "../lib/ymd";
-import type { DisplaySummary, Platform } from "../lib/transcripts";
-import { makeId, splitId } from "./originId";
-import { sortGroups, type ChannelGroup } from "../lib/channelGroups";
-import { AI_HANDOFF_KEY, buildSearchHandoff } from "../lib/aiHandoff";
-
-type Summary = DisplaySummary;
-
-type ResultGroup = {
- slug: string;
- title: string;
- channel: string;
- date: string;
- isLivestream: boolean;
- ageRestricted: boolean;
- // Carried through so the card can flag likely-expired stream VODs (Kick,
- // Twitch) via vodExpiry(platform, uploadDate).
- platform: Platform;
- uploadDate: string;
- hits: LayerHit[];
- // Provenance accent of the source origin (hub mode only); undefined
- // single-site, so no marker renders.
- accent?: string;
-};
-
-// Serialize the current result set as markdown to paste into an AI chat as
-// context: the search terms, then each matching video with its timestamped hit
-// snippets. Bounded so the clipboard payload stays reasonable.
-function buildResultsContextMarkdown(
- groups: ResultGroup[],
- queries: string[],
- opts: { maxVideos?: number; maxHitsPerVideo?: number } = {},
-): string {
- const maxVideos = opts.maxVideos ?? 30;
- const maxHits = opts.maxHitsPerVideo ?? 8;
- const hms = (s: number): string => {
- const n = Math.max(0, Math.floor(s));
- const h = Math.floor(n / 3600);
- const m = Math.floor((n % 3600) / 60);
- const ss = n % 60;
- const mm = String(m).padStart(2, "0");
- const sss = String(ss).padStart(2, "0");
- return h > 0 ? `${h}:${mm}:${sss}` : `${m}:${sss}`;
- };
- const lines: string[] = [];
- lines.push(
- queries.length
- ? `# Transcript search results for: ${queries.join(" AND ")}`
- : "# Transcript search results",
- );
- lines.push("");
- const shown = groups.slice(0, maxVideos);
- for (const g of shown) {
- lines.push(`## ${g.title} — ${g.channel}${g.date ? ` (${g.date})` : ""}`);
- const hits = g.hits.slice(0, maxHits);
- for (const h of hits) {
- const stamp = h.start > 0 ? `[${hms(h.start)}] ` : "";
- lines.push(`- ${stamp}${h.text.trim().replace(/\s+/g, " ")}`);
- }
- if (g.hits.length > hits.length) {
- lines.push(`- …and ${g.hits.length - hits.length} more hit(s)`);
- }
- lines.push("");
- }
- if (groups.length > shown.length) {
- lines.push(`_(showing ${shown.length} of ${groups.length} matching videos)_`);
- }
- return lines.join("\n") + "\n";
-}
-
-const DEFAULT_MAX_HITS = 500;
-const DEFAULT_FETCH_CONCURRENCY = 6;
-const DEFAULT_FLUSH_INTERVAL_MS = 120;
-// Rough first-paint guess for one full result card (header + a couple of
-// leaf sections + a handful of hits). After mount, ResizeObserver measures
-// the card's actual height and the virtualizer re-positions later cards.
-const CARD_ROW_ESTIMATE_PX = 120;
-
-// Canonical hash of a freshly-constructed empty root — used to disable
-// "Reset layers" when the draft is already at the empty default. Module-level
-// so we don't reallocate a node every render; `canonicalHash` strips IDs so
-// the value is stable.
-const EMPTY_ROOT_HASH = canonicalHash(emptyRoot());
-
-// Legacy URL keys still parsed for hydration (old share-links), but stripped
-// on the first commit so the modern `qt=` is canonical going forward.
-const FILTER_URL_KEYS = [
- "ch",
- "nov",
- "nol",
- "naa",
- "nar",
- "nav",
- "nd",
- "nu",
- "m",
- "tk",
-] as const;
-
-function urlHasAnyFilterParam(): boolean {
- if (typeof window === "undefined") return false;
- const p = new URLSearchParams(window.location.search);
- return FILTER_URL_KEYS.some((k) => p.has(k));
-}
-
-// Resolve a stored snapshot's per-channel deltas (plus group defaults) into
-// the concrete set of EXCLUDED channel names.
-function snapshotToExcluded(
- snapshot: FilterSnapshot | null,
- channelOptions: ReadonlyArray<string>,
- defaultSelectedChannels: ReadonlySet<string>,
-): Set<string> {
- const included = new Set(snapshot?.channels.included ?? []);
- const excluded = new Set(snapshot?.channels.excluded ?? []);
- const out = new Set<string>();
- for (const name of channelOptions) {
- if (included.has(name)) continue;
- if (excluded.has(name)) {
- out.add(name);
- continue;
- }
- if (!defaultSelectedChannels.has(name)) out.add(name);
- }
- return out;
-}
-
-function buildSnapshotDeltas(
- excludedSet: ReadonlySet<string>,
- channelOptions: ReadonlyArray<string>,
- defaultSelectedChannels: ReadonlySet<string>,
-): { included: string[]; excluded: string[] } {
- const included: string[] = [];
- const excluded: string[] = [];
- for (const name of channelOptions) {
- const isSelected = !excludedSet.has(name);
- const defaultSelected = defaultSelectedChannels.has(name);
- if (isSelected === defaultSelected) continue;
- if (isSelected) included.push(name);
- else excluded.push(name);
- }
- return { included: included.sort(), excluded: excluded.sort() };
-}
-
-function anyLeafHasScope(
- root: GroupNode,
- scope: LayerScope,
-): boolean {
- let found = false;
- forEachLeaf(root, (leaf) => {
- if (leaf.scope === scope && leaf.query.trim()) found = true;
- });
- return found;
-}
+// Backward-compatible, single-column composition of the search. The monolith
+// this file used to be has been split into three pieces that share one
+// SearchSession:
+// • SearchSessionContext — the committed search state + pipeline + URL sync,
+// • WorkspaceSearchBar — the query-builder form + filters (the input half),
+// • SearchResults — the results / chart view (the output half).
+//
+// The workspace shell mounts those pieces SEPARATELY (bar in the layout, results
+// in the page) so the bar persists across `/` ⇄ `/ask`. This component keeps the
+// old "everything stacked in one place" shape for callers that still want it —
+// notably hub mode's <HubHome/>, which drops it inside a MultiSiteDataProvider.
+
+import { SearchSessionProvider } from "./SearchSessionContext";
+import WorkspaceSearchBar from "./WorkspaceSearchBar";
+import SearchResults from "./SearchResults";
export default function TranscriptSearch() {
- const {
- q: legacyQ,
- re: legacyRe,
- v: activeVideo,
- t: activeTime,
- mode: legacyMode,
- } = useUrlParams();
-
- // Committed filter state lives in React (not the URL) since filters are
- // no longer auto-tracked.
- const [committedExcludedChannels, setCommittedExcludedChannels] = useState<
- Set<string>
- >(() => new Set());
- const [committedNov, setCommittedNov] = useState(false);
- const [committedNol, setCommittedNol] = useState(false);
- const [committedNaa, setCommittedNaa] = useState(false);
- const [committedNar, setCommittedNar] = useState(false);
- const [committedNav, setCommittedNav] = useState(false);
- const [committedNd, setCommittedNd] = useState(false);
- const [committedNu, setCommittedNu] = useState(false);
- // Inclusive upload-date bounds ("YYYYMMDD"); "" => unbounded on that end.
- const [committedDateFrom, setCommittedDateFrom] = useState("");
- const [committedDateTo, setCommittedDateTo] = useState("");
- const committedChannelsKey = useMemo(
- () => Array.from(committedExcludedChannels).sort().join(" "),
- [committedExcludedChannels],
- );
- const committedChannels = committedExcludedChannels;
-
- // Query tree: draft (what the user is editing) vs. committed (what is
- // currently being searched). `committedHash` is a fingerprint so React
- // effects can dep on it without recomputing on identity churn.
- const [draftRoot, setDraftRoot] = useState<GroupNode>(emptyRoot);
- const [committedRoot, setCommittedRoot] = useState<GroupNode>(emptyRoot);
- const draftHash = useMemo(() => canonicalHash(draftRoot), [draftRoot]);
- const committedHash = useMemo(
- () => canonicalHash(committedRoot),
- [committedRoot],
- );
- const queryDirty = draftHash !== committedHash;
-
- const [draftExcludedChannels, setDraftExcludedChannels] = useState<
- Set<string>
- >(() => new Set());
- const [draftNov, setDraftNov] = useState(false);
- const [draftNol, setDraftNol] = useState(false);
- const [draftNaa, setDraftNaa] = useState(false);
- const [draftNar, setDraftNar] = useState(false);
- const [draftNav, setDraftNav] = useState(false);
- const [draftNd, setDraftNd] = useState(false);
- const [draftNu, setDraftNu] = useState(false);
- const [draftDateFrom, setDraftDateFrom] = useState("");
- const [draftDateTo, setDraftDateTo] = useState("");
-
- const [hydrated, setHydrated] = useState(false);
- const [profiles, setProfiles] = useState<Record<string, FilterSnapshot>>({});
- const [activeProfileName, setActiveProfileName] = useState<string | null>(
- null,
- );
- const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
- () => new Set(),
- );
- const [filtersCollapsed, setFiltersCollapsed] = useState<boolean>(false);
-
- // Results vs. chart view of the current search. `chartShape` is created
- // lazily (never during the server pass) to keep deterministic-id hydration
- // quiet — the same reason the QueryBuilder is gated behind `mounted`.
- const [view, setView] = useState<"results" | "chart">("results");
- const [chartShape, setChartShape] = useState<ChartShape | null>(null);
-
- // Tracks whether the user has committed at least once this session. Used
- // by the placeholder copy below — until the user has searched, we show a
- // "tip" hint rather than the "no matching videos" empty state.
- const [, setSearchExecuted] = useState<boolean>(() => {
- if (typeof window === "undefined") return true;
- const params = new URLSearchParams(window.location.search);
- return !(
- params.has("v") &&
- !params.has("qt") &&
- (params.get("q") ?? "") === ""
- );
- });
-
- // Defer rendering of the QueryBuilder to the client. The builder's leaf
- // IDs come from a module-scoped counter that's necessarily out of sync
- // between the long-running server process and a fresh client module load,
- // which would otherwise produce a hydration warning on every page load.
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
-
- // Data feeds come from the SearchData provider (single-site or, in hub mode,
- // merged across origins) so this component is mode-agnostic. `channels` is the
- // selection model keyed by a stable channelKey (= the channel name in single-
- // site mode, so name-keyed filter state / share links / profiles round-trip).
- const {
- summariesState,
- subsManifest,
- channels: channelModel,
- groups: manifestGroups,
- channelKeyOf,
- accentOf,
- } = useSearchData();
- const { summaries, loadedPages, pageCount, summariesReady } =
- summariesState;
- const transcripts: Summary[] | null = summariesReady ? summaries : null;
-
- // We need subs manifests whenever any leaf has scope=chat OR a chat leaf
- // could be added (we eagerly load when the tab existed in v1). For v2 we
- // only load subs manifests when a chat leaf is actually in the tree.
- const liveChatTotalCount = subsManifest?.liveChatTotalCount ?? 0;
- const hasSubs = subsManifest !== null && liveChatTotalCount > 0;
- const draftHasChatLeaf = useMemo(
- () => anyLeafHasScope(draftRoot, "chat"),
- [draftRoot],
- );
- const committedHasChatLeaf = useMemo(
- () => anyLeafHasScope(committedRoot, "chat"),
- [committedRoot],
- );
- const needsChatManifests = draftHasChatLeaf || committedHasChatLeaf;
- // Origin-qualified refs. In single-site mode the manifest slugs are bare, so
- // splitId yields {origin:"", channelSlug} and the refs (and the chat-scope
- // ids built below) are byte-identical to before. In hub mode the merged
- // manifest carries makeId(origin, slug) slugs, so each ref fetches from the
- // right origin and the chat-scope ids match the origin-qualified summaries.
- const subsRefs = useMemo(
- () =>
- subsManifest
- ? subsManifest.channels.map((c) => {
- const { origin, slug } = splitId(c.slug);
- return { origin, channelSlug: slug };
- })
- : [],
- [subsManifest],
- );
- const channelSubsQueries = useChannelSubsManifests(
- needsChatManifests ? subsRefs : [],
- );
- // Set of origin-qualified (channelSlug/videoId) slugs that have live_chat
- // content, resolved across all per-channel subs manifests.
- const chatScopeSlugs = useMemo<Set<string>>(() => {
- const set = new Set<string>();
- if (!needsChatManifests) return set;
- for (let i = 0; i < channelSubsQueries.length; i++) {
- const q = channelSubsQueries[i];
- const ref = subsRefs[i];
- const data = q.data;
- if (!data || !ref) continue;
- for (const id of Object.keys(data.slugToPage)) {
- set.add(makeId(ref.origin, `${ref.channelSlug}/${id}`));
- }
- }
- return set;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [
- needsChatManifests,
- subsRefs,
- channelSubsQueries.map((q) => (q.data ? 1 : 0)).join(""),
- ]);
- const subsManifestReady =
- !needsChatManifests ||
- (subsManifest !== null &&
- channelSubsQueries.length > 0 &&
- channelSubsQueries.every((q) => q.data));
-
- const [treeProgress, setTreeProgress] = useState<TreeProgress | null>(null);
-
- // Advanced options — configurable from the UI, pre-filled with defaults.
- const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS);
- const [hitBatchUnlimited, setHitBatchUnlimited] = useState(false);
- const [fetchConcurrency, setFetchConcurrency] = useState(
- DEFAULT_FETCH_CONCURRENCY,
- );
- const [flushIntervalMs, setFlushIntervalMs] = useState(
- DEFAULT_FLUSH_INTERVAL_MS,
- );
- const hitBatchSize = hitBatchUnlimited ? Infinity : hitBatchValue;
-
- const [advancedHydrated, setAdvancedHydrated] = useState(false);
- useEffect(() => {
- if (advancedHydrated) return;
- const snap = loadAdvanced();
- if (snap) {
- setHitBatchValue(snap.hitBatchValue);
- setHitBatchUnlimited(snap.hitBatchUnlimited);
- setFetchConcurrency(snap.fetchConcurrency);
- setFlushIntervalMs(snap.flushIntervalMs);
- }
- setAdvancedHydrated(true);
- }, [advancedHydrated]);
-
- useEffect(() => {
- if (!advancedHydrated) return;
- saveAdvanced({
- hitBatchValue,
- hitBatchUnlimited,
- fetchConcurrency,
- flushIntervalMs,
- });
- }, [
- advancedHydrated,
- hitBatchValue,
- hitBatchUnlimited,
- fetchConcurrency,
- flushIntervalMs,
- ]);
-
- const [hitLimit, setHitLimit] = useState(DEFAULT_MAX_HITS);
- const pipelineRef = useRef<TreeController | null>(null);
- const { openTranscript } = usePlayer();
-
- // Reset the live hit cap on each new committed query.
- useEffect(() => {
- setHitLimit(hitBatchSize);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [committedHash]);
-
- // ─── Channel-grouping setup ────────────────────────────────────────────────
- // Selection identity is the stable channelKey from the provider's model. In
- // single-site mode key === the channel name, so all name-keyed filter state,
- // share links, and saved profiles round-trip exactly as before; in hub mode
- // the key is origin-qualified so same-named channels from different sites
- // don't collide. `channelOptions` holds keys; labels come from
- // `channelLabelByKey` for display only.
- const channelOptions = useMemo<string[]>(
- () => channelModel.map((c) => c.key),
- [channelModel],
- );
- const channelLabelByKey = useMemo<Map<string, string>>(
- () => new Map(channelModel.map((c) => [c.key, c.name])),
- [channelModel],
- );
-
- const channelGroupings = useMemo<
- { group: ChannelGroup; channels: string[] }[]
- >(() => {
- if (channelModel.length === 0) return [];
- const byId = new Map<string, string[]>();
- for (const c of channelModel) {
- const list = byId.get(c.groupId) ?? [];
- list.push(c.key);
- byId.set(c.groupId, list);
- }
- const labelOf = (k: string) => channelLabelByKey.get(k) ?? k;
- const sorted = sortGroups(manifestGroups);
- const out: { group: ChannelGroup; channels: string[] }[] = [];
- for (const g of sorted) {
- const keys = byId.get(g.id);
- if (!keys || keys.length === 0) continue;
- out.push({
- group: g,
- channels: keys
- .slice()
- .sort((a, b) => labelOf(a).localeCompare(labelOf(b))),
- });
- }
- return out;
- }, [channelModel, channelLabelByKey, manifestGroups]);
-
- const defaultSelectedChannels = useMemo<Set<string>>(() => {
- const out = new Set<string>();
- const groupById = new Map(manifestGroups.map((g) => [g.id, g]));
- for (const c of channelModel) {
- if (groupById.get(c.groupId)?.selectedByDefault) out.add(c.key);
- }
- return out;
- }, [channelModel, manifestGroups]);
-
- const selectedChannelNames = useMemo(
- () => channelOptions.filter((key) => !draftExcludedChannels.has(key)),
- [channelOptions, draftExcludedChannels],
- );
- const selectedChannelsLabel = useMemo(
- () =>
- selectedChannelNames
- .map((k) => channelLabelByKey.get(k) ?? k)
- .join(", "),
- [selectedChannelNames, channelLabelByKey],
- );
-
- const passesFilter = useMemo(() => {
- return (t: Summary) => {
- if (committedChannels.has(channelKeyOf(t))) return false;
- if (committedDateFrom && t.uploadDate < committedDateFrom) return false;
- if (committedDateTo && t.uploadDate > committedDateTo) return false;
- if (t.isLivestream ? committedNol : committedNov) return false;
- if (t.ageRestricted ? committedNar : committedNaa) return false;
- // Availability is a three-way split: deleted / unlisted / available.
- // Unlisted has its own bucket, so excluding "Available" no longer hides
- // unlisted videos.
- if (t.isDeleted) {
- if (committedNd) return false;
- } else if (t.isUnlisted) {
- if (committedNu) return false;
- } else if (committedNav) {
- return false;
- }
- return true;
- };
- }, [
- committedChannels,
- channelKeyOf,
- committedDateFrom,
- committedDateTo,
- committedNov,
- committedNol,
- committedNaa,
- committedNar,
- committedNav,
- committedNd,
- committedNu,
- ]);
-
- const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedNav ? 1 : 0}|${committedNd ? 1 : 0}|${committedNu ? 1 : 0}|${committedDateFrom}|${committedDateTo}`;
-
- const globalScopeSlugs = useMemo<string[]>(() => {
- if (!transcripts) return [];
- const out: string[] = [];
- for (const t of transcripts) if (passesFilter(t)) out.push(t.slug);
- return out;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [transcripts, filterKey]);
-
- const hasActiveQuery = useMemo(
- () => isNodeActive(committedRoot),
- [committedRoot],
- );
-
- // ─── Run the query tree ────────────────────────────────────────────────────
- // Re-run whenever the committed root changes, filters change, or the
- // chat-scope slug set finishes loading. hitLimit is *not* a dep — bumped
- // via setHitLimit on the controller so "Show more" resumes without
- // restarting traversal.
- useEffect(() => {
- pipelineRef.current?.cancel();
- pipelineRef.current = null;
-
- if (!hasActiveQuery) {
- setTreeProgress(null);
- return;
- }
- if (!transcripts) return;
- if (needsChatManifests && !subsManifestReady) return;
-
- const controller = runQueryTree({
- root: committedRoot,
- globalScope: globalScopeSlugs,
- summaries: transcripts,
- chatScopeSlugs: needsChatManifests ? chatScopeSlugs : null,
- initialHitLimit: hitLimit,
- concurrency: fetchConcurrency,
- flushIntervalMs,
- emit: (p) => setTreeProgress(p),
- });
- pipelineRef.current = controller;
- return () => {
- controller.cancel();
- if (pipelineRef.current === controller) pipelineRef.current = null;
- };
- // hitLimit intentionally omitted — pushed via setHitLimit on the
- // controller below. globalScopeSlugs identity already tracked via
- // filterKey + transcripts. chatScopeSlugs identity ditto via
- // chatScopeSlugsKey.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [
- committedHash,
- transcripts,
- filterKey,
- needsChatManifests,
- subsManifestReady,
- fetchConcurrency,
- flushIntervalMs,
- ]);
-
- useEffect(() => {
- pipelineRef.current?.setHitLimit(hitLimit);
- }, [hitLimit]);
-
- // ─── Build the result list ────────────────────────────────────────────────
- // Walk the summaries in their natural (newest-first) order, surfacing only
- // those slugs that survived the query tree. For each, attach the per-leaf
- // hits collected by searchEval. Hits are already sorted by start time
- // within each video.
- const resultGroups = useMemo<ResultGroup[]>(() => {
- if (!transcripts) return [];
- if (!hasActiveQuery) {
- // Browse mode: list every video passing the committed filters, in the
- // summaries' natural (newest-first) order, with no hits. No transcript
- // fetches happen — all the card metadata is already in the summaries.
- const out: ResultGroup[] = [];
- for (const t of transcripts) {
- if (!passesFilter(t)) continue;
- out.push({
- slug: t.slug,
- title: t.title,
- channel: t.channel,
- date: t.date,
- isLivestream: t.isLivestream,
- ageRestricted: t.ageRestricted,
- platform: t.platform,
- uploadDate: t.uploadDate,
- hits: [],
- accent: accentOf(splitId(t.slug).origin),
- });
- }
- return out;
- }
- if (!treeProgress) return [];
- const matched = treeProgress.slugs;
- const hitsBySlug = treeProgress.hits;
- const out: ResultGroup[] = [];
- for (const t of transcripts) {
- if (!matched.has(t.slug)) continue;
- out.push({
- slug: t.slug,
- title: t.title,
- channel: t.channel,
- date: t.date,
- isLivestream: t.isLivestream,
- ageRestricted: t.ageRestricted,
- platform: t.platform,
- uploadDate: t.uploadDate,
- hits: hitsBySlug.get(t.slug) ?? [],
- accent: accentOf(splitId(t.slug).origin),
- });
- }
- return out;
- }, [hasActiveQuery, transcripts, treeProgress, passesFilter, accentOf]);
-
- const leafStates = treeProgress?.leafStates ?? new Map<string, LeafState>();
- const groupStates =
- treeProgress?.groupStates ?? new Map<string, GroupState>();
- const totalHits = useMemo(() => {
- let n = 0;
- for (const list of treeProgress?.hits?.values() ?? []) n += list.length;
- return n;
- }, [treeProgress]);
- const capped = treeProgress?.capped ?? false;
- const pipelineDone = treeProgress?.done ?? !hasActiveQuery;
- const totalToProcess = globalScopeSlugs.length;
- const processed = useMemo(() => {
- if (!treeProgress) return 0;
- // For "1/N searched" the user really wants to know root-level progress.
- // Use the maximum over all leaves' `processed` since AND chains evaluate
- // sequentially and the bottleneck leaf is the front-runner indicator.
- let max = 0;
- for (const s of treeProgress.leafStates.values()) {
- if (s.totalToProcess === 0) continue;
- const frac = s.processed / s.totalToProcess;
- max = Math.max(max, frac * totalToProcess);
- }
- return Math.round(max);
- }, [treeProgress, totalToProcess]);
-
- const indexLoading =
- hasActiveQuery &&
- (!summariesReady || (needsChatManifests && !subsManifestReady));
- // Browse mode still needs the summaries before it can list anything.
- const summariesLoading = !summariesReady;
- const searching = hasActiveQuery && totalToProcess > 0;
- const pipelineActive = searching && !pipelineDone;
- const indexProgress =
- pageCount > 0 ? `${loadedPages}/${pageCount} pages` : "";
-
- const filtersDirty =
- !sameSet(draftExcludedChannels, committedChannels) ||
- draftNov !== committedNov ||
- draftNol !== committedNol ||
- draftNaa !== committedNaa ||
- draftNar !== committedNar ||
- draftNav !== committedNav ||
- draftNd !== committedNd ||
- draftNu !== committedNu ||
- draftDateFrom !== committedDateFrom ||
- draftDateTo !== committedDateTo;
-
- const committedSnapshot = useMemo<FilterSnapshot>(() => {
- const deltas = buildSnapshotDeltas(
- committedChannels,
- channelOptions,
- defaultSelectedChannels,
- );
- const snap: FilterSnapshot = { channels: deltas };
- if (committedNov) snap.nov = true;
- if (committedNol) snap.nol = true;
- if (committedNaa) snap.naa = true;
- if (committedNar) snap.nar = true;
- if (committedNav) snap.nav = true;
- if (committedNd) snap.nd = true;
- if (committedNu) snap.nu = true;
- if (committedDateFrom) snap.dateFrom = committedDateFrom;
- if (committedDateTo) snap.dateTo = committedDateTo;
- snap.query = stringifyRoot(committedRoot);
- return snap;
- }, [
- committedChannels,
- channelOptions,
- defaultSelectedChannels,
- committedNov,
- committedNol,
- committedNaa,
- committedNar,
- committedNav,
- committedNd,
- committedNu,
- committedDateFrom,
- committedDateTo,
- committedRoot,
- ]);
-
- const buildDraftSnapshot = useCallback((): FilterSnapshot => {
- const snap: FilterSnapshot = {
- channels: buildSnapshotDeltas(
- draftExcludedChannels,
- channelOptions,
- defaultSelectedChannels,
- ),
- };
- if (draftNov) snap.nov = true;
- if (draftNol) snap.nol = true;
- if (draftNaa) snap.naa = true;
- if (draftNar) snap.nar = true;
- if (draftNav) snap.nav = true;
- if (draftNd) snap.nd = true;
- if (draftNu) snap.nu = true;
- if (draftDateFrom) snap.dateFrom = draftDateFrom;
- if (draftDateTo) snap.dateTo = draftDateTo;
- snap.query = stringifyRoot(draftRoot);
- return snap;
- }, [
- draftExcludedChannels,
- channelOptions,
- defaultSelectedChannels,
- draftNov,
- draftNol,
- draftNaa,
- draftNar,
- draftNav,
- draftNd,
- draftNu,
- draftDateFrom,
- draftDateTo,
- draftRoot,
- ]);
-
- const promoteDraftsToCommitted = useCallback(() => {
- setCommittedExcludedChannels(new Set(draftExcludedChannels));
- setCommittedNov(draftNov);
- setCommittedNol(draftNol);
- setCommittedNaa(draftNaa);
- setCommittedNar(draftNar);
- setCommittedNav(draftNav);
- setCommittedNd(draftNd);
- setCommittedNu(draftNu);
- setCommittedDateFrom(draftDateFrom);
- setCommittedDateTo(draftDateTo);
- stripAllFilterParamsFromUrl();
- }, [
- draftExcludedChannels,
- draftNov,
- draftNol,
- draftNaa,
- draftNar,
- draftNav,
- draftNd,
- draftNu,
- draftDateFrom,
- draftDateTo,
- ]);
-
- // ─── Hydration ────────────────────────────────────────────────────────────
- // Resolution order:
- // 1. `qt=` in URL (modern composite-query share).
- // 2. Legacy `q/m/re` URL (auto-migrate to a single-leaf root).
- // 3. Share-link v1 schema (`?fv=1&fc=…`) for global filters only.
- // 4. Legacy URL filter params (`?ch=&nov=…`).
- // 5. Otherwise, active profile / working snapshot in localStorage.
- useEffect(() => {
- if (hydrated) return;
- if (channelOptions.length === 0) return;
- const stored = loadStoredState();
- if (stored) {
- setProfiles(stored.profiles);
- setActiveProfileName(stored.activeProfileName);
- setCollapsedGroups(new Set(stored.collapsedGroups ?? []));
- if (typeof stored.filtersCollapsed === "boolean") {
- setFiltersCollapsed(stored.filtersCollapsed);
- }
- }
- const search = typeof window !== "undefined" ? window.location.search : "";
- const params = new URLSearchParams(search);
-
- // Query tree.
- const qt = params.get("qt");
- let rootFromUrl: GroupNode | null = null;
- if (qt) {
- rootFromUrl = parseRoot(qt);
- } else if (legacyQ || legacyMode === "subs") {
- rootFromUrl = rootFromLegacy(legacyQ, legacyMode, legacyRe);
- }
-
- // Global filters (share-v1 wins over legacy keys wins over snapshot).
- let initialExcluded: Set<string> | null = null;
- let initialNov = false;
- let initialNol = false;
- let initialNaa = false;
- let initialNar = false;
- let initialNav = false;
- let initialNd = false;
- let initialNu = false;
- let initialDateFrom = "";
- let initialDateTo = "";
-
- if (hasShareV1(search)) {
- const sel = parseShareV1(search, channelOptions);
- const excluded = new Set<string>();
- for (const name of channelOptions) {
- if (!sel.selectedChannels.has(name)) excluded.add(name);
- }
- initialExcluded = excluded;
- initialNov = !sel.videos;
- initialNol = !sel.livestreams;
- initialNaa = !sel.allAges;
- initialNar = !sel.restricted;
- initialNav = !sel.available;
- initialNd = !sel.deleted;
- initialNu = !sel.unlisted;
- initialDateFrom = sel.dateFrom ?? "";
- initialDateTo = sel.dateTo ?? "";
- } else if (urlHasAnyFilterParam()) {
- const excluded = new Set(params.getAll("ch"));
- initialExcluded = excluded;
- initialNov = params.get("nov") === "1";
- initialNol = params.get("nol") === "1";
- initialNaa = params.get("naa") === "1";
- initialNar = params.get("nar") === "1";
- initialNav = params.get("nav") === "1";
- initialNd = params.get("nd") === "1";
- initialNu = params.get("nu") === "1";
- }
-
- let resolvedRoot = rootFromUrl;
- if (!initialExcluded) {
- // Fall back to stored profile / working snapshot.
- const snapshot =
- stored?.activeProfileName != null
- ? (stored.profiles[stored.activeProfileName] ?? stored.working)
- : (stored?.working ?? null);
- const excluded = snapshotToExcluded(
- snapshot,
- channelOptions,
- defaultSelectedChannels,
- );
- initialExcluded = excluded;
- initialNov = snapshot?.nov === true;
- initialNol = snapshot?.nol === true;
- initialNaa = snapshot?.naa === true;
- initialNar = snapshot?.nar === true;
- initialNav = snapshot?.nav === true;
- initialNd = snapshot?.nd === true;
- initialNu = snapshot?.nu === true;
- initialDateFrom = snapshot?.dateFrom ?? "";
- initialDateTo = snapshot?.dateTo ?? "";
- if (!resolvedRoot && snapshot?.query) {
- resolvedRoot = parseRoot(snapshot.query);
- }
- }
-
- if (resolvedRoot) {
- setDraftRoot(resolvedRoot);
- setCommittedRoot(resolvedRoot);
- setSearchExecuted(true);
- }
-
- setDraftExcludedChannels(initialExcluded);
- setDraftNov(initialNov);
- setDraftNol(initialNol);
- setDraftNaa(initialNaa);
- setDraftNar(initialNar);
- setDraftNav(initialNav);
- setDraftNd(initialNd);
- setDraftNu(initialNu);
- setDraftDateFrom(initialDateFrom);
- setDraftDateTo(initialDateTo);
- setCommittedExcludedChannels(initialExcluded);
- setCommittedNov(initialNov);
- setCommittedNol(initialNol);
- setCommittedNaa(initialNaa);
- setCommittedNar(initialNar);
- setCommittedNav(initialNav);
- setCommittedNd(initialNd);
- setCommittedNu(initialNu);
- setCommittedDateFrom(initialDateFrom);
- setCommittedDateTo(initialDateTo);
-
- // Chart view + shape (after query/filters so the default data source can
- // key off whether a query was restored).
- const csRaw = params.get("cs");
- const wantsChart = params.get("view") === "chart";
- if (csRaw) {
- setChartShape(parseChartShape(csRaw));
- } else if (wantsChart) {
- const active = resolvedRoot ? isNodeActive(resolvedRoot) : false;
- setChartShape(defaultChartShape(active ? "search" : "metadata"));
- }
- if (wantsChart) setView("chart");
-
- setHydrated(true);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [channelOptions.length === 0, hydrated]);
-
- // Reflect the chart view + shape in the URL live (post-hydration only), so the
- // address bar always carries the current chart — mirrors how qt= is written.
- useEffect(() => {
- if (!hydrated) return;
- writeViewChartParams(view, view === "chart" ? chartShape : null);
- }, [hydrated, view, chartShape]);
-
- // Persist UI-only collapse state immediately on toggle. Loads + merges +
- // saves so concurrent writes to other top-level fields aren't clobbered.
- const persistUiCollapse = useCallback(
- (
- patch: Partial<Pick<StoredState, "collapsedGroups" | "filtersCollapsed">>,
- ) => {
- const current = loadStoredState() ?? emptyStoredState();
- if (patch.collapsedGroups !== undefined) {
- current.collapsedGroups = patch.collapsedGroups;
- }
- if (patch.filtersCollapsed !== undefined) {
- current.filtersCollapsed = patch.filtersCollapsed;
- }
- saveStoredState(current);
- },
- [],
- );
-
- // React mounts <details open={…}> after the manifest loads — that transition
- // fires a spurious `toggle` event whose handler can clobber the persisted
- // state with the default "open" value. Guard the persist call so it only
- // runs when the state actually changes.
- const toggleGroupCollapsed = useCallback(
- (groupId: string, isOpen: boolean) => {
- setCollapsedGroups((prev) => {
- const next = new Set(prev);
- if (isOpen) next.delete(groupId);
- else next.add(groupId);
- if (
- next.size === prev.size &&
- Array.from(next).every((v) => prev.has(v))
- ) {
- return prev;
- }
- persistUiCollapse({ collapsedGroups: Array.from(next).sort() });
- return next;
- });
- },
- [persistUiCollapse],
- );
-
- const toggleFiltersCollapsed = useCallback(() => {
- setFiltersCollapsed((prev) => {
- const next = !prev;
- persistUiCollapse({ filtersCollapsed: next });
- return next;
- });
- }, [persistUiCollapse]);
-
- const commitSearch = () => {
- setSearchExecuted(true);
- setCommittedRoot(draftRoot);
- const nextSnapshot = buildDraftSnapshot();
- const current = loadStoredState() ?? emptyStoredState();
- current.working = nextSnapshot;
- current.profiles = profiles;
- // If the user committed changes that diverge from the active profile,
- // drop the profile pointer. Otherwise hydration would prefer the
- // (unchanged) profile snapshot on reload and the user's commit would
- // silently revert.
- const activeProfileSnap =
- activeProfileName != null ? profiles[activeProfileName] : null;
- const profileNowDirty =
- activeProfileSnap != null &&
- !snapshotsEqual(activeProfileSnap, nextSnapshot);
- if (profileNowDirty) {
- setActiveProfileName(null);
- current.activeProfileName = null;
- } else {
- current.activeProfileName = activeProfileName;
- }
- saveStoredState(current);
- promoteDraftsToCommitted();
- // Write the URL: `qt=` is canonical. Strip legacy `q`/`m`/`re` so old
- // params don't clobber the next reload's tree parse.
- const serialized = stringifyRoot(draftRoot);
- if (isNodeActive(draftRoot)) {
- writeUrlParams({ q: "", re: false, mode: "transcripts" });
- writeQtParam(serialized);
- } else {
- writeUrlParams({ q: "", re: false, mode: "transcripts" });
- writeQtParam("");
- }
- };
-
- const applyDraftSnapshot = useCallback(
- (snapshot: FilterSnapshot | null) => {
- const excluded = snapshotToExcluded(
- snapshot,
- channelOptions,
- defaultSelectedChannels,
- );
- setDraftExcludedChannels(excluded);
- setDraftNov(snapshot?.nov === true);
- setDraftNol(snapshot?.nol === true);
- setDraftNaa(snapshot?.naa === true);
- setDraftNar(snapshot?.nar === true);
- setDraftNav(snapshot?.nav === true);
- setDraftNd(snapshot?.nd === true);
- setDraftNu(snapshot?.nu === true);
- setDraftDateFrom(snapshot?.dateFrom ?? "");
- setDraftDateTo(snapshot?.dateTo ?? "");
- if (snapshot?.query) {
- const parsed = parseRoot(snapshot.query);
- if (parsed) setDraftRoot(parsed);
- }
- },
- [channelOptions, defaultSelectedChannels],
- );
-
- const applySnapshot = useCallback(
- (snapshot: FilterSnapshot | null) => {
- applyDraftSnapshot(snapshot);
- const excluded = snapshotToExcluded(
- snapshot,
- channelOptions,
- defaultSelectedChannels,
- );
- setCommittedExcludedChannels(excluded);
- setCommittedNov(snapshot?.nov === true);
- setCommittedNol(snapshot?.nol === true);
- setCommittedNaa(snapshot?.naa === true);
- setCommittedNar(snapshot?.nar === true);
- setCommittedNav(snapshot?.nav === true);
- setCommittedNd(snapshot?.nd === true);
- setCommittedNu(snapshot?.nu === true);
- setCommittedDateFrom(snapshot?.dateFrom ?? "");
- setCommittedDateTo(snapshot?.dateTo ?? "");
- if (snapshot?.query) {
- const parsed = parseRoot(snapshot.query);
- if (parsed) setCommittedRoot(parsed);
- }
- stripAllFilterParamsFromUrl();
- },
- [applyDraftSnapshot, channelOptions, defaultSelectedChannels],
- );
-
- const writeStorage = useCallback(
- (mut: (s: StoredState) => void) => {
- const current = loadStoredState() ?? emptyStoredState();
- current.profiles = profiles;
- current.activeProfileName = activeProfileName;
- current.collapsedGroups = Array.from(collapsedGroups).sort();
- mut(current);
- saveStoredState(current);
- },
- [profiles, activeProfileName, collapsedGroups],
- );
-
- const handleLoadProfile = useCallback(
- (name: string | "") => {
- if (name === "") {
- setActiveProfileName(null);
- writeStorage((s) => {
- s.activeProfileName = null;
- });
- const current = loadStoredState();
- applySnapshot(current?.working ?? null);
- return;
- }
- const snap = profiles[name];
- if (!snap) return;
- setActiveProfileName(name);
- writeStorage((s) => {
- s.activeProfileName = name;
- s.working = { ...snap };
- });
- applySnapshot(snap);
- },
- [profiles, applySnapshot, writeStorage],
- );
-
- const handleSaveProfile = useCallback(() => {
- if (!activeProfileName) return;
- const snap = buildDraftSnapshot();
- promoteDraftsToCommitted();
- setProfiles((prev) => {
- const next = { ...prev, [activeProfileName]: snap };
- writeStorage((s) => {
- s.profiles = next;
- s.working = snap;
- });
- return next;
- });
- }, [
- activeProfileName,
- buildDraftSnapshot,
- promoteDraftsToCommitted,
- writeStorage,
- ]);
-
- const handleSaveAsProfile = useCallback(() => {
- if (typeof window === "undefined") return;
- const raw = window.prompt("Save current filters as a new profile named:");
- if (raw == null) return;
- const name = raw.trim();
- if (!name) return;
- if (Object.prototype.hasOwnProperty.call(profiles, name)) {
- window.alert(`A profile named "${name}" already exists.`);
- return;
- }
- const snap = buildDraftSnapshot();
- promoteDraftsToCommitted();
- setProfiles((prev) => {
- const next = { ...prev, [name]: snap };
- writeStorage((s) => {
- s.profiles = next;
- s.activeProfileName = name;
- s.working = snap;
- });
- return next;
- });
- setActiveProfileName(name);
- }, [profiles, buildDraftSnapshot, promoteDraftsToCommitted, writeStorage]);
-
- const handleRenameProfile = useCallback(() => {
- if (typeof window === "undefined") return;
- if (!activeProfileName) return;
- const raw = window.prompt("Rename profile:", activeProfileName);
- if (raw == null) return;
- const next = raw.trim();
- if (!next || next === activeProfileName) return;
- if (Object.prototype.hasOwnProperty.call(profiles, next)) {
- window.alert(`A profile named "${next}" already exists.`);
- return;
- }
- setProfiles((prev) => {
- const copy: Record<string, FilterSnapshot> = {};
- for (const [k, v] of Object.entries(prev)) {
- copy[k === activeProfileName ? next : k] = v;
- }
- writeStorage((s) => {
- s.profiles = copy;
- s.activeProfileName = next;
- });
- return copy;
- });
- setActiveProfileName(next);
- }, [activeProfileName, profiles, writeStorage]);
-
- const handleDeleteProfile = useCallback(() => {
- if (typeof window === "undefined") return;
- if (!activeProfileName) return;
- if (!window.confirm(`Delete profile "${activeProfileName}"?`)) return;
- setProfiles((prev) => {
- const copy = { ...prev };
- delete copy[activeProfileName];
- writeStorage((s) => {
- s.profiles = copy;
- s.activeProfileName = null;
- });
- return copy;
- });
- setActiveProfileName(null);
- }, [activeProfileName, writeStorage]);
-
- const profileDirty =
- activeProfileName != null &&
- profiles[activeProfileName] != null &&
- !snapshotsEqual(profiles[activeProfileName], committedSnapshot);
-
- const handleResetChannels = useCallback(() => {
- const excluded = new Set<string>();
- for (const name of channelOptions) {
- if (!defaultSelectedChannels.has(name)) excluded.add(name);
- }
- setDraftExcludedChannels(excluded);
- }, [channelOptions, defaultSelectedChannels]);
-
- const handleResetAllFilters = useCallback(() => {
- setActiveProfileName(null);
- writeStorage((s) => {
- s.working = emptySnapshot();
- s.activeProfileName = null;
- });
- applyDraftSnapshot(null);
- }, [applyDraftSnapshot, writeStorage]);
-
- const handleResetLayers = useCallback(() => {
- setDraftRoot(emptyRoot());
- }, []);
-
- const handleResetEverything = useCallback(() => {
- handleResetLayers();
- handleResetAllFilters();
- }, [handleResetLayers, handleResetAllFilters]);
-
- const handleRevertProfile = useCallback(() => {
- if (!activeProfileName) return;
- const snap = profiles[activeProfileName];
- if (!snap) return;
- applySnapshot(snap);
- }, [activeProfileName, profiles, applySnapshot]);
-
- const layersResetDisabled = draftHash === EMPTY_ROOT_HASH;
-
- const [shareCopied, setShareCopied] = useState(false);
- const shareResetRef = useRef<number | null>(null);
- const handleShareCurrentSearch = useCallback(async () => {
- const selectedChannels = new Set<string>();
- for (const name of channelOptions) {
- if (!committedExcludedChannels.has(name)) selectedChannels.add(name);
- }
- const selection: ShareSelection = {
- selectedChannels,
- videos: !committedNov,
- livestreams: !committedNol,
- allAges: !committedNaa,
- restricted: !committedNar,
- available: !committedNav,
- deleted: !committedNd,
- unlisted: !committedNu,
- tracks: new Set(),
- dateFrom: committedDateFrom || undefined,
- dateTo: committedDateTo || undefined,
- };
- const params = buildShareSearchParams(selection, {
- q: "",
- mode: "transcripts",
- regex: false,
- });
- if (isNodeActive(committedRoot)) {
- params.set("qt", stringifyRoot(committedRoot));
- }
- // Carry the chart view + its shape so a shared link opens straight to the
- // same chart (query/filters stay in qt= and the share params above).
- if (view === "chart") {
- params.set("view", "chart");
- if (chartShape) params.set("cs", stringifyChartShape(chartShape));
- }
- const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
- try {
- await navigator.clipboard.writeText(url);
- setShareCopied(true);
- if (shareResetRef.current !== null) {
- window.clearTimeout(shareResetRef.current);
- }
- shareResetRef.current = window.setTimeout(
- () => setShareCopied(false),
- 1800,
- );
- } catch {
- /* clipboard unavailable */
- }
- }, [
- channelOptions,
- committedExcludedChannels,
- committedNov,
- committedNol,
- committedNaa,
- committedNar,
- committedNav,
- committedNd,
- committedDateFrom,
- committedDateTo,
- committedRoot,
- view,
- chartShape,
- ]);
-
- // Switch to the chart view, creating the chart shape on first use. Defaults
- // its data source to the current search (or metadata when there's no query).
- const showChartView = useCallback(() => {
- setChartShape((s) => s ?? defaultChartShape(hasActiveQuery ? "search" : "metadata"));
- setView("chart");
- }, [hasActiveQuery]);
-
- const setSelected = useCallback(
- (targets: ReadonlyArray<string>, selected: boolean) => {
- setDraftExcludedChannels((prev) => {
- const next = new Set(prev);
- if (selected) {
- for (const name of targets) next.delete(name);
- } else {
- for (const name of targets) next.add(name);
- }
- return next;
- });
- },
- [],
- );
-
- const openWithMode = useCallback(
- (slug: string, hit?: LayerHit) => {
- const modalMode = hit?.scope === "chat" ? "chat" : "transcript";
- openTranscript(slug, hit?.start, { mode: modalMode });
- },
- [openTranscript],
- );
-
- // Map of leafId → leaf (and its query) so result-row rendering can look
- // up the original search terms for `<mark>` highlighting.
- const leavesById = useMemo(() => {
- const m = new Map<
- string,
- {
- query: string;
- useRegex: boolean;
- scope: LayerScope;
- }
- >();
- forEachLeaf(committedRoot, (leaf) =>
- m.set(leaf.id, {
- query: leaf.query,
- useRegex: leaf.useRegex,
- scope: leaf.scope,
- }),
- );
- return m;
- }, [committedRoot]);
-
- const router = useRouter();
- // Hand the current result set to the /ask chat as its grounding: serialize the
- // matched videos + snippets, stash them for the chat to pick up, and navigate.
- const askAboutResults = useCallback(() => {
- const queries = Array.from(leavesById.values())
- .map((l) => l.query.trim())
- .filter(Boolean);
- const bySlug = new Map(summaries.map((s) => [s.slug, s]));
- const handoff = buildSearchHandoff(
- resultGroups,
- (slug) => {
- const s = bySlug.get(slug);
- return s ? { id: s.id, webpageUrl: s.webpageUrl } : undefined;
- },
- queries,
- { searchCapped: capped },
- );
- if (handoff.videos.length === 0) return;
- try {
- sessionStorage.setItem(AI_HANDOFF_KEY, JSON.stringify(handoff));
- } catch {
- /* storage unavailable — the chat just won't receive the pin */
- }
- router.push("/ask/");
- }, [leavesById, resultGroups, summaries, capped, router]);
-
- const [resultsCopied, setResultsCopied] = useState(false);
- const resultsCopiedResetRef = useRef<number | null>(null);
- const copyResultsContext = useCallback(async () => {
- const queries = Array.from(leavesById.values())
- .map((l) => l.query.trim())
- .filter(Boolean);
- const md = buildResultsContextMarkdown(resultGroups, queries);
- try {
- await navigator.clipboard.writeText(md);
- setResultsCopied(true);
- if (resultsCopiedResetRef.current) {
- window.clearTimeout(resultsCopiedResetRef.current);
- }
- resultsCopiedResetRef.current = window.setTimeout(
- () => setResultsCopied(false),
- 1500,
- );
- } catch {
- /* clipboard blocked — no-op */
- }
- }, [leavesById, resultGroups]);
-
- // Hits split by leaf, in the order leaves appear in the tree, so the UI
- // sections render in a stable order regardless of the order findHitsInCues
- // discovered them.
- const orderedLeafIds = useMemo<string[]>(() => {
- const ids: string[] = [];
- forEachLeaf(committedRoot, (leaf) => {
- if (leaf.contributeHits) ids.push(leaf.id);
- });
- return ids;
- }, [committedRoot]);
-
- // The chart plots the live search results when there's a query, otherwise the
- // in-scope (filter-passing) videos. `effectiveShape` guards the null shape on
- // the server pass / before lazy init.
- const effectiveShape =
- chartShape ?? defaultChartShape(hasActiveQuery ? "search" : "metadata");
- const chartSlugs: Iterable<string> = hasActiveQuery
- ? treeProgress?.slugs ?? []
- : globalScopeSlugs;
-
- return (
- <div className="flex flex-col gap-6">
- <ProfilesRow
- profileNames={Object.keys(profiles).sort()}
- activeProfileName={activeProfileName}
- profileDirty={profileDirty}
- shareCopied={shareCopied}
- onSelect={handleLoadProfile}
- onSave={handleSaveProfile}
- onSaveAs={handleSaveAsProfile}
- onRename={handleRenameProfile}
- onDelete={handleDeleteProfile}
- onRevert={handleRevertProfile}
- onShareCurrentSearch={handleShareCurrentSearch}
- />
- <form
- onSubmit={(e) => {
- e.preventDefault();
- commitSearch();
- }}
- className="flex flex-col gap-3"
- data-testid="search-form"
- >
- {mounted ? (
- <QueryBuilder
- root={draftRoot}
- onChange={setDraftRoot}
- leafStates={leafStates}
- groupStates={groupStates}
- />
- ) : (
- <div className="h-12 rounded-lg border border-border bg-muted" />
- )}
- <div className="flex items-center gap-2 flex-wrap">
- <Button
- type="submit"
- data-testid="search-submit"
- data-dirty={queryDirty || filtersDirty ? "true" : "false"}
- variant={queryDirty || filtersDirty ? "default" : "outline"}
- className="shrink-0"
- >
- Search
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={handleResetLayers}
- disabled={layersResetDisabled}
- data-testid="reset-layers"
- className="text-xs text-muted-foreground"
- >
- Reset layers
- </Button>
- {(queryDirty || filtersDirty) && (
- <span className="text-xs text-warning">
- Press Enter or click Search to apply
- </span>
- )}
- {hasSubs && (
- <span
- className="text-xs text-muted-foreground ml-auto"
- title={`${liveChatTotalCount} videos have live chat`}
- >
- Live chat available on {liveChatTotalCount} video
- {liveChatTotalCount === 1 ? "" : "s"} — try scope: Live chat.
- </span>
- )}
- </div>
- </form>
-
- {channelOptions.length > 0 && (
- <details open={!filtersCollapsed} className="-mt-3">
- <summary
- onClick={(e) => {
- // Drive the open state from React rather than the browser's
- // default toggle action. Avoids a spurious `toggle` event that
- // fires when React first mounts <details open> after the
- // manifest loads, which would clobber the persisted value.
- e.preventDefault();
- toggleFiltersCollapsed();
- }}
- className="cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted-foreground"
- >
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Filters
- </span>
- <span className="text-xs text-muted-foreground">
- {selectedChannelNames.length} of {channelOptions.length} channel
- {channelOptions.length === 1 ? "" : "s"}
- </span>
- {filtersCollapsed && selectedChannelNames.length > 0 && (
- <span
- className="text-xs text-muted-foreground truncate min-w-0 flex-1"
- title={selectedChannelsLabel}
- data-testid="filters-summary-channels"
- >
- {selectedChannelsLabel}
- </span>
- )}
- </summary>
- <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
- {channelOptions.length > 1 && (
- <div className="flex flex-col gap-2 w-full">
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Channels
- </span>
- <ChannelAllToggle
- channelOptions={channelOptions}
- draftExcludedChannels={draftExcludedChannels}
- setDraftExcludedChannels={setDraftExcludedChannels}
- />
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={handleResetChannels}
- className="text-xs text-muted-foreground"
- >
- Reset channels
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={handleResetAllFilters}
- className="text-xs text-muted-foreground"
- >
- Reset all filters
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={handleResetEverything}
- data-testid="reset-everything"
- className="text-xs text-muted-foreground"
- >
- Reset everything
- </Button>
- </div>
- <div className="flex flex-col gap-1.5">
- {channelGroupings.map(({ group, channels }) => {
- const isOpen = !collapsedGroups.has(group.id);
- const selectedCount = channels.reduce(
- (n, name) =>
- draftExcludedChannels.has(name) ? n : n + 1,
- 0,
- );
- return (
- <details
- key={group.id}
- open={isOpen}
- onToggle={(e) =>
- toggleGroupCollapsed(
- group.id,
- (e.target as HTMLDetailsElement).open,
- )
- }
- className="rounded border border-border bg-card/60"
- >
- <summary className="cursor-pointer select-none flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1.5 text-sm">
- {group.name && (
- <span className="font-medium text-foreground inline-flex items-center gap-1.5">
- {group.accent && (
- <span
- aria-hidden="true"
- className="inline-block size-2 shrink-0 rounded-full"
- style={{ background: group.accent }}
- />
- )}
- {group.name}
- </span>
- )}
- <span className="text-xs text-muted-foreground">
- ({selectedCount}/{channels.length} selected)
- </span>
- <span className="ml-auto flex items-center gap-2">
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setSelected(channels, true);
- }}
- className="text-xs text-muted-foreground"
- >
- All
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setSelected(channels, false);
- }}
- className="text-xs text-muted-foreground"
- >
- None
- </Button>
- </span>
- </summary>
- {group.description && (
- <div className="px-3 pt-0 pb-1 text-xs text-muted-foreground">
- {group.description}
- </div>
- )}
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-1 pb-2">
- {channels.map((key) => {
- const checked = !draftExcludedChannels.has(key);
- return (
- <label
- key={key}
- className="flex items-center gap-1.5 select-none"
- >
- <Checkbox
- checked={checked}
- onCheckedChange={(value) => {
- setDraftExcludedChannels((prev) => {
- const next = new Set(prev);
- if (value === true) next.delete(key);
- else next.add(key);
- return next;
- });
- }}
- />
- {channelLabelByKey.get(key) ?? key}
- </label>
- );
- })}
- </div>
- </details>
- );
- })}
- </div>
- </div>
- )}
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Type
- </span>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNov}
- onCheckedChange={(value) => setDraftNov(value !== true)}
- />
- Videos
- </label>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNol}
- onCheckedChange={(value) => setDraftNol(value !== true)}
- />
- Livestreams
- </label>
- </div>
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Audience
- </span>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNaa}
- onCheckedChange={(value) => setDraftNaa(value !== true)}
- />
- All ages
- </label>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNar}
- onCheckedChange={(value) => setDraftNar(value !== true)}
- />
- Age-restricted
- </label>
- </div>
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Availability
- </span>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNav}
- onCheckedChange={(value) => setDraftNav(value !== true)}
- />
- Available
- </label>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNu}
- onCheckedChange={(value) => setDraftNu(value !== true)}
- />
- Unlisted
- </label>
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNd}
- onCheckedChange={(value) => setDraftNd(value !== true)}
- />
- Deleted
- </label>
- </div>
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Date
- </span>
- <label className="flex items-center gap-1.5 select-none">
- From
- <input
- type="date"
- data-testid="date-from"
- value={ymdToInput(draftDateFrom)}
- onChange={(e) =>
- setDraftDateFrom(inputToYmd(e.target.value) ?? "")
- }
- className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm"
- />
- </label>
- <label className="flex items-center gap-1.5 select-none">
- To
- <input
- type="date"
- data-testid="date-to"
- value={ymdToInput(draftDateTo)}
- onChange={(e) =>
- setDraftDateTo(inputToYmd(e.target.value) ?? "")
- }
- className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm"
- />
- </label>
- </div>
- </div>
- </details>
- )}
-
- <details className="-mt-3 text-sm text-muted-foreground">
- <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
- Advanced options
- </summary>
- <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-border bg-card/60">
- <LimitField
- label="Max hits per batch"
- hint="Pause after this many matches; “Show more” adds another batch."
- value={hitBatchValue}
- unlimited={hitBatchUnlimited}
- defaultValue={DEFAULT_MAX_HITS}
- onValueChange={setHitBatchValue}
- onUnlimitedChange={setHitBatchUnlimited}
- />
- <NumberField
- label="Fetch concurrency"
- hint="Parallel transcript downloads while searching. Applies on next search."
- value={fetchConcurrency}
- defaultValue={DEFAULT_FETCH_CONCURRENCY}
- min={1}
- max={32}
- onChange={setFetchConcurrency}
- />
- <NumberField
- label="Flush interval (ms)"
- hint="UI update cadence during search. Applies on next search."
- value={flushIntervalMs}
- defaultValue={DEFAULT_FLUSH_INTERVAL_MS}
- min={16}
- max={2000}
- onChange={setFlushIntervalMs}
- />
- <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => {
- setHitBatchUnlimited(true);
- setHitLimit(Infinity);
- }}
- className="text-xs"
- >
- Search through all transcripts
- </Button>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => {
- setHitBatchValue(DEFAULT_MAX_HITS);
- setHitBatchUnlimited(false);
- setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY);
- setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS);
- clearAdvanced();
- }}
- className="text-xs"
- >
- Reset to defaults
- </Button>
- </div>
- </div>
- </details>
-
- <section data-testid="results-section">
- <h2 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-2 flex-wrap">
- <span data-testid="results-summary">
- {hasActiveQuery ? (
- <>
- Matching videos{" "}
- {resultGroups.length > 0
- ? `(${resultGroups.length} video${
- resultGroups.length === 1 ? "" : "s"
- }${totalHits > 0 ? `, ${totalHits}${capped ? "+" : ""} hit${totalHits === 1 ? "" : "s"}` : ""})`
- : "(0)"}
- </>
- ) : (
- `All videos (${resultGroups.length})`
- )}
- </span>
- {(indexLoading || (!hasActiveQuery && summariesLoading)) && (
- <span className="text-xs text-muted-foreground font-normal">
- loading index{indexProgress ? ` (${indexProgress})` : ""}…
- </span>
- )}
- {searching && (
- <span className="text-xs text-muted-foreground font-normal">
- searched {processed}/{totalToProcess}
- {pipelineActive ? "…" : ""}
- </span>
- )}
- <span
- data-testid="view-toggle"
- className="ml-auto inline-flex overflow-hidden rounded-md border border-border bg-muted"
- >
- <button
- type="button"
- onClick={() => setView("results")}
- className={`px-2.5 py-1 text-xs font-normal ${
- view === "results"
- ? "bg-primary text-primary-foreground"
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
- }`}
- >
- Results
- </button>
- <button
- type="button"
- onClick={showChartView}
- className={`px-2.5 py-1 text-xs font-normal ${
- view === "chart"
- ? "bg-primary text-primary-foreground"
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
- }`}
- >
- Chart
- </button>
- </span>
- {hasActiveQuery && resultGroups.length > 0 && (
- <span className="inline-flex items-center gap-2">
- <button
- type="button"
- onClick={copyResultsContext}
- title="Copy these results as context to paste into an AI chat"
- className="inline-flex items-center rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-normal text-muted-foreground hover:bg-accent hover:text-accent-foreground"
- >
- {resultsCopied ? "✓ Copied" : "Copy for AI"}
- </button>
- <button
- type="button"
- onClick={askAboutResults}
- title="Open the AI chat grounded in these search results"
- className="inline-flex items-center rounded-md bg-primary px-2.5 py-1 text-xs font-normal text-primary-foreground hover:opacity-90"
- >
- Ask AI about these results
- </button>
- </span>
- )}
- </h2>
-
- {view === "chart" ? (
- <div className="flex flex-col gap-4">
- <details
- data-testid="chart-options"
- open
- className="rounded-lg border border-border px-3 py-2"
- >
- <summary className="cursor-pointer text-sm font-medium text-foreground">
- Chart options
- </summary>
- <div className="pt-3">
- <ChartShapeControls shape={effectiveShape} onChange={setChartShape} />
- </div>
- </details>
- <SearchChartPanel
- slugs={chartSlugs}
- hitsBySlug={treeProgress?.hits ?? null}
- scopeSlugs={globalScopeSlugs}
- shape={effectiveShape}
- loading={pipelineActive}
- />
- </div>
- ) : (
- <>
- {!hasActiveQuery && !summariesLoading && resultGroups.length > 0 && (
- <p className="text-sm text-muted-foreground mb-2" data-testid="browse-hint">
- Showing all videos — type a query above to narrow.
- </p>
- )}
- {hasActiveQuery
- ? !indexLoading &&
- resultGroups.length === 0 &&
- !pipelineActive && (
- <p className="text-sm text-muted-foreground">No matching videos.</p>
- )
- : !summariesLoading &&
- resultGroups.length === 0 && (
- <p className="text-sm text-muted-foreground">
- No videos match the current filters.
- </p>
- )}
- {resultGroups.length > 0 && (
- <VirtualResultList
- resultGroups={resultGroups}
- leavesById={leavesById}
- orderedLeafIds={orderedLeafIds}
- activeVideo={activeVideo}
- activeTime={activeTime}
- openWithMode={openWithMode}
- />
- )}
- {capped && (
- <div className="mt-3 flex justify-center">
- <Button
- type="button"
- variant="outline"
- onClick={() => setHitLimit((l) => l + hitBatchSize)}
- >
- Load more results
- </Button>
- </div>
- )}
- </>
- )}
- </section>
- </div>
- );
-}
-
-// ─── Result rendering ────────────────────────────────────────────────────────
-
-type LeafInfo = {
- query: string;
- useRegex: boolean;
- scope: LayerScope;
-};
-
-// Each card is one virtualized element. Intra-card layout (header, leaf
-// sections, hit rows) is plain CSS flow inside the card — the virtualizer
-// only ever measures a card's total height, so there is no inter-row
-// translateY math to misalign.
-type ResultRow =
- | { kind: "card"; cardIndex: number; group: ResultGroup }
- | { kind: "gap"; cardIndex: number };
-
-const GAP_ROW_PX = 12;
-
-function buildResultRows(
- resultGroups: ReadonlyArray<ResultGroup>,
-): ResultRow[] {
- const out: ResultRow[] = [];
- resultGroups.forEach((g, cardIndex) => {
- out.push({ kind: "card", cardIndex, group: g });
- if (cardIndex < resultGroups.length - 1) {
- out.push({ kind: "gap", cardIndex });
- }
- });
- return out;
-}
-
-function rowKey(row: ResultRow): string {
- return row.kind === "card" ? `c${row.cardIndex}` : `c${row.cardIndex}:gap`;
-}
-
-function VirtualResultList({
- resultGroups,
- leavesById,
- orderedLeafIds,
- activeVideo,
- activeTime,
- openWithMode,
-}: {
- resultGroups: ReadonlyArray<ResultGroup>;
- leavesById: ReadonlyMap<string, LeafInfo>;
- orderedLeafIds: ReadonlyArray<string>;
- activeVideo: string | null;
- activeTime: number | null;
- openWithMode: (slug: string, hit?: LayerHit) => void;
-}) {
- const flatRows = useMemo(() => buildResultRows(resultGroups), [resultGroups]);
-
- const listRef = useRef<HTMLDivElement | null>(null);
- const [scrollMargin, setScrollMargin] = useState(0);
-
- useEffect(() => {
- if (typeof window === "undefined") return;
- const update = () => {
- const el = listRef.current;
- if (!el) return;
- setScrollMargin(el.getBoundingClientRect().top + window.scrollY);
- };
- update();
- const ro = new ResizeObserver(update);
- if (listRef.current) ro.observe(listRef.current);
- ro.observe(document.body);
- window.addEventListener("resize", update);
- return () => {
- ro.disconnect();
- window.removeEventListener("resize", update);
- };
- }, []);
-
- const rowVirtualizer = useWindowVirtualizer({
- count: flatRows.length,
- // Card heights vary a lot (header-only tombstone vs. a card with
- // hundreds of hits), so estimateSize is only a rough first-paint
- // guess; ResizeObserver measures each card's actual height on mount
- // and the virtualizer re-positions later cards. The unit here is a
- // whole card, not a row inside one — there is no intra-card row
- // positioning to get wrong.
- estimateSize: (index) =>
- flatRows[index]?.kind === "gap" ? GAP_ROW_PX : CARD_ROW_ESTIMATE_PX,
- // Cards are large; ~4 above/below the viewport is plenty of buffer.
- overscan: 4,
- scrollMargin,
- });
-
- const virtualItems = rowVirtualizer.getVirtualItems();
- const totalSize = rowVirtualizer.getTotalSize();
-
- return (
- <div ref={listRef} className="relative" style={{ height: totalSize }}>
- {virtualItems.map((vi) => {
- const row = flatRows[vi.index];
- return (
- <VirtualRow
- key={rowKey(row)}
- measureRef={rowVirtualizer.measureElement}
- index={vi.index}
- offset={vi.start - scrollMargin}
- >
- {row.kind === "gap" ? (
- <div style={{ height: GAP_ROW_PX }} aria-hidden="true" />
- ) : (
- <ResultCard
- group={row.group}
- leavesById={leavesById}
- orderedLeafIds={orderedLeafIds}
- activeVideo={activeVideo}
- activeTime={activeTime}
- openWithMode={openWithMode}
- />
- )}
- </VirtualRow>
- );
- })}
- </div>
- );
-}
-
-const ResultCard = memo(function ResultCard({
- group,
- leavesById,
- orderedLeafIds,
- activeVideo,
- activeTime,
- openWithMode,
-}: {
- group: ResultGroup;
- leavesById: ReadonlyMap<string, LeafInfo>;
- orderedLeafIds: ReadonlyArray<string>;
- activeVideo: string | null;
- activeTime: number | null;
- openWithMode: (slug: string, hit?: LayerHit) => void;
-}) {
- // Bucket hits per contributing leaf so the user sees one section per
- // layer rather than an interleaved mishmash.
- const buckets = useMemo(() => {
- const m = new Map<string, LayerHit[]>();
- for (const id of orderedLeafIds) m.set(id, []);
- for (const h of group.hits) {
- const arr = m.get(h.leafId);
- if (arr) arr.push(h);
- }
- return m;
- }, [group.hits, orderedLeafIds]);
-
- return (
- <div
- data-result-slug={group.slug}
- data-card-header=""
- className="border border-border rounded-lg overflow-hidden bg-card"
- // Provenance: a left edge in the source archive's accent (hub mode only).
- style={
- group.accent
- ? { borderLeftColor: group.accent, borderLeftWidth: "3px" }
- : undefined
- }
- >
- <Button
- type="button"
- variant="ghost"
- onClick={() => openWithMode(group.slug)}
- className="w-full h-auto justify-start text-left items-baseline gap-2 px-4 py-2 rounded-none border-b border-border bg-muted font-normal"
- >
- <span className="font-medium truncate flex-1 min-w-0">
- {group.title}
- </span>
- {group.isLivestream && <LivestreamBadge />}
- {group.ageRestricted && <AgeRestrictedBadge />}
- {(() => {
- const e = vodExpiry(group.platform, group.uploadDate);
- return e?.likelyExpired ? (
- <VodExpiredBadge tooltip={e.tooltip} />
- ) : null;
- })()}
- <span className="text-xs text-muted-foreground shrink-0">
- {group.channel && `${group.channel} · `}
- {group.date}
- {group.hits.length > 0 &&
- ` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`}
- </span>
- </Button>
- <ul className="flex flex-col divide-y divide-border">
- {Array.from(buckets.entries()).map(([leafId, hits]) => {
- if (hits.length === 0) return null;
- const leafInfo = leavesById.get(leafId);
- if (!leafInfo) return null;
- return (
- <li key={leafId}>
- <div
- className="flex items-center gap-2 px-4 py-1.5 bg-muted/40 border-b border-border"
- data-leaf-section={leafId}
- >
- <LayerSwatch leafId={leafId} size="xs" />
- <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
- {leafInfo.scope === "metadata"
- ? "Title / channel"
- : leafInfo.scope === "chat"
- ? "Live chat"
- : "Transcripts"}
- </span>
- <span className="font-mono text-xs text-muted-foreground truncate">
- {leafInfo.query}
- </span>
- <span className="ml-auto text-[10px] text-muted-foreground">
- {hits.length} hit{hits.length === 1 ? "" : "s"}
- </span>
- </div>
- <ul className="flex flex-col divide-y divide-border/60">
- {hits.map((h, i) => (
- <HitRow
- key={i}
- slug={group.slug}
- hit={h}
- query={leafInfo.query}
- useRegex={leafInfo.useRegex}
- isActive={
- activeVideo === group.slug && activeTime === h.start
- }
- onOpen={openWithMode}
- />
- ))}
- </ul>
- </li>
- );
- })}
- </ul>
- </div>
- );
-});
-ResultCard.displayName = "ResultCard";
-
-// One hit row inside a card. Memoized so that (a) scroll-frame re-renders of
-// the parent list don't re-run highlight() for every hit, and (b) when the
-// modal target changes only the previously-active and newly-active rows
-// re-render — every other row bails out on shallow-equal props.
-const HitRow = memo(function HitRow({
- slug,
- hit,
- query,
- useRegex,
- isActive,
- onOpen,
-}: {
- slug: string;
- hit: LayerHit;
- query: string;
- useRegex: boolean;
- isActive: boolean;
- onOpen: (slug: string, hit?: LayerHit) => void;
-}) {
return (
- <li>
- <Button
- type="button"
- variant="ghost"
- onClick={() => onOpen(slug, hit)}
- className={`w-full h-auto justify-start text-left flex gap-3 px-4 py-2 rounded-none font-normal whitespace-normal ${
- isActive ? "bg-warning-soft ring-1 ring-inset ring-warning/60" : ""
- }`}
- >
- <span className="text-xs font-mono text-muted-foreground shrink-0 w-16">
- {hit.scope === "metadata" ? "—" : formatSeconds(hit.start)}
- </span>
- {hit.track && hit.track !== "live_chat" && (
- <TrackBadge track={hit.track} />
- )}
- <span className="flex-1 min-w-0 text-sm">
- {highlight(hit.text, query, useRegex)}
- </span>
- </Button>
- </li>
- );
-});
-HitRow.displayName = "HitRow";
-
-function TrackBadge({ track }: { track: string }) {
- const label = track === "live_chat" ? "live chat" : track;
- return (
- <span className="shrink-0 text-[10px] uppercase tracking-wide font-medium px-1.5 py-0.5 rounded bg-muted text-muted-foreground self-center">
- {label}
- </span>
- );
-}
-
-function ProfilesRow({
- profileNames,
- activeProfileName,
- profileDirty,
- shareCopied,
- onSelect,
- onSave,
- onSaveAs,
- onRename,
- onDelete,
- onRevert,
- onShareCurrentSearch,
-}: {
- profileNames: string[];
- activeProfileName: string | null;
- profileDirty: boolean;
- shareCopied: boolean;
- onSelect: (name: string) => void;
- onSave: () => void;
- onSaveAs: () => void;
- onRename: () => void;
- onDelete: () => void;
- onRevert: () => void;
- onShareCurrentSearch: () => void;
-}) {
- const showRevert = activeProfileName != null && profileDirty;
- return (
- <div
- className="flex flex-wrap items-center gap-x-3 gap-y-1"
- data-testid="profiles-row"
- >
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
- Profile
- </span>
- <select
- value={activeProfileName ?? ""}
- onChange={(e) => onSelect(e.target.value)}
- data-testid="profile-select"
- className="rounded border border-border bg-card text-foreground px-2 py-0.5 text-sm"
- >
- <option value="">(unsaved)</option>
- {profileNames.map((n) => (
- <option key={n} value={n}>
- {n}
- </option>
- ))}
- </select>
- {profileDirty && (
- <span
- role="status"
- aria-label="profile has unsaved changes"
- data-testid="profile-dirty-dot"
- className="text-base leading-none text-warning"
- >
- •
- </span>
- )}
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onSave}
- disabled={!activeProfileName || !profileDirty}
- className="text-xs text-muted-foreground"
- >
- Save
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onSaveAs}
- className="text-xs text-muted-foreground"
- >
- Save as…
- </Button>
- {showRevert && (
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onRevert}
- data-testid="profile-revert"
- className="text-xs text-muted-foreground"
- >
- Revert
- </Button>
- )}
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onRename}
- disabled={!activeProfileName}
- className="text-xs text-muted-foreground"
- >
- Rename
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onDelete}
- disabled={!activeProfileName}
- className="text-xs text-destructive"
- >
- Delete
- </Button>
- <div className="ml-auto flex items-center gap-x-3">
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={onShareCurrentSearch}
- title={
- shareCopied
- ? "Copied!"
- : "Copy link encoding the current search + filters"
- }
- className="text-xs text-muted-foreground"
- >
- {shareCopied ? "Link copied!" : "Share current search"}
- </Button>
+ <SearchSessionProvider>
+ <div className="flex flex-col gap-6">
+ <WorkspaceSearchBar />
+ <SearchResults />
</div>
- </div>
- );
-}
-
-function ChannelAllToggle({
- channelOptions,
- draftExcludedChannels,
- setDraftExcludedChannels,
-}: {
- channelOptions: string[];
- draftExcludedChannels: Set<string>;
- setDraftExcludedChannels: (next: Set<string>) => void;
-}) {
- const allChecked = draftExcludedChannels.size === 0;
- return (
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={allChecked}
- onCheckedChange={() => {
- setDraftExcludedChannels(
- allChecked ? new Set(channelOptions) : new Set(),
- );
- }}
- />
- All
- </label>
+ </SearchSessionProvider>
);
}
-
-function sameSet<T>(a: Set<T>, b: Set<T>): boolean {
- if (a.size !== b.size) return false;
- for (const v of a) if (!b.has(v)) return false;
- return true;
-}
-
-function NumberField({
- label,
- hint,
- value,
- defaultValue,
- min,
- max,
- onChange,
- disabled,
-}: {
- label: string;
- hint?: string;
- value: number;
- defaultValue: number;
- min: number;
- max: number;
- onChange: (n: number) => void;
- disabled?: boolean;
-}) {
- return (
- <label className="flex flex-col gap-1">
- <span className="text-xs font-medium text-foreground">
- {label}
- </span>
- <input
- type="number"
- min={min}
- max={max}
- value={Number.isFinite(value) ? value : ""}
- placeholder={String(defaultValue)}
- disabled={disabled}
- onChange={(e) => {
- const raw = e.target.value;
- if (raw === "") {
- onChange(defaultValue);
- return;
- }
- const n = Math.max(min, Math.min(max, Math.floor(Number(raw))));
- if (Number.isFinite(n)) onChange(n);
- }}
- className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50"
- />
- {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
- </label>
- );
-}
-
-function LimitField({
- label,
- hint,
- value,
- unlimited,
- defaultValue,
- onValueChange,
- onUnlimitedChange,
-}: {
- label: string;
- hint?: string;
- value: number;
- unlimited: boolean;
- defaultValue: number;
- onValueChange: (n: number) => void;
- onUnlimitedChange: (b: boolean) => void;
-}) {
- return (
- <div className="flex flex-col gap-1">
- <div className="flex items-baseline justify-between gap-2">
- <span className="text-xs font-medium text-foreground">
- {label}
- </span>
- <label className="flex items-center gap-1 text-xs select-none">
- <Checkbox
- checked={unlimited}
- onCheckedChange={(value) => onUnlimitedChange(value === true)}
- />
- No limit
- </label>
- </div>
- <input
- type="number"
- aria-label={label}
- min={1}
- max={1_000_000}
- value={unlimited ? "" : value}
- placeholder={unlimited ? "∞" : String(defaultValue)}
- disabled={unlimited}
- onChange={(e) => {
- const raw = e.target.value;
- if (raw === "") {
- onValueChange(defaultValue);
- return;
- }
- const n = Math.max(1, Math.floor(Number(raw)));
- if (Number.isFinite(n)) onValueChange(n);
- }}
- className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50"
- />
- {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
- </div>
- );
-}
-
-function formatSeconds(s: number): string {
- const h = Math.floor(s / 3600);
- const m = Math.floor((s % 3600) / 60);
- const sec = s % 60;
- const mm = String(m).padStart(2, "0");
- const ss = String(sec).padStart(2, "0");
- return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
-}
-
-function highlight(text: string, q: string, useRegex = false) {
- if (!q) return text;
- const parts: React.ReactNode[] = [];
-
- if (useRegex) {
- let re: RegExp;
- try {
- re = new RegExp(q, "gi");
- } catch {
- return text;
- }
- let last = 0;
- let m: RegExpExecArray | null;
- let key = 0;
- while ((m = re.exec(text)) !== null) {
- if (m.index > last) parts.push(text.slice(last, m.index));
- parts.push(
- <mark
- key={key++}
- className="bg-warning-soft text-inherit"
- >
- {m[0]}
- </mark>,
- );
- last = m.index + m[0].length;
- if (m[0].length === 0) re.lastIndex++;
- }
- if (last < text.length) parts.push(text.slice(last));
- return <>{parts}</>;
- }
-
- const lower = text.toLowerCase();
- const ql = q.toLowerCase();
- let i = 0;
- while (i < text.length) {
- const idx = lower.indexOf(ql, i);
- if (idx === -1) {
- parts.push(text.slice(i));
- break;
- }
- if (idx > i) parts.push(text.slice(i, idx));
- parts.push(
- <mark
- key={idx}
- className="bg-warning-soft text-inherit"
- >
- {text.slice(idx, idx + q.length)}
- </mark>,
- );
- i = idx + q.length;
- }
- return <>{parts}</>;
-}
-
-// Write `qt=` directly. `writeUrlParams` doesn't know about the param —
-// keep that helper focused on the legacy/share keys and use a small
-// dedicated mutator here.
-function writeQtParam(value: string): void {
- if (typeof window === "undefined") return;
- const params = new URLSearchParams(window.location.search);
- if (value) params.set("qt", value);
- else params.delete("qt");
- const qs = params.toString();
- const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
- if (next === window.location.pathname + window.location.search) return;
- window.history.replaceState(window.history.state, "", next);
- window.dispatchEvent(new PopStateEvent("popstate"));
-}
-
-// Mirror writeQtParam for the chart view/shape params. Keeps the address bar in
-// sync with the current chart so refresh/Back restore it (same dedicated-mutator
-// pattern as qt=).
-function writeViewChartParams(
- view: "results" | "chart",
- shape: ChartShape | null,
-): void {
- if (typeof window === "undefined") return;
- const params = new URLSearchParams(window.location.search);
- if (view === "chart") params.set("view", "chart");
- else params.delete("view");
- if (view === "chart" && shape) params.set("cs", stringifyChartShape(shape));
- else params.delete("cs");
- const qs = params.toString();
- const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
- if (next === window.location.pathname + window.location.search) return;
- window.history.replaceState(window.history.state, "", next);
- window.dispatchEvent(new PopStateEvent("popstate"));
-}
diff --git a/common/components/WorkspaceSearchBar.tsx b/common/components/WorkspaceSearchBar.tsx
@@ -0,0 +1,753 @@
+"use client";
+
+// The persistent search BAR: the profiles row, the query-builder form, the
+// collapsible filters, and the advanced options. It's the input half of the old
+// TranscriptSearch monolith, lifted into the workspace shell so it stays mounted
+// (and keeps its committed search) across `/` ⇄ `/ask`. All of its state comes
+// from the shared SearchSession.
+
+import { Button } from "./ui/button";
+import { Checkbox } from "./ui/checkbox";
+import QueryBuilder from "./QueryBuilder";
+import { clearAdvanced } from "./exportAdvancedStorage";
+import { ymdToInput, inputToYmd } from "../lib/ymd";
+import {
+ useSearchSession,
+ DEFAULT_MAX_HITS,
+ DEFAULT_FETCH_CONCURRENCY,
+ DEFAULT_FLUSH_INTERVAL_MS,
+} from "./SearchSessionContext";
+
+export default function WorkspaceSearchBar() {
+ const {
+ // Profiles row
+ profiles,
+ activeProfileName,
+ profileDirty,
+ shareCopied,
+ handleLoadProfile,
+ handleSaveProfile,
+ handleSaveAsProfile,
+ handleRenameProfile,
+ handleDeleteProfile,
+ handleRevertProfile,
+ handleShareCurrentSearch,
+ // Query builder / commit
+ commitSearch,
+ mounted,
+ draftRoot,
+ setDraftRoot,
+ leafStates,
+ groupStates,
+ queryDirty,
+ filtersDirty,
+ handleResetLayers,
+ layersResetDisabled,
+ hasSubs,
+ liveChatTotalCount,
+ // Filters
+ channelOptions,
+ filtersCollapsed,
+ toggleFiltersCollapsed,
+ selectedChannelNames,
+ selectedChannelsLabel,
+ draftExcludedChannels,
+ setDraftExcludedChannels,
+ handleResetChannels,
+ handleResetAllFilters,
+ handleResetEverything,
+ channelGroupings,
+ collapsedGroups,
+ toggleGroupCollapsed,
+ setSelected,
+ channelLabelByKey,
+ draftNov,
+ setDraftNov,
+ draftNol,
+ setDraftNol,
+ draftNaa,
+ setDraftNaa,
+ draftNar,
+ setDraftNar,
+ draftNav,
+ setDraftNav,
+ draftNd,
+ setDraftNd,
+ draftNu,
+ setDraftNu,
+ draftDateFrom,
+ setDraftDateFrom,
+ draftDateTo,
+ setDraftDateTo,
+ // Advanced options
+ hitBatchValue,
+ setHitBatchValue,
+ hitBatchUnlimited,
+ setHitBatchUnlimited,
+ fetchConcurrency,
+ setFetchConcurrency,
+ flushIntervalMs,
+ setFlushIntervalMs,
+ setHitLimit,
+ } = useSearchSession();
+
+ return (
+ <div className="flex flex-col gap-6">
+ <ProfilesRow
+ profileNames={Object.keys(profiles).sort()}
+ activeProfileName={activeProfileName}
+ profileDirty={profileDirty}
+ shareCopied={shareCopied}
+ onSelect={handleLoadProfile}
+ onSave={handleSaveProfile}
+ onSaveAs={handleSaveAsProfile}
+ onRename={handleRenameProfile}
+ onDelete={handleDeleteProfile}
+ onRevert={handleRevertProfile}
+ onShareCurrentSearch={handleShareCurrentSearch}
+ />
+ <form
+ onSubmit={(e) => {
+ e.preventDefault();
+ commitSearch();
+ }}
+ className="flex flex-col gap-3"
+ data-testid="search-form"
+ >
+ {mounted ? (
+ <QueryBuilder
+ root={draftRoot}
+ onChange={setDraftRoot}
+ leafStates={leafStates}
+ groupStates={groupStates}
+ />
+ ) : (
+ <div className="h-12 rounded-lg border border-border bg-muted" />
+ )}
+ <div className="flex items-center gap-2 flex-wrap">
+ <Button
+ type="submit"
+ data-testid="search-submit"
+ data-dirty={queryDirty || filtersDirty ? "true" : "false"}
+ variant={queryDirty || filtersDirty ? "default" : "outline"}
+ className="shrink-0"
+ >
+ Search
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={handleResetLayers}
+ disabled={layersResetDisabled}
+ data-testid="reset-layers"
+ className="text-xs text-muted-foreground"
+ >
+ Reset layers
+ </Button>
+ {(queryDirty || filtersDirty) && (
+ <span className="text-xs text-warning">
+ Press Enter or click Search to apply
+ </span>
+ )}
+ {hasSubs && (
+ <span
+ className="text-xs text-muted-foreground ml-auto"
+ title={`${liveChatTotalCount} videos have live chat`}
+ >
+ Live chat available on {liveChatTotalCount} video
+ {liveChatTotalCount === 1 ? "" : "s"} — try scope: Live chat.
+ </span>
+ )}
+ </div>
+ </form>
+
+ {channelOptions.length > 0 && (
+ <details open={!filtersCollapsed} className="-mt-3">
+ <summary
+ onClick={(e) => {
+ // Drive the open state from React rather than the browser's
+ // default toggle action. Avoids a spurious `toggle` event that
+ // fires when React first mounts <details open> after the
+ // manifest loads, which would clobber the persisted value.
+ e.preventDefault();
+ toggleFiltersCollapsed();
+ }}
+ className="cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted-foreground"
+ >
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Filters
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {selectedChannelNames.length} of {channelOptions.length} channel
+ {channelOptions.length === 1 ? "" : "s"}
+ </span>
+ {filtersCollapsed && selectedChannelNames.length > 0 && (
+ <span
+ className="text-xs text-muted-foreground truncate min-w-0 flex-1"
+ title={selectedChannelsLabel}
+ data-testid="filters-summary-channels"
+ >
+ {selectedChannelsLabel}
+ </span>
+ )}
+ </summary>
+ <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
+ {channelOptions.length > 1 && (
+ <div className="flex flex-col gap-2 w-full">
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Channels
+ </span>
+ <ChannelAllToggle
+ channelOptions={channelOptions}
+ draftExcludedChannels={draftExcludedChannels}
+ setDraftExcludedChannels={setDraftExcludedChannels}
+ />
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={handleResetChannels}
+ className="text-xs text-muted-foreground"
+ >
+ Reset channels
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={handleResetAllFilters}
+ className="text-xs text-muted-foreground"
+ >
+ Reset all filters
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={handleResetEverything}
+ data-testid="reset-everything"
+ className="text-xs text-muted-foreground"
+ >
+ Reset everything
+ </Button>
+ </div>
+ <div className="flex flex-col gap-1.5">
+ {channelGroupings.map(({ group, channels }) => {
+ const isOpen = !collapsedGroups.has(group.id);
+ const selectedCount = channels.reduce(
+ (n, name) =>
+ draftExcludedChannels.has(name) ? n : n + 1,
+ 0,
+ );
+ return (
+ <details
+ key={group.id}
+ open={isOpen}
+ onToggle={(e) =>
+ toggleGroupCollapsed(
+ group.id,
+ (e.target as HTMLDetailsElement).open,
+ )
+ }
+ className="rounded border border-border bg-card/60"
+ >
+ <summary className="cursor-pointer select-none flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1.5 text-sm">
+ {group.name && (
+ <span className="font-medium text-foreground inline-flex items-center gap-1.5">
+ {group.accent && (
+ <span
+ aria-hidden="true"
+ className="inline-block size-2 shrink-0 rounded-full"
+ style={{ background: group.accent }}
+ />
+ )}
+ {group.name}
+ </span>
+ )}
+ <span className="text-xs text-muted-foreground">
+ ({selectedCount}/{channels.length} selected)
+ </span>
+ <span className="ml-auto flex items-center gap-2">
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setSelected(channels, true);
+ }}
+ className="text-xs text-muted-foreground"
+ >
+ All
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setSelected(channels, false);
+ }}
+ className="text-xs text-muted-foreground"
+ >
+ None
+ </Button>
+ </span>
+ </summary>
+ {group.description && (
+ <div className="px-3 pt-0 pb-1 text-xs text-muted-foreground">
+ {group.description}
+ </div>
+ )}
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-1 pb-2">
+ {channels.map((key) => {
+ const checked = !draftExcludedChannels.has(key);
+ return (
+ <label
+ key={key}
+ className="flex items-center gap-1.5 select-none"
+ >
+ <Checkbox
+ checked={checked}
+ onCheckedChange={(value) => {
+ setDraftExcludedChannels((prev) => {
+ const next = new Set(prev);
+ if (value === true) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ }}
+ />
+ {channelLabelByKey.get(key) ?? key}
+ </label>
+ );
+ })}
+ </div>
+ </details>
+ );
+ })}
+ </div>
+ </div>
+ )}
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Type
+ </span>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNov}
+ onCheckedChange={(value) => setDraftNov(value !== true)}
+ />
+ Videos
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNol}
+ onCheckedChange={(value) => setDraftNol(value !== true)}
+ />
+ Livestreams
+ </label>
+ </div>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Audience
+ </span>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNaa}
+ onCheckedChange={(value) => setDraftNaa(value !== true)}
+ />
+ All ages
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNar}
+ onCheckedChange={(value) => setDraftNar(value !== true)}
+ />
+ Age-restricted
+ </label>
+ </div>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Availability
+ </span>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNav}
+ onCheckedChange={(value) => setDraftNav(value !== true)}
+ />
+ Available
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNu}
+ onCheckedChange={(value) => setDraftNu(value !== true)}
+ />
+ Unlisted
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNd}
+ onCheckedChange={(value) => setDraftNd(value !== true)}
+ />
+ Deleted
+ </label>
+ </div>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Date
+ </span>
+ <label className="flex items-center gap-1.5 select-none">
+ From
+ <input
+ type="date"
+ data-testid="date-from"
+ value={ymdToInput(draftDateFrom)}
+ onChange={(e) =>
+ setDraftDateFrom(inputToYmd(e.target.value) ?? "")
+ }
+ className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm"
+ />
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ To
+ <input
+ type="date"
+ data-testid="date-to"
+ value={ymdToInput(draftDateTo)}
+ onChange={(e) =>
+ setDraftDateTo(inputToYmd(e.target.value) ?? "")
+ }
+ className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm"
+ />
+ </label>
+ </div>
+ </div>
+ </details>
+ )}
+
+ <details className="-mt-3 text-sm text-muted-foreground">
+ <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
+ Advanced options
+ </summary>
+ <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-border bg-card/60">
+ <LimitField
+ label="Max hits per batch"
+ hint="Pause after this many matches; “Show more” adds another batch."
+ value={hitBatchValue}
+ unlimited={hitBatchUnlimited}
+ defaultValue={DEFAULT_MAX_HITS}
+ onValueChange={setHitBatchValue}
+ onUnlimitedChange={setHitBatchUnlimited}
+ />
+ <NumberField
+ label="Fetch concurrency"
+ hint="Parallel transcript downloads while searching. Applies on next search."
+ value={fetchConcurrency}
+ defaultValue={DEFAULT_FETCH_CONCURRENCY}
+ min={1}
+ max={32}
+ onChange={setFetchConcurrency}
+ />
+ <NumberField
+ label="Flush interval (ms)"
+ hint="UI update cadence during search. Applies on next search."
+ value={flushIntervalMs}
+ defaultValue={DEFAULT_FLUSH_INTERVAL_MS}
+ min={16}
+ max={2000}
+ onChange={setFlushIntervalMs}
+ />
+ <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setHitBatchUnlimited(true);
+ setHitLimit(Infinity);
+ }}
+ className="text-xs"
+ >
+ Search through all transcripts
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setHitBatchValue(DEFAULT_MAX_HITS);
+ setHitBatchUnlimited(false);
+ setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY);
+ setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS);
+ clearAdvanced();
+ }}
+ className="text-xs"
+ >
+ Reset to defaults
+ </Button>
+ </div>
+ </div>
+ </details>
+ </div>
+ );
+}
+
+function ProfilesRow({
+ profileNames,
+ activeProfileName,
+ profileDirty,
+ shareCopied,
+ onSelect,
+ onSave,
+ onSaveAs,
+ onRename,
+ onDelete,
+ onRevert,
+ onShareCurrentSearch,
+}: {
+ profileNames: string[];
+ activeProfileName: string | null;
+ profileDirty: boolean;
+ shareCopied: boolean;
+ onSelect: (name: string) => void;
+ onSave: () => void;
+ onSaveAs: () => void;
+ onRename: () => void;
+ onDelete: () => void;
+ onRevert: () => void;
+ onShareCurrentSearch: () => void;
+}) {
+ const showRevert = activeProfileName != null && profileDirty;
+ return (
+ <div
+ className="flex flex-wrap items-center gap-x-3 gap-y-1"
+ data-testid="profiles-row"
+ >
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Profile
+ </span>
+ <select
+ value={activeProfileName ?? ""}
+ onChange={(e) => onSelect(e.target.value)}
+ data-testid="profile-select"
+ className="rounded border border-border bg-card text-foreground px-2 py-0.5 text-sm"
+ >
+ <option value="">(unsaved)</option>
+ {profileNames.map((n) => (
+ <option key={n} value={n}>
+ {n}
+ </option>
+ ))}
+ </select>
+ {profileDirty && (
+ <span
+ role="status"
+ aria-label="profile has unsaved changes"
+ data-testid="profile-dirty-dot"
+ className="text-base leading-none text-warning"
+ >
+ •
+ </span>
+ )}
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onSave}
+ disabled={!activeProfileName || !profileDirty}
+ className="text-xs text-muted-foreground"
+ >
+ Save
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onSaveAs}
+ className="text-xs text-muted-foreground"
+ >
+ Save as…
+ </Button>
+ {showRevert && (
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onRevert}
+ data-testid="profile-revert"
+ className="text-xs text-muted-foreground"
+ >
+ Revert
+ </Button>
+ )}
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onRename}
+ disabled={!activeProfileName}
+ className="text-xs text-muted-foreground"
+ >
+ Rename
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onDelete}
+ disabled={!activeProfileName}
+ className="text-xs text-destructive"
+ >
+ Delete
+ </Button>
+ <div className="ml-auto flex items-center gap-x-3">
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={onShareCurrentSearch}
+ title={
+ shareCopied
+ ? "Copied!"
+ : "Copy link encoding the current search + filters"
+ }
+ className="text-xs text-muted-foreground"
+ >
+ {shareCopied ? "Link copied!" : "Share current search"}
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function ChannelAllToggle({
+ channelOptions,
+ draftExcludedChannels,
+ setDraftExcludedChannels,
+}: {
+ channelOptions: string[];
+ draftExcludedChannels: Set<string>;
+ setDraftExcludedChannels: (next: Set<string>) => void;
+}) {
+ const allChecked = draftExcludedChannels.size === 0;
+ return (
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={allChecked}
+ onCheckedChange={() => {
+ setDraftExcludedChannels(
+ allChecked ? new Set(channelOptions) : new Set(),
+ );
+ }}
+ />
+ All
+ </label>
+ );
+}
+
+function NumberField({
+ label,
+ hint,
+ value,
+ defaultValue,
+ min,
+ max,
+ onChange,
+ disabled,
+}: {
+ label: string;
+ hint?: string;
+ value: number;
+ defaultValue: number;
+ min: number;
+ max: number;
+ onChange: (n: number) => void;
+ disabled?: boolean;
+}) {
+ return (
+ <label className="flex flex-col gap-1">
+ <span className="text-xs font-medium text-foreground">
+ {label}
+ </span>
+ <input
+ type="number"
+ min={min}
+ max={max}
+ value={Number.isFinite(value) ? value : ""}
+ placeholder={String(defaultValue)}
+ disabled={disabled}
+ onChange={(e) => {
+ const raw = e.target.value;
+ if (raw === "") {
+ onChange(defaultValue);
+ return;
+ }
+ const n = Math.max(min, Math.min(max, Math.floor(Number(raw))));
+ if (Number.isFinite(n)) onChange(n);
+ }}
+ className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50"
+ />
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
+ </label>
+ );
+}
+
+function LimitField({
+ label,
+ hint,
+ value,
+ unlimited,
+ defaultValue,
+ onValueChange,
+ onUnlimitedChange,
+}: {
+ label: string;
+ hint?: string;
+ value: number;
+ unlimited: boolean;
+ defaultValue: number;
+ onValueChange: (n: number) => void;
+ onUnlimitedChange: (b: boolean) => void;
+}) {
+ return (
+ <div className="flex flex-col gap-1">
+ <div className="flex items-baseline justify-between gap-2">
+ <span className="text-xs font-medium text-foreground">
+ {label}
+ </span>
+ <label className="flex items-center gap-1 text-xs select-none">
+ <Checkbox
+ checked={unlimited}
+ onCheckedChange={(value) => onUnlimitedChange(value === true)}
+ />
+ No limit
+ </label>
+ </div>
+ <input
+ type="number"
+ aria-label={label}
+ min={1}
+ max={1_000_000}
+ value={unlimited ? "" : value}
+ placeholder={unlimited ? "∞" : String(defaultValue)}
+ disabled={unlimited}
+ onChange={(e) => {
+ const raw = e.target.value;
+ if (raw === "") {
+ onValueChange(defaultValue);
+ return;
+ }
+ const n = Math.max(1, Math.floor(Number(raw)));
+ if (Number.isFinite(n)) onValueChange(n);
+ }}
+ className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50"
+ />
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
+ </div>
+ );
+}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Search and "Ask AI" are now one workspace — the search bar stays put when you switch between them.** Previously `/` (search) and `/ask` (chat) were separate pages, and navigating from one to the other threw away your search. They now share a single shell: the search bar and its results live in a persistent layout, with a **Results ⇄ Chat** switch between the two views. Run a search, flip to Chat to ask about it, flip back — your search is exactly where you left it. Under the hood the search state was lifted out of the monolithic search component into a shared `SearchSession` (both views read the same committed search), so it's the one source of truth features build on. Hub mode is unchanged. See `common/components/{SearchSessionContext,WorkspaceSearchBar,SearchResults,TranscriptSearch}.tsx`, `export/app/(workspace)/*`, and `export/e2e/workspace-shell.spec.ts`.
- **Handing a search to "Ask AI" now passes the *whole* result set, not just the first 20.** Previously only the top 20 videos reached the chat, so it literally couldn't see the rest. Now every match is handed off (up to a generous cap) and presented in tiers: the model gets a compact **index of all matching videos** (title, channel, date, hit count) plus **full excerpts for the top ~12** — and it can pull excerpts for any other result on demand via the existing *fetch_context* tool, or you can click **Load context** on any of them. This keeps the payload bounded while letting the assistant reason over the complete set. See `common/lib/aiHandoff.ts` (tiered `buildSearchHandoff`) and `export/app/lib/askConversation.ts` (`buildTieredGrounding`).
- **"Ask AI" chat — regenerate, edit & resend, and per-answer attribution.** You can now **Regenerate** a completed answer (reusing the excerpts it already gathered), **Edit** any earlier question to pull it back into the composer and re-ask from that point, and see **which model** produced each answer — useful when you switch providers mid-conversation. The Context panel now shows a rough **token estimate** (not just a character count) as a cost cue, a hand-typed model name survives switching providers and back within a session, and a failed "Load context" fetch now says so on that result instead of the spinner just quietly stopping. See `export/app/ask/{MessageBubble,AskChat,PinnedResultsPanel,ContextPanel,useAskChat}.tsx` and `export/app/lib/askConversation.ts`.
- **"Ask AI" chat polish — fewer dead ends, clearer feedback.** Several first-use rough edges are fixed: the provider settings panel no longer collapses out from under you the moment you start typing your API key; **Enter** now sends (with **Shift+Enter** for a new line); a message that stops or fails part-way through streaming now keeps its actions (Copy, Retry) and still shows what was searched, instead of freezing as raw text with no way forward; a request that errors mid-answer now shows *what* went wrong appended to whatever streamed, rather than silently dropping the error; an empty answer says so (with a Retry) instead of rendering nothing; the pre-answer "writing…" indicator no longer double-renders with an empty bubble; a fast double-press can no longer fire two turns at once; and the streaming answer is announced to screen readers. See `export/app/ask/{ProviderSettings,Composer,MessageBubble,useAskChat}.tsx`.
diff --git a/export/app/(workspace)/SiteWorkspace.tsx b/export/app/(workspace)/SiteWorkspace.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+// The single-site workspace shell. It mounts the corpus data source, the shared
+// SearchSession, and the PERSISTENT search bar ABOVE the routed content, then
+// renders {children} (the results on `/`, the chat on `/ask`). Because a shared
+// layout stays mounted across navigation between its child routes, the bar and
+// the committed search survive `/` ⇄ `/ask` — the whole point of the shell.
+//
+// Hub mode does NOT use this: its layout passes children straight through so the
+// self-contained <HubHome/> / <AskHub/> keep their existing MultiSiteDataProvider
+// wiring untouched.
+
+import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
+import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
+import { SingleSiteDataProvider } from "yt-dlp-transcript-common/components/SearchDataContext";
+import { SearchSessionProvider } from "yt-dlp-transcript-common/components/SearchSessionContext";
+import WorkspaceSearchBar from "yt-dlp-transcript-common/components/WorkspaceSearchBar";
+import WorkspaceNav from "./WorkspaceNav";
+
+export default function SiteWorkspace({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+ <PlayerProvider>
+ <SingleSiteDataProvider>
+ <SearchSessionProvider>
+ <div className="flex flex-col gap-6">
+ <WorkspaceNav />
+ <WorkspaceSearchBar />
+ {children}
+ </div>
+ </SearchSessionProvider>
+ </SingleSiteDataProvider>
+ <TranscriptModal />
+ </PlayerProvider>
+ );
+}
diff --git a/export/app/(workspace)/WorkspaceNav.tsx b/export/app/(workspace)/WorkspaceNav.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+// The Results ⇄ Chat switch for the shared workspace. Both destinations sit
+// under the same layout, so navigating between them keeps the search bar (and
+// its committed search) mounted — this is just real routing between the two
+// views of that one session. Trailing slashes match the static export's
+// trailingSlash:true.
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const ITEMS = [
+ { href: "/", label: "Results" },
+ { href: "/ask/", label: "Chat" },
+] as const;
+
+export default function WorkspaceNav() {
+ const pathname = usePathname();
+ const onAsk = pathname?.startsWith("/ask") ?? false;
+ return (
+ <div
+ role="group"
+ aria-label="Search or chat"
+ data-testid="workspace-nav"
+ className="inline-flex self-start overflow-hidden rounded-md border border-border bg-muted text-sm"
+ >
+ {ITEMS.map((item) => {
+ const active = item.href === "/ask/" ? onAsk : !onAsk;
+ return (
+ <Link
+ key={item.href}
+ href={item.href}
+ aria-current={active ? "page" : undefined}
+ className={`px-3 py-1.5 font-medium transition-colors ${
+ active
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ }`}
+ >
+ {item.label}
+ </Link>
+ );
+ })}
+ </div>
+ );
+}
diff --git a/export/app/(workspace)/ask/page.tsx b/export/app/(workspace)/ask/page.tsx
@@ -0,0 +1,48 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { currentSite } from "../../lib/site";
+import { instanceMode } from "../../lib/mode";
+import AskChat from "../../ask/AskChat";
+import AskHub from "../../ask/AskHub";
+
+export const metadata: Metadata = { title: "Ask AI" };
+
+// Static shell for the bring-your-own-key chat. All the work happens client-side
+// in <AskChat/> — retrieval over the published shards + a direct call to the
+// visitor's chosen provider. The export stays fully static.
+//
+// In single-site mode the corpus data source (SingleSiteDataProvider) lives in
+// the shared workspace layout — alongside the persistent search bar — so this
+// route renders just the chat and inherits the same session the results page
+// uses. Hub mode is passed through the layout untouched, so <AskHub/> keeps its
+// own MultiSiteDataProvider wiring.
+export default function AskPage() {
+ const site = currentSite();
+ const isHub = instanceMode() === "hub";
+ const scope = isHub ? "the federation" : "the transcripts";
+
+ return (
+ <div className="mx-auto flex max-w-3xl flex-col gap-6">
+ <header className="flex flex-col gap-2 border-b border-border pb-5">
+ <p className="font-mono text-xs uppercase tracking-[0.18em] text-brand">
+ Ask AI · {site.headerTitle}
+ </p>
+ <h1 className="font-display text-3xl font-semibold leading-tight text-foreground">
+ Ask a question about {scope}
+ </h1>
+ <p className="max-w-prose text-sm text-muted-foreground">
+ Bring your own API key. The chat searches the transcripts in your
+ browser, sends the relevant excerpts to your chosen AI, and answers with
+ citations. Nothing is hosted here — your key and the requests stay
+ between your browser and the provider. See{" "}
+ <Link href="/use-with-ai" className="text-brand hover:underline">
+ Use with AI
+ </Link>{" "}
+ for other ways to use this archive.
+ </p>
+ </header>
+
+ {isHub ? <AskHub /> : <AskChat />}
+ </div>
+ );
+}
diff --git a/export/app/(workspace)/layout.tsx b/export/app/(workspace)/layout.tsx
@@ -0,0 +1,18 @@
+import { instanceMode } from "../lib/mode";
+import SiteWorkspace from "./SiteWorkspace";
+
+// Shared shell for the two workspace routes — `/` (results) and `/ask` (chat).
+// A route group ("(workspace)") leaves both URLs unchanged but gives them one
+// layout, so the persistent search bar mounted in the single-site shell below
+// survives navigation between them.
+//
+// Hub mode keeps its own self-contained pages (<HubHome/>, <AskHub/>), so here
+// it's a straight pass-through — the fork is preserved exactly as before.
+export default function WorkspaceLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ if (instanceMode() === "hub") return <>{children}</>;
+ return <SiteWorkspace>{children}</SiteWorkspace>;
+}
diff --git a/export/app/(workspace)/page.tsx b/export/app/(workspace)/page.tsx
@@ -0,0 +1,37 @@
+import { countTranscripts } from "yt-dlp-transcript-common/lib/transcripts";
+import SearchResults from "yt-dlp-transcript-common/components/SearchResults";
+import { currentSite } from "../lib/site";
+import { instanceMode } from "../lib/mode";
+import NewSinceLastVisit from "../components/NewSinceLastVisit";
+import HubHome from "../components/hub/HubHome";
+
+export default async function Home() {
+ // Hub mode renders the federated shelf + cross-site search and reads no local
+ // summaries manifest (there is none), so branch before countTranscripts().
+ // The workspace layout passes hub children straight through, so <HubHome/>
+ // stays fully self-contained (its own PlayerProvider + MultiSiteDataProvider).
+ if (instanceMode() === "hub") return <HubHome />;
+
+ const settings = currentSite();
+ const count = await countTranscripts();
+
+ // The corpus data source, SearchSession, and the persistent search bar all
+ // live in the workspace shell (layout). This page renders only the "results"
+ // half of the workspace: the transcript count header and the results view,
+ // both reading the shared session.
+ return (
+ <div className="flex flex-col gap-6">
+ <div className="flex flex-col gap-1">
+ <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground">
+ <span className="tabular-nums">{count.toLocaleString()}</span>{" "}
+ <span className="font-normal text-muted-foreground">transcripts</span>
+ </h1>
+ {settings.homeTagline && (
+ <p className="text-sm text-muted-foreground">{settings.homeTagline}</p>
+ )}
+ </div>
+ <NewSinceLastVisit siteId={settings.siteId} count={count} />
+ <SearchResults />
+ </div>
+ );
+}
diff --git a/export/app/ask/page.tsx b/export/app/ask/page.tsx
@@ -1,49 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { SingleSiteDataProvider } from "yt-dlp-transcript-common/components/SearchDataContext";
-import { currentSite } from "../lib/site";
-import { instanceMode } from "../lib/mode";
-import AskChat from "./AskChat";
-import AskHub from "./AskHub";
-
-export const metadata: Metadata = { title: "Ask AI" };
-
-// Static shell for the bring-your-own-key chat. All the work happens client-side
-// in <AskChat/> — retrieval over the published shards + a direct call to the
-// visitor's chosen provider. The export stays fully static.
-export default function AskPage() {
- const site = currentSite();
- const isHub = instanceMode() === "hub";
- const scope = isHub ? "the federation" : "the transcripts";
-
- return (
- <div className="mx-auto flex max-w-3xl flex-col gap-6">
- <header className="flex flex-col gap-2 border-b border-border pb-5">
- <p className="font-mono text-xs uppercase tracking-[0.18em] text-brand">
- Ask AI · {site.headerTitle}
- </p>
- <h1 className="font-display text-3xl font-semibold leading-tight text-foreground">
- Ask a question about {scope}
- </h1>
- <p className="max-w-prose text-sm text-muted-foreground">
- Bring your own API key. The chat searches the transcripts in your
- browser, sends the relevant excerpts to your chosen AI, and answers with
- citations. Nothing is hosted here — your key and the requests stay
- between your browser and the provider. See{" "}
- <Link href="/use-with-ai" className="text-brand hover:underline">
- Use with AI
- </Link>{" "}
- for other ways to use this archive.
- </p>
- </header>
-
- {isHub ? (
- <AskHub />
- ) : (
- <SingleSiteDataProvider>
- <AskChat />
- </SingleSiteDataProvider>
- )}
- </div>
- );
-}
diff --git a/export/app/page.tsx b/export/app/page.tsx
@@ -1,39 +0,0 @@
-import { countTranscripts } from "yt-dlp-transcript-common/lib/transcripts";
-import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
-import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
-import TranscriptSearch from "yt-dlp-transcript-common/components/TranscriptSearch";
-import { SingleSiteDataProvider } from "yt-dlp-transcript-common/components/SearchDataContext";
-import { currentSite } from "./lib/site";
-import { instanceMode } from "./lib/mode";
-import NewSinceLastVisit from "./components/NewSinceLastVisit";
-import HubHome from "./components/hub/HubHome";
-
-export default async function Home() {
- // Hub mode renders the federated shelf + cross-site search and reads no local
- // summaries manifest (there is none), so branch before countTranscripts().
- if (instanceMode() === "hub") return <HubHome />;
-
- const settings = currentSite();
- const count = await countTranscripts();
-
- return (
- <PlayerProvider>
- <div className="flex flex-col gap-6">
- <div className="flex flex-col gap-1">
- <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground">
- <span className="tabular-nums">{count.toLocaleString()}</span>{" "}
- <span className="font-normal text-muted-foreground">transcripts</span>
- </h1>
- {settings.homeTagline && (
- <p className="text-sm text-muted-foreground">{settings.homeTagline}</p>
- )}
- </div>
- <NewSinceLastVisit siteId={settings.siteId} count={count} />
- <SingleSiteDataProvider>
- <TranscriptSearch />
- </SingleSiteDataProvider>
- </div>
- <TranscriptModal />
- </PlayerProvider>
- );
-}
diff --git a/export/e2e/ask-chat.spec.ts b/export/e2e/ask-chat.spec.ts
@@ -203,8 +203,7 @@ test.describe("ask chat", () => {
});
});
await page.goto("/ask/");
- await page.getByRole("button", { name: "Scripted" }).click();
- await page.locator('input[placeholder^="sk-ant"]').fill("sk-ant-test");
+ await keyIn(page, "Scripted");
await ask(page, "tell me about the alpha discussion");
// The answer failed → an error turn with a Retry action.
@@ -265,8 +264,15 @@ test.describe("ask chat", () => {
}
async function keyIn(page: Page, mode: "Scripted" | "Native tools" = "Scripted") {
+ // Fill the key FIRST and confirm it sticks: a value that survives proves
+ // React has hydrated and attached its handlers, so the mode-button click
+ // that follows isn't lost to a pre-hydration no-op. The persistent workspace
+ // search bar makes /ask heavier to hydrate under `next dev`, widening that
+ // window — this mirrors the guard already used in setup().
+ const key = page.locator('input[placeholder^="sk-ant"]');
+ await key.fill("sk-ant-test");
+ await expect(key).toHaveValue("sk-ant-test");
await page.getByRole("button", { name: mode }).click();
- await page.locator('input[placeholder^="sk-ant"]').fill("sk-ant-test");
}
// A hand-off whose video key resolves against the transcript fixtures
@@ -618,8 +624,7 @@ test.describe("ask chat", () => {
});
});
await page.goto("/ask/");
- await page.getByRole("button", { name: "Scripted" }).click();
- await page.locator('input[placeholder^="sk-ant"]').fill("sk-ant-test");
+ await keyIn(page, "Scripted");
await ask(page, "first question");
await expect(page.getByText("answer with a point", { exact: false }).first()).toBeVisible();
diff --git a/export/e2e/workspace-shell.spec.ts b/export/e2e/workspace-shell.spec.ts
@@ -0,0 +1,71 @@
+import { expect, test } from "@playwright/test";
+import {
+ CHANNEL_SLUG,
+ VIDEO_CHAT_LARGE,
+ VIDEO_CHAT_SMALL,
+ VIDEO_TRANSCRIPT_ONLY,
+} from "./fixtures/data";
+import { installRoutes } from "./helpers";
+
+// The unified workspace shell: `/` (results) and `/ask` (chat) share one layout,
+// so the persistent search bar and its committed search survive navigation
+// between the two views. These tests prove that persistence — the whole point of
+// the shell — which no per-page test covered before.
+
+const qt = (tree: unknown) => encodeURIComponent(JSON.stringify(tree));
+
+const ALPHA_TREE = {
+ k: "g",
+ o: "AND",
+ c: [{ k: "l", q: "alpha", s: "transcripts" }],
+};
+
+const ALPHA_SLUGS = [
+ `${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`,
+ `${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`,
+ `${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}`,
+];
+
+test.describe("workspace shell", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("the committed search persists across Results ⇄ Chat navigation", async ({
+ page,
+ }) => {
+ await page.goto(`/?qt=${qt(ALPHA_TREE)}`);
+ // Results render on the results view.
+ await expect(page.locator("[data-card-header]")).toHaveCount(ALPHA_SLUGS.length);
+
+ const nav = page.getByTestId("workspace-nav");
+ // Navigate to the chat via the workspace nav (real client-side routing).
+ await nav.getByRole("link", { name: "Chat" }).click();
+ await expect(page).toHaveURL(/\/ask\/?$/);
+ await expect(page.getByPlaceholder(/Ask about the transcripts/)).toBeVisible();
+ // The persistent bar is still mounted — its query-builder leaf keeps the term.
+ await expect(
+ page.locator('input[data-testid^="leaf-query-"]').first(),
+ ).toHaveValue("alpha");
+
+ // Navigate back to results — the search is preserved (no ?qt re-seed needed).
+ await nav.getByRole("link", { name: "Results" }).click();
+ await expect(page).not.toHaveURL(/\/ask/);
+ await expect(page.locator("[data-card-header]")).toHaveCount(ALPHA_SLUGS.length);
+ });
+
+ test("the workspace nav marks the active view", async ({ page }) => {
+ await page.goto("/");
+ const nav = page.getByTestId("workspace-nav");
+ await expect(nav.getByRole("link", { name: "Results" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ await nav.getByRole("link", { name: "Chat" }).click();
+ await expect(page.getByPlaceholder(/Ask about the transcripts/)).toBeVisible();
+ await expect(nav.getByRole("link", { name: "Chat" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ });
+});