Archilyzer · Source

archilyzer

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

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:
Acommon/components/SearchResults.tsx | 548+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/SearchSessionContext.tsx | 1605+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/TranscriptSearch.tsx | 2625+------------------------------------------------------------------------------
Acommon/components/WorkspaceSearchBar.tsx | 753+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 1+
Aexport/app/(workspace)/SiteWorkspace.tsx | 39+++++++++++++++++++++++++++++++++++++++
Aexport/app/(workspace)/WorkspaceNav.tsx | 46++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/(workspace)/ask/page.tsx | 48++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/(workspace)/layout.tsx | 18++++++++++++++++++
Aexport/app/(workspace)/page.tsx | 37+++++++++++++++++++++++++++++++++++++
Dexport/app/ask/page.tsx | 49-------------------------------------------------
Dexport/app/page.tsx | 39---------------------------------------
Mexport/e2e/ask-chat.spec.ts | 15++++++++++-----
Aexport/e2e/workspace-shell.spec.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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", + ); + }); +});