"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 { usePlayer } from "./PlayerProvider"; import { useChannelSubsManifests } from "./subsCache"; import { peekPost, useChannelPostsManifests } from "./postsCache"; import { useSearchData } from "./SearchDataContext"; import { searchRuntime, type LayerHit } from "./searchPipeline"; import { runQueryTree, type GroupState, type LeafState, type TreeController, type TreeProgress, } from "../lib/searchEval"; import { applySearchIn, canonicalHash, emptyRoot, forEachLeaf, isNodeActive, parseRoot, rootFromLegacy, searchInReadsNothing, searchInUnderTags, stringifyRoot, type GroupNode, type LayerScope, type SearchIn, } from "../lib/searchQuery"; import { foldSearchIn } from "../lib/search/searchIn"; import { VIDEO_STATES, summaryState, type VideoState, } from "../lib/availability"; import { useUrlParams, writeUrlParams } from "./urlState"; import { FILTER_URL_KEYS_V1, buildShareSearchParams, hasShareV1, parseShareV1, stripAllFilterParamsFromUrl, type ShareSelection, } from "./shareUrl"; import { emptySnapshot, emptyStoredState, loadStoredState, saveStoredState, snapshotStates, snapshotsEqual, statesFromLegacy, writeSnapshotStates, 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 { isTagId } from "../lib/curatedTags"; import type { Post } from "../lib/posts"; import { makeId, splitId } from "./originId"; import { sortGroups, type ChannelGroup } from "../lib/channelGroups"; import { buildSearchHandoff, type SearchHandoff } from "../lib/aiHandoff"; import { rankByUploadDateDesc } from "../lib/search/rank"; import { pruneSelection, selectGroups } from "../lib/selection"; 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; // Presence on the source platform, for the card's state badge. Posts are // always "available" — a post has no channel listing to fall out of. state: VideoState; // Carried through so the card can flag likely-expired stream VODs (Kick, // Twitch) via vodExpiry(platform, uploadDate). platform: Platform; uploadDate: string; hits: LayerHit[]; // The operator's curated tag ids on this record (lib/curatedTags.ts), when it // carries any — so a card can show what it was tagged as. Absent on posts, on // untagged videos, and on every record from a site built before corpus spec // 4. Labels come from the site's /tags.json; the id is the fallback. curatedTags?: string[]; // Provenance accent of the source origin (hub mode only); undefined // single-site, so no marker renders. accent?: string; // The source archive's title (hub mode only), named on the card beside the // channel so provenance is not carried by colour alone. Absent single-site. source?: string; // Set on rows from the social-post corpus. A post is not a video: it has no // timeline to seek, no livestream/age state and no VOD expiry, so the result // card renders a distinct variant rather than a degraded video card. post?: Post; }; // A leaf's original terms so result rows can look up the query for // 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; // The words the Filters panel's "Search in" row and the Search button's // description say while nothing the site offers is ticked. export const SEARCH_IN_REFUSAL = "Search in: pick at least one"; export const DEFAULT_FETCH_CONCURRENCY = 6; export const DEFAULT_FLUSH_INTERVAL_MS = 120; // Whole-corpus sweep: hit excerpts carried per video when exposing the FULL // matched set (getFullGrounding). Small so a large set stays token-bounded, but // enough to reason over each video within its batch (vs. liveGrounding's tiered // single-snippet tail). export const SWEEP_HITS_PER_VIDEO = 4; // What the AI is grounded in: the whole committed search, or a hand-picked // selection of result videos (the grounding palette's toggle). An empty // selection always parks on "search" — see effectiveGroundingMode below. export type GroundingMode = "search" | "selection"; // The video-selection set is persisted lightly (just a slug array) under its own // key so a hand-picked grounding survives reload without touching the strict // export-filter parser. const SELECTION_KEY = "ytdlp-tb:selection"; // Two facts about this page life, each with one job. Module state, so both // survive client-side navigation and reset on a reload or a new visit. // // `ranThisPageLife`: a search has run — a commit (Search, Enter, a Filters // Apply), a profile load, or an active query arriving on the URL. A stored // query is held (see `runHeld`) while it is unset: on the page life's first // load, and on any later mount while nothing has run. Once it is set a later // mount runs the stored query — the hub mounts a fresh provider per route, and // `/` → `/ask` must keep grounding in the search the visitor just ran there. A // filter-only link does not set it: it shows its results, but it ran no query. // // `askedThisPageLife`: the visitor has asked — a search ran, or a link carries // a query or a filter (`urlAsks`). The results area shows nothing until it is // set (`searchedThisPageLife`, its reactive copy): until the visitor asks, the // page is the bar, the intro and the footer. let ranThisPageLife = false; let askedThisPageLife = false; function loadSelection(): string[] { if (typeof window === "undefined") return []; try { const raw = window.localStorage.getItem(SELECTION_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) { return parsed; } } catch { /* malformed → empty */ } return []; } function saveSelection(slugs: ReadonlySet): void { if (typeof window === "undefined") return; try { if (slugs.size === 0) window.localStorage.removeItem(SELECTION_KEY); else window.localStorage.setItem(SELECTION_KEY, JSON.stringify([...slugs])); } catch { /* quota — non-fatal */ } } // 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)); } // A link that carries a query (`qt`, legacy `q` / `m`) or a filter (`tg`, the // share-link keys, the legacy filter keys) is the visitor asking: it shows its // results on load, as a Search would (`askedThisPageLife`). A video (`v`, `t`) // or a chart's shape (`view`, `cs`) alone is not a search. const ASKING_URL_KEYS: readonly string[] = [ "qt", "q", "tg", ...FILTER_URL_KEYS, ...FILTER_URL_KEYS_V1, ]; function urlAsks(params: URLSearchParams): boolean { return ASKING_URL_KEYS.some((k) => params.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, defaultSelectedChannels: ReadonlySet, ): Set { const included = new Set(snapshot?.channels.included ?? []); const excluded = new Set(snapshot?.channels.excluded ?? []); const out = new Set(); 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, channelOptions: ReadonlyArray, defaultSelectedChannels: ReadonlySet, ): { 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(a: ReadonlySet, b: ReadonlySet): 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 >(() => new Set()); const [committedNov, setCommittedNov] = useState(false); const [committedNol, setCommittedNol] = useState(false); // Posts, the "Search in" row's second box (it was a third media kind in the // Type row until release 16): unticked, a plain query reads no posts. A leaf // of scope "Posts" reads them whatever it says. const [committedNop, setCommittedNop] = useState(false); // The rest of the "Search in" row (release 16) beside `nop`: what a plain // query — a "transcripts" leaf — reads. Stored as off-the-default booleans // like the others: `notr` = Transcripts unticked, `lc` = Live chat ticked. // The row is applied to the committed tree just before it runs // (`applySearchIn`); the tree itself, its hash and its `qt=` do not change. const [committedNotr, setCommittedNotr] = useState(false); const [committedLc, setCommittedLc] = useState(false); const [committedNaa, setCommittedNaa] = useState(false); const [committedNar, setCommittedNar] = useState(false); // Availability is a six-state enum (VideoState), not a set of "exclude X" // booleans: a video is available, or missing for one of five reasons. Kept as // the set of states to KEEP — absent means filtered out — because six // booleans threaded through commit/hydrate/persist/share would be untenable. // Default keeps everything. const [committedStates, setCommittedStates] = useState>( () => new Set(VIDEO_STATES), ); // Inclusive upload-date bounds ("YYYYMMDD"); "" => unbounded on that end. const [committedDateFrom, setCommittedDateFrom] = useState(""); const [committedDateTo, setCommittedDateTo] = useState(""); // Curated tag ids selected in the chip row, ORed: a video carrying ANY of // them passes. An EMPTY set is no filter at all — the row starts empty, and // on a site that publishes no /tags.json it is empty forever. const [committedTags, setCommittedTags] = useState>( () => new Set(), ); 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(emptyRoot); const [committedRoot, setCommittedRoot] = useState(emptyRoot); const draftHash = useMemo(() => canonicalHash(draftRoot), [draftRoot]); const committedHash = useMemo( () => canonicalHash(committedRoot), [committedRoot], ); // A query restored from localStorage is committed (so the profile, share // link and divergence logic all see the session exactly as it was left) but // HELD: the pipeline does not run it until the visitor acts — Search / Enter, // or a profile load. A restored query re-ran on every visit, refetching // transcript shards (up to 8 MB each) on a cold device for a search nobody // asked for this time. A query that arrives on the URL (`qt=`, legacy `q=`) // is never held: a shared link is the visitor asking. const [runHeld, setRunHeld] = useState(false); const queryDirty = runHeld || draftHash !== committedHash; const [draftExcludedChannels, setDraftExcludedChannels] = useState< Set >(() => new Set()); const [draftNov, setDraftNov] = useState(false); const [draftNop, setDraftNop] = useState(false); const [draftNotr, setDraftNotr] = useState(false); const [draftLc, setDraftLc] = useState(false); const [draftNol, setDraftNol] = useState(false); const [draftNaa, setDraftNaa] = useState(false); const [draftNar, setDraftNar] = useState(false); const [draftStates, setDraftStates] = useState>( () => new Set(VIDEO_STATES), ); const [draftDateFrom, setDraftDateFrom] = useState(""); const [draftDateTo, setDraftDateTo] = useState(""); const [draftTags, setDraftTags] = useState>( () => new Set(), ); const [hydrated, setHydrated] = useState(false); const [profiles, setProfiles] = useState>({}); const [activeProfileName, setActiveProfileName] = useState( null, ); const [collapsedGroups, setCollapsedGroups] = useState>( () => new Set(), ); const [filtersCollapsed, setFiltersCollapsed] = useState(false); // ── Grounding target (shared with /ask) ── // The hand-picked video-selection set (an INCLUSION set at video granularity, // mirroring the channel EXCLUSION set) + which target grounds the chat. Both // survive `/` ⇄ `/ask` (one session) and reload (localStorage). Restored in the // hydration effect below. const [selectedSlugs, setSelectedSlugs] = useState>( () => new Set(), ); const [groundingMode, setGroundingMode] = useState("search"); // 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(null); // The reactive copy of `askedThisPageLife`, which stays the source of truth // across remounts: a mount later in the page life starts from it, and every // place that sets it sets this too. False until the visitor asks, and the // results area renders nothing until then. False on the server, where the // module variable is never set, so the first client render matches. const [searchedThisPageLife, setSearchedThisPageLife] = useState( () => askedThisPageLife, ); // 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, postsManifest, manifestsSettled, channels: channelModel, groups: manifestGroups, channelKeyOf, accentOf, siteTitleOf, } = 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 hasPostsCorpus = (postsManifest?.channels.length ?? 0) > 0; // ─── Search in ──────────────────────────────────────────────────────────── // What the row ticks, as far as this site can honour it: Posts counts only // where the site ships posts and Live chat only where it has live chat (the // panel offers neither box otherwise), so a stored `lc` on a site without // chat asks for no manifest that is not there. const draftOffered = useMemo( () => ({ transcripts: !draftNotr, posts: !draftNop && hasPostsCorpus, chat: draftLc && hasSubs, }), [draftNotr, draftNop, draftLc, hasPostsCorpus, hasSubs], ); // What the rewrite reads: the same, less a posts copy under a tag filter, // which no post can pass (`searchInUnderTags`). const committedSearchIn = useMemo( () => searchInUnderTags( { transcripts: !committedNotr, posts: !committedNop && hasPostsCorpus, chat: committedLc && hasSubs, }, committedTags.size > 0, ), [committedNotr, committedNop, committedLc, hasPostsCorpus, hasSubs, committedTags], ); const draftSearchIn = useMemo( () => searchInUnderTags(draftOffered, draftTags.size > 0), [draftOffered, draftTags], ); // Nothing ticked that this site offers: Search, Enter, Apply and the profile // saves all refuse, and the panel says which row to fix. Only once the // manifests have answered: until then Posts and Live chat read as not // offered, and a stored row with Transcripts unticked would read as empty. // The run waits for them too (below), so the decision is held, not guessed. const draftSearchInEmpty = manifestsSettled && searchInReadsNothing(draftOffered); // The committed tree as it RUNS: every "transcripts" leaf rewritten to read // the kinds ticked. `committedRoot` stays the tree the visitor built — the // builder, the result cards' leaf sections and `qt=` read that one, and the // run's progress is folded back onto it (`foldSearchIn`). const committedTree = useMemo( () => applySearchIn(committedRoot, committedSearchIn), [committedRoot, committedSearchIn], ); const committedTreeHash = useMemo( () => canonicalHash(committedTree.root), [committedTree], ); const draftTreeRoot = useMemo( () => applySearchIn(draftRoot, draftSearchIn).root, [draftRoot, draftSearchIn], ); // Read from the rewritten trees, so a plain query with Live chat ticked // loads the subs manifests (and with Posts ticked, the posts manifests) // exactly as a leaf asked for by name always has. const draftHasChatLeaf = useMemo( () => anyLeafHasScope(draftTreeRoot, "chat"), [draftTreeRoot], ); const committedHasChatLeaf = useMemo( () => anyLeafHasScope(committedTree.root, "chat"), [committedTree], ); 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>(() => { const set = new Set(); 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(""), ]); // ─── Posts scope ────────────────────────────────────────────────────────── // Mirrors the chat-scope block above: only load the per-channel posts // manifests when a posts leaf is actually present, then flatten them into the // set of post slugs the tree may match. const draftHasPostsLeaf = useMemo( () => anyLeafHasScope(draftTreeRoot, "posts"), [draftTreeRoot], ); const committedHasPostsLeaf = useMemo( () => anyLeafHasScope(committedTree.root, "posts"), [committedTree], ); const needsPostsManifests = draftHasPostsLeaf || committedHasPostsLeaf; const postsRefs = useMemo( () => postsManifest ? postsManifest.channels.map((c) => { const { origin, slug } = splitId(c.slug); return { origin, channelSlug: slug, name: c.name }; }) : [], [postsManifest], ); const channelPostsQueries = useChannelPostsManifests( needsPostsManifests ? postsRefs : [], ); const postScopeSlugs = useMemo>(() => { const set = new Set(); if (!needsPostsManifests) return set; for (let i = 0; i < channelPostsQueries.length; i++) { const data = channelPostsQueries[i]?.data; const ref = postsRefs[i]; if (!data || !ref) continue; // Channel selection applies to posts exactly as it does to videos; the // date filter can't be applied here (a manifest carries no dates) and is // enforced on the result rows instead, where the post body is loaded. if ( committedChannels.has( channelKeyOf({ channel: ref.name, channelSlug: makeId(ref.origin, ref.channelSlug) }), ) ) { 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 }, [ needsPostsManifests, postsRefs, committedChannelsKey, channelKeyOf, channelPostsQueries.map((q) => (q.data ? 1 : 0)).join(""), ]); const postsManifestReady = !needsPostsManifests || postsRefs.length === 0 || channelPostsQueries.every((q) => q.data || q.isError); const subsManifestReady = !needsChatManifests || (subsManifest !== null && channelSubsQueries.length > 0 && channelSubsQueries.every((q) => q.data)); const [treeProgress, setTreeProgress] = useState(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(null); const { openTranscript } = usePlayer(); // Reset the live hit cap on each new committed query — including the same // query read in other kinds ("Search in"). useEffect(() => { setHitLimit(hitBatchSize); // eslint-disable-next-line react-hooks/exhaustive-deps }, [committedHash, committedTreeHash]); // ─── 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( () => channelModel.map((c) => c.key), [channelModel], ); const channelLabelByKey = useMemo>( () => 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(); 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>(() => { const out = new Set(); 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; if (!committedStates.has(summaryState(t))) return false; // tg — curated tags, ORed across the selection. Mirrors // lib/search/evalTree.ts:passesFilters, which is the same rule applied by // the MCP and the record-level evaluator; when one moves the other must. if (committedTags.size > 0) { const on = t.curatedTags; if (!on || on.length === 0) return false; if (!on.some((id) => committedTags.has(id))) return false; } return true; }; }, [ committedChannels, channelKeyOf, committedDateFrom, committedDateTo, committedNov, committedNol, committedNaa, committedNar, committedStates, committedTags, ]); const committedStatesKey = useMemo( () => VIDEO_STATES.filter((s) => committedStates.has(s)).join(","), [committedStates], ); // Sorted, so a selection built in a different click order is the same key and // does not re-run the pipeline. const committedTagsKey = useMemo( () => Array.from(committedTags).sort().join(" "), [committedTags], ); const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNop ? 1 : 0}|${committedNotr ? 1 : 0}|${committedLc ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedStatesKey}|${committedDateFrom}|${committedDateTo}|${committedTagsKey}`; // The scope universe is videos AND posts. The two namespaces are disjoint; // searchEval partitions them per-leaf (see EvalCtx.postScopeSlugs) so a posts // leaf never fetches a video and vice versa, while OR across the two still // unions into one result set. const globalScopeSlugs = useMemo(() => { if (!transcripts) return []; const out: string[] = []; for (const t of transcripts) if (passesFilter(t)) out.push(t.slug); // A post carries no curated tags, so a tag filter drops the whole posts // corpus rather than letting it through un-filtered — the same answer // passesFilter gives an untagged video. See tagFilteredPostScope below: // the DEFAULT scope is only half of it, because a `posts`-scope leaf // carries its own slug set past this. // // Not gated on `nop` (release 16, review M2): the Posts box governs the // plain query's posts copy, through `applySearchIn`, and a leaf of scope // "Posts" was asked for by name and reads posts whatever the row says. // `postScopeSlugs` is empty unless some leaf reads posts. if (committedTags.size === 0) { for (const slug of postScopeSlugs) out.push(slug); } return out; // eslint-disable-next-line react-hooks/exhaustive-deps }, [transcripts, filterKey, postScopeSlugs]); // The posts slug set as the PIPELINE sees it. A `posts`-scope leaf is fed // from here and not from the global scope above, so gating only the global // build left an explicit scopes:['posts'] leaf searching the whole posts // corpus while a tag chip was on: the rows were then dropped by the display // fold, but `totalHits` sums the pipeline's hits BEFORE that fold, so the // header read "3 videos, 8 hits" with hits nobody could see. // // `null` (not `[]`) is the "no posts in scope" signal the pipeline already // understands — the same value it gets when no leaf needs posts at all. const tagFilteredPostScope = useMemo | null>( () => (committedTags.size > 0 ? null : postScopeSlugs), [committedTags, postScopeSlugs], ); // False while a restored query is held (see `runHeld`): nothing fetches, and // the results area is empty until the visitor asks — or, when a filter-only // link asked, it lists every video under the link's filters. const hasActiveQuery = useMemo( () => !runHeld && isNodeActive(committedRoot), [committedRoot, runHeld], ); // ─── 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; // "Search in" counts Posts and Live chat only once the site's manifests // say it has them; a run before that would read the wrong kinds (with // Transcripts unticked, transcripts) and run again. Hold it. if (!manifestsSettled) return; if (needsChatManifests && !subsManifestReady) return; if (needsPostsManifests && !postsManifestReady) return; const controller = runQueryTree({ root: committedTree.root, runtime: searchRuntime, globalScope: globalScopeSlugs, summaries: transcripts, chatScopeSlugs: needsChatManifests ? chatScopeSlugs : null, postScopeSlugs: needsPostsManifests ? tagFilteredPostScope : null, initialHitLimit: hitLimit, concurrency: fetchConcurrency, flushIntervalMs, // The copies "Search in" made report under ids of their own; fold them // back under the leaf the visitor built before anything reads them. emit: (p) => setTreeProgress(foldSearchIn(p, committedTree)), }); 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, committedTreeHash, hasActiveQuery, transcripts, manifestsSettled, filterKey, needsChatManifests, subsManifestReady, needsPostsManifests, postsManifestReady, 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. // `{ source }` for a hub result, `{}` single-site — so a single-site group // carries no `source` key at all. const sourceOf = useCallback( (slug: string): { source?: string } => { const title = siteTitleOf?.(splitId(slug).origin); return title ? { source: title } : {}; }, [siteTitleOf], ); const resultGroups = useMemo(() => { 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, state: summaryState(t), platform: t.platform, uploadDate: t.uploadDate, hits: [], ...(t.curatedTags && t.curatedTags.length > 0 ? { curatedTags: t.curatedTags } : {}), accent: accentOf(splitId(t.slug).origin), ...sourceOf(t.slug), }); } return out; } if (!treeProgress) return []; const matched = treeProgress.slugs; const hitsBySlug = treeProgress.hits; const out: ResultGroup[] = []; const videoSlugs = new Set(); for (const t of transcripts) { if (!matched.has(t.slug)) continue; videoSlugs.add(t.slug); out.push({ slug: t.slug, title: t.title, channel: t.channel, date: t.date, isLivestream: t.isLivestream, ageRestricted: t.ageRestricted, state: summaryState(t), platform: t.platform, uploadDate: t.uploadDate, hits: hitsBySlug.get(t.slug) ?? [], ...(t.curatedTags && t.curatedTags.length > 0 ? { curatedTags: t.curatedTags } : {}), accent: accentOf(splitId(t.slug).origin), ...sourceOf(t.slug), }); } // Matched POST slugs. They are not in `transcripts` (a disjoint namespace), // so they're appended here from the posts page cache, which the pipeline // has necessarily already warmed for anything it matched. The date filter // is applied here rather than at scope-build time because a posts manifest // carries no dates. for (const slug of matched) { if (videoSlugs.has(slug)) continue; const post = peekPost(slug); if (!post) continue; if (committedDateFrom && post.uploadDate < committedDateFrom) continue; if (committedDateTo && post.uploadDate > committedDateTo) continue; // See globalScopeSlugs: a tag filter excludes posts, which carry none. if (committedTags.size > 0) continue; // A post is either still up or deleted at the source — the two states of // the six that can apply to it — so it participates in the same // availability filter videos use. const postState: VideoState = post.isDeleted ? "deleted" : "available"; if (!committedStates.has(postState)) continue; out.push({ slug, // A post has no title; the card renders its body, and the author // stands in for the channel. title: "", channel: post.authorName || post.author, date: post.createdAt, isLivestream: false, ageRestricted: false, state: postState, platform: post.platform, uploadDate: post.uploadDate, hits: hitsBySlug.get(slug) ?? [], accent: accentOf(splitId(slug).origin), ...sourceOf(slug), post, }); } // One newest-first ordering across BOTH corpora, so a unified search reads // as one result set rather than videos-then-posts. The comparator is // `lib/search/rank.ts` — same function the next consumer of this ordering // gets, which is the only reason a shared comparator is worth having. // Sorts in place and returns the same array, as the inline sort did. return rankByUploadDateDesc(out); }, [ hasActiveQuery, transcripts, treeProgress, passesFilter, accentOf, sourceOf, committedDateFrom, committedDateTo, committedStates, committedTags, ]); const leafStates = treeProgress?.leafStates ?? new Map(); const groupStates = treeProgress?.groupStates ?? new Map(); 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 || !manifestsSettled || (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 || draftNop !== committedNop || draftNotr !== committedNotr || draftLc !== committedLc || draftNol !== committedNol || draftNaa !== committedNaa || draftNar !== committedNar || !sameSet(draftStates, committedStates) || draftDateFrom !== committedDateFrom || draftDateTo !== committedDateTo || !sameSet(draftTags, committedTags); const committedSnapshot = useMemo(() => { const deltas = buildSnapshotDeltas( committedChannels, channelOptions, defaultSelectedChannels, ); const snap: FilterSnapshot = { channels: deltas }; if (committedNov) snap.nov = true; // `nop` was missing here, so a profile with Posts unticked always read as // changed against the committed state it had just loaded. if (committedNop) snap.nop = true; if (committedNotr) snap.notr = true; if (committedLc) snap.lc = true; if (committedNol) snap.nol = true; if (committedNaa) snap.naa = true; if (committedNar) snap.nar = true; writeSnapshotStates(snap, committedStates); if (committedDateFrom) snap.dateFrom = committedDateFrom; if (committedDateTo) snap.dateTo = committedDateTo; // Omitted when empty, so a profile saved on an untagged site stays // byte-identical to one saved before tags existed. if (committedTags.size > 0) snap.tg = Array.from(committedTags).sort(); snap.query = stringifyRoot(committedRoot); return snap; }, [ committedChannels, channelOptions, defaultSelectedChannels, committedNov, committedNop, committedNotr, committedLc, committedNol, committedNaa, committedNar, committedStates, committedDateFrom, committedDateTo, committedTags, committedRoot, ]); const buildDraftSnapshot = useCallback((): FilterSnapshot => { const snap: FilterSnapshot = { channels: buildSnapshotDeltas( draftExcludedChannels, channelOptions, defaultSelectedChannels, ), }; if (draftNov) snap.nov = true; if (draftNop) snap.nop = true; if (draftNotr) snap.notr = true; if (draftLc) snap.lc = true; if (draftNol) snap.nol = true; if (draftNaa) snap.naa = true; if (draftNar) snap.nar = true; writeSnapshotStates(snap, draftStates); if (draftDateFrom) snap.dateFrom = draftDateFrom; if (draftDateTo) snap.dateTo = draftDateTo; if (draftTags.size > 0) snap.tg = Array.from(draftTags).sort(); snap.query = stringifyRoot(draftRoot); return snap; }, [ draftExcludedChannels, channelOptions, defaultSelectedChannels, draftNov, draftNop, draftNotr, draftLc, draftNol, draftNaa, draftNar, draftStates, draftDateFrom, draftDateTo, draftTags, draftRoot, ]); const promoteDraftsToCommitted = useCallback(() => { setCommittedExcludedChannels(new Set(draftExcludedChannels)); setCommittedNov(draftNov); setCommittedNop(draftNop); setCommittedNotr(draftNotr); setCommittedLc(draftLc); setCommittedNol(draftNol); setCommittedNaa(draftNaa); setCommittedNar(draftNar); setCommittedStates(new Set(draftStates)); setCommittedDateFrom(draftDateFrom); setCommittedDateTo(draftDateTo); setCommittedTags(new Set(draftTags)); // `tg` survives this on purpose: it is not a legacy or share-v1 key, and // the effect below rewrites it from the committed selection. stripAllFilterParamsFromUrl(); }, [ draftExcludedChannels, draftNov, draftNop, draftNotr, draftLc, draftNol, draftNaa, draftNar, draftStates, draftDateFrom, draftDateTo, draftTags, ]); // ─── 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); if (typeof stored.filtersCollapsed === "boolean") { setFiltersCollapsed(stored.filtersCollapsed); } } if (stored?.collapsedGroups !== undefined) { // User preference honored (a stored [] — all groups open — stays). setCollapsedGroups(new Set(stored.collapsedGroups)); } else { // Inline groups render as loose chips with no collapse affordance, so // they're excluded from both the default-collapse set and the >1 // heuristic (a lone collapsible group among inline chips stays open). const collapsible = channelGroupings.filter((g) => !g.group.inline); if (collapsible.length > 1) { // Collapse state never touched on a multi-group site: start compact. // The chips still show selection state, so nothing important is // hidden. setCollapsedGroups(new Set(collapsible.map((g) => g.group.id))); } } 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 | null = null; let initialNov = false; let initialNol = false; let initialNaa = false; let initialNar = false; let initialStates: ReadonlySet = new Set(VIDEO_STATES); let initialDateFrom = ""; let initialDateTo = ""; // `tg` is read from the URL FIRST and independently of the share-v1 / // legacy branch below, because it is not part of either schema: it is the // live representation of the committed tag selection, so its presence on // the URL always wins and its absence falls through to the snapshot. // isTagId, not a bare non-empty check: the URL is an entry point like the // MCP's parseSearchArgs and must agree with it about what an id is. A // malformed token can only ever produce a silent empty result. const tagsFromUrl = params.getAll("tg").filter((t) => isTagId(t)); let initialTags: ReadonlySet | null = tagsFromUrl.length > 0 ? new Set(tagsFromUrl) : null; if (hasShareV1(search)) { const sel = parseShareV1(search, channelOptions); const excluded = new Set(); 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; initialStates = sel.states; 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"; // Legacy exclusion keys, still parsed so old links keep working. `nav` // meant "exclude available" back when unconfirmed/private/members-only // had no bucket of their own, so it drops maybe_missing with available: // those videos were counted as available when the link was written. initialStates = statesFromLegacy({ nav: params.get("nav") === "1", nd: params.get("nd") === "1", nu: params.get("nu") === "1", }); } let resolvedRoot = rootFromUrl; // True only when the query came from the stored snapshot, never the URL. let holdRestored = false; // The stored profile / working snapshot. const snapshot = stored?.activeProfileName != null ? (stored.profiles[stored.activeProfileName] ?? stored.working) : (stored?.working ?? null); // The "Search in" row always comes from here, whatever the URL carries: no // link carries it (share-v1 has no key for it, by ruling), so a shared // link reads with the visitor's own row. Before the row, `nop` was read // from nowhere, and a stored Posts-unticked came back ticked on reload. const initialNop = snapshot?.nop === true; const initialNotr = snapshot?.notr === true; const initialLc = snapshot?.lc === true; if (!initialExcluded) { // Fall back to stored profile / working snapshot. const excluded = snapshotToExcluded( snapshot, channelOptions, defaultSelectedChannels, ); initialExcluded = excluded; initialNov = snapshot?.nov === true; initialNol = snapshot?.nol === true; initialNaa = snapshot?.naa === true; initialNar = snapshot?.nar === true; initialStates = snapshotStates(snapshot); initialDateFrom = snapshot?.dateFrom ?? ""; initialDateTo = snapshot?.dateTo ?? ""; initialTags ??= new Set(snapshot?.tg ?? []); if (!resolvedRoot && snapshot?.query) { resolvedRoot = parseRoot(snapshot.query); holdRestored = !ranThisPageLife && resolvedRoot != null && isNodeActive(resolvedRoot); } } if (resolvedRoot) { setDraftRoot(resolvedRoot); setCommittedRoot(resolvedRoot); if (holdRestored) setRunHeld(true); } // After `holdRestored` is decided. Only an active URL query counts as a // run; a filter-only link asks (its results show) without releasing a // stored query on this mount or a later one. if (rootFromUrl && isNodeActive(rootFromUrl)) ranThisPageLife = true; if (ranThisPageLife || urlAsks(params)) askedThisPageLife = true; setSearchedThisPageLife(askedThisPageLife); setDraftExcludedChannels(initialExcluded); setDraftNov(initialNov); setDraftNop(initialNop); setDraftNotr(initialNotr); setDraftLc(initialLc); setDraftNol(initialNol); setDraftNaa(initialNaa); setDraftNar(initialNar); setDraftStates(new Set(initialStates)); setDraftDateFrom(initialDateFrom); setDraftDateTo(initialDateTo); setDraftTags(new Set(initialTags ?? [])); setCommittedExcludedChannels(initialExcluded); setCommittedNov(initialNov); setCommittedNop(initialNop); setCommittedNotr(initialNotr); setCommittedLc(initialLc); setCommittedNol(initialNol); setCommittedNaa(initialNaa); setCommittedNar(initialNar); setCommittedStates(new Set(initialStates)); setCommittedDateFrom(initialDateFrom); setCommittedDateTo(initialDateTo); setCommittedTags(new Set(initialTags ?? [])); // 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) { // A held query has no results to chart until it runs. const active = resolvedRoot && !holdRestored ? isNodeActive(resolvedRoot) : false; setChartShape(defaultChartShape(active ? "search" : "metadata")); } if (wantsChart) setView("chart"); // Restore the hand-picked grounding selection (pruned later against the // settled result set by the self-clean effect). const savedSelection = loadSelection(); if (savedSelection.length > 0) setSelectedSlugs(new Set(savedSelection)); setHydrated(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [channelOptions.length === 0, hydrated, channelGroupings]); // 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]); // `tg` likewise: the URL carries the COMMITTED tag selection at all times, so // a chip survives a reload, a share of the address bar, and a profile load — // without teaching the share-v1 schema or the strip-on-commit helper about a // key that is not theirs. Sorted, so the same selection is always the same // URL. Deliberately post-hydration only: writing before the URL has been // read would erase the selection the link arrived with. useEffect(() => { if (!hydrated) return; writeUrlParams({ tags: committedTagsKey ? committedTagsKey.split(" ") : [] }); }, [hydrated, committedTagsKey]); // 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>, ) => { const current = loadStoredState() ?? emptyStoredState(); if (patch.collapsedGroups !== undefined) { current.collapsedGroups = patch.collapsedGroups; } if (patch.filtersCollapsed !== undefined) { current.filtersCollapsed = patch.filtersCollapsed; } saveStoredState(current); }, [], ); // React mounts
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 = () => { // "Search in" with nothing ticked reads nothing: refused here, which is // where Search, Enter and the sheet's Apply all arrive. if (draftSearchInEmpty) return; setRunHeld(false); ranThisPageLife = true; askedThisPageLife = true; setSearchedThisPageLife(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); setDraftNop(snapshot?.nop === true); setDraftNotr(snapshot?.notr === true); setDraftLc(snapshot?.lc === true); setDraftNol(snapshot?.nol === true); setDraftNaa(snapshot?.naa === true); setDraftNar(snapshot?.nar === true); setDraftStates(snapshotStates(snapshot)); setDraftDateFrom(snapshot?.dateFrom ?? ""); setDraftDateTo(snapshot?.dateTo ?? ""); setDraftTags(new Set(snapshot?.tg ?? [])); if (snapshot?.query) { const parsed = parseRoot(snapshot.query); if (parsed) setDraftRoot(parsed); } }, [channelOptions, defaultSelectedChannels], ); const applySnapshot = useCallback( (snapshot: FilterSnapshot | null) => { // Loading a profile commits it, which runs it — as it always has. setRunHeld(false); ranThisPageLife = true; askedThisPageLife = true; setSearchedThisPageLife(true); applyDraftSnapshot(snapshot); const excluded = snapshotToExcluded( snapshot, channelOptions, defaultSelectedChannels, ); setCommittedExcludedChannels(excluded); setCommittedNov(snapshot?.nov === true); // `nop` was not committed here, so a loaded profile with Posts unticked // kept searching posts until the next Search. setCommittedNop(snapshot?.nop === true); setCommittedNotr(snapshot?.notr === true); setCommittedLc(snapshot?.lc === true); setCommittedNol(snapshot?.nol === true); setCommittedNaa(snapshot?.naa === true); setCommittedNar(snapshot?.nar === true); setCommittedStates(snapshotStates(snapshot)); setCommittedDateFrom(snapshot?.dateFrom ?? ""); setCommittedDateTo(snapshot?.dateTo ?? ""); setCommittedTags(new Set(snapshot?.tg ?? [])); 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; // A save commits the draft, so it refuses what Search refuses. if (draftSearchInEmpty) return; const snap = buildDraftSnapshot(); promoteDraftsToCommitted(); setProfiles((prev) => { const next = { ...prev, [activeProfileName]: snap }; writeStorage((s) => { s.profiles = next; s.working = snap; }); return next; }); }, [ activeProfileName, draftSearchInEmpty, buildDraftSnapshot, promoteDraftsToCommitted, writeStorage, ]); const handleSaveAsProfile = useCallback(() => { if (typeof window === "undefined") return; if (draftSearchInEmpty) 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, draftSearchInEmpty, 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 = {}; 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(); for (const name of channelOptions) { if (!defaultSelectedChannels.has(name)) excluded.add(name); } setDraftExcludedChannels(excluded); }, [channelOptions, defaultSelectedChannels]); // One click on a chip: select it, or (when it is already selected) clear it. // The chip row is the whole control — there is no separate "clear tags" // affordance, because a selected chip IS the clear affordance. const toggleDraftTag = useCallback((id: string) => { setDraftTags((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); 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(null); const handleShareCurrentSearch = useCallback(async () => { const selectedChannels = new Set(); for (const name of channelOptions) { if (!committedExcludedChannels.has(name)) selectedChannels.add(name); } const selection: ShareSelection = { selectedChannels, videos: !committedNov, livestreams: !committedNol, allAges: !committedNaa, restricted: !committedNar, states: committedStates, 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)); } // `tg` rides along under its own name rather than joining the share-v1 // schema: buildShareSearchParams starts from an empty URLSearchParams, so // without this a shared link would silently drop the tag selection it was // copied with. for (const id of Array.from(committedTags).sort()) params.append("tg", id); // 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, committedStates, 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, 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) => { // A post opens the PostModal, not the transcript reader — there is no // video to load and no playhead to seek. peekPost covers the case where // the row was opened from its header (no hit to read the scope from). const isPost = hit?.scope === "posts" || !!peekPost(slug); const modalMode = isPost ? "post" : hit?.scope === "chat" ? "chat" : "transcript"; // A transcript hit from an alternate track (lib/captionTracks.ts) opens // the reader on that track, where its words are. openTranscript(slug, hit?.start, { mode: modalMode, ...(modalMode === "transcript" && hit?.track ? { track: hit.track } : {}), }); }, [openTranscript], ); // ── Video-selection API (the grounding INCLUSION set) ── // Mirrors the channel-selection idiom (a set toggled by the cards), but at // video granularity and shared with the chat as a grounding target. const toggleSelected = useCallback((slug: string) => { setSelectedSlugs((prev) => { const next = new Set(prev); if (next.has(slug)) next.delete(slug); else next.add(slug); return next; }); }, []); const selectAllResults = useCallback(() => { setSelectedSlugs(new Set(resultGroups.map((g) => g.slug))); }, [resultGroups]); // Select exactly one video and ground in it (a per-card "Ask" → selection of // one). Flips the grounding mode so the chat picks it up immediately. const selectOnly = useCallback((slug: string) => { setSelectedSlugs(new Set([slug])); setGroundingMode("selection"); }, []); const clearSelection = useCallback(() => { setSelectedSlugs((prev) => (prev.size === 0 ? prev : new Set())); }, []); const selectedCount = selectedSlugs.size; // Persist the selection (post-hydration only, so the one-time restore below // isn't immediately overwritten by the initial empty set). useEffect(() => { if (!hydrated) return; saveSelection(selectedSlugs); }, [hydrated, selectedSlugs]); // Self-clean: once the current search settles, drop any selected slugs that are // no longer among the results (a selection carried across a narrower search). const resultSlugSet = useMemo( () => new Set(resultGroups.map((g) => g.slug)), [resultGroups], ); useEffect(() => { if (!hydrated || !pipelineDone) return; setSelectedSlugs((prev) => { if (prev.size === 0) return prev; const next = pruneSelection(prev, resultSlugSet); return next.size === prev.size ? prev : next; }); // resultSlugSet identity changes as results stream; the pipelineDone guard // means we only prune against a SETTLED set, never a partial one. // eslint-disable-next-line react-hooks/exhaustive-deps }, [hydrated, pipelineDone, resultSlugSet]); // Map of leafId → leaf (and its query) so result-row rendering can look // up the original search terms for `` 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]); // Shared builder: turn a set of result groups into a SearchHandoff, joining in // each slug's summary metadata (id + canonical URL) and the active leaf queries // as the label. All three groundings below go through this one path so they // stay byte-identical modulo the tiering opts. `searchCapped` is threaded from // the TRUE engine cap so the "matched more than we can sweep" copy is accurate. const buildGroundingFor = useCallback( ( groups: ResultGroup[], opts: Parameters[3], ): SearchHandoff | null => { if (groups.length === 0) return null; 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( groups, (slug) => { const s = bySlug.get(slug); return s ? { id: s.id, webpageUrl: s.webpageUrl } : undefined; }, queries, { ...opts, searchCapped: capped }, ); return handoff.videos.length ? handoff : null; }, [leavesById, summaries, capped], ); // The current search, serialized as chat grounding — the shared "active search" // the /ask chat auto-grounds in (it reads this live from the session, so no // sessionStorage hand-off is needed now that both views share this provider). // Null when there's no active query or it matched nothing. Tiered inside // buildSearchHandoff (whole set, full excerpts for the top few). const liveGrounding = useMemo(() => { if (!hasActiveQuery) return null; return buildGroundingFor(resultGroups, {}); }, [hasActiveQuery, resultGroups, buildGroundingFor]); // The selected result videos, in result order. const selectedGroups = useMemo( () => selectGroups(resultGroups, selectedSlugs), [resultGroups, selectedSlugs], ); // The hand-picked selection as grounding: every selected video with full // excerpts (sweep caps) — a curated subset is small, so it carries real // evidence per video rather than liveGrounding's tiered single-snippet tail. const selectionGrounding = useMemo(() => { if (selectedGroups.length === 0) return null; return buildGroundingFor(selectedGroups, { maxVideos: selectedGroups.length, fullExcerptVideos: selectedGroups.length, maxHitsPerVideo: SWEEP_HITS_PER_VIDEO, }); }, [selectedGroups, buildGroundingFor]); // The FULL matched set as chat grounding — every discovered video with a few // hit excerpts each, caps lifted so nothing is dropped or thinned (unlike // liveGrounding's tiered 100-video/top-12 payload). Lazy (a function, not a // memo) because the full set can be large and is only needed when the user // launches a whole-corpus "sweep", not on every keystroke. Bounded only by the // engine's hit cap; `capped` flags when that cap bit. const getFullGrounding = useCallback((): SearchHandoff | null => { if (!hasActiveQuery) return null; return buildGroundingFor(resultGroups, { maxVideos: resultGroups.length, fullExcerptVideos: resultGroups.length, maxHitsPerVideo: SWEEP_HITS_PER_VIDEO, }); }, [hasActiveQuery, resultGroups, buildGroundingFor]); // An empty selection always parks the mode on whole-search, so nothing changes // for users who never select a video. const effectiveGroundingMode: GroundingMode = groundingMode === "selection" && selectedSlugs.size > 0 ? "selection" : "search"; // The grounding for the ACTIVE mode, bounded for per-turn use (the pinned // panel / normal chat turns): selection → the curated set; else the live search. const activeGrounding = effectiveGroundingMode === "selection" ? selectionGrounding : liveGrounding; // The grounding for an explicit target — used by the whole-corpus sweep, which // wants FULL excerpts over the whole target (not the tiered per-turn payload). const getGrounding = useCallback( (target: GroundingMode): SearchHandoff | null => target === "selection" ? selectionGrounding : getFullGrounding(), [selectionGrounding, getFullGrounding], ); const [resultsCopied, setResultsCopied] = useState(false); const resultsCopiedResetRef = useRef(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(() => { 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 = 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, draftNop, setDraftNop, // ── Search in (the rest of the row; Posts is `draftNop` above) ── draftNotr, setDraftNotr, draftLc, setDraftLc, // Nothing the site offers is ticked: Search is disabled and refused. draftSearchInEmpty, hasPostsCorpus, draftNol, setDraftNol, draftNaa, setDraftNaa, draftNar, setDraftNar, draftStates, setDraftStates, draftDateFrom, setDraftDateFrom, draftDateTo, setDraftDateTo, // ── Curated tags (draft selection + what is actually being filtered on) ── draftTags, toggleDraftTag, committedTags, // ── Advanced options ── hitBatchValue, setHitBatchValue, hitBatchUnlimited, setHitBatchUnlimited, fetchConcurrency, setFetchConcurrency, flushIntervalMs, setFlushIntervalMs, hitBatchSize, setHitLimit, // ── Results ── // False until the visitor asks in this page life; nothing in the results // area renders until then (see `askedThisPageLife`). searchedThisPageLife, 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, // ── Grounding target (selection) ── selectedSlugs, selectedCount, toggleSelected, selectAllResults, selectOnly, clearSelection, groundingMode, setGroundingMode, // The mode after the empty-selection guard (parks on "search" when nothing // is selected). effectiveGroundingMode, // ── Results → AI ── // The current search as chat grounding (read live by /ask; no hand-off). liveGrounding, // The grounding for the ACTIVE mode, bounded for per-turn chat use. activeGrounding, // The grounding for an explicit target (full excerpts) — for the sweep. getGrounding, // The FULL matched set (lazy) — for the /ask whole-corpus sweep. getFullGrounding, copyResultsContext, resultsCopied, }; } export type SearchSessionValue = ReturnType; const SearchSessionContext = createContext(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 ( {children} ); } export function useSearchSession(): SearchSessionValue { const value = useContext(SearchSessionContext); if (!value) { throw new Error( "useSearchSession must be used within a SearchSessionProvider", ); } return value; }