commit de7687df19eb897b22eb90fcc062bb8f6b31da41
parent b2e462e872b9b12e43a663222232c57099732ef4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 18:34:04 -0400
Merge r16/search-in (release 16 slice CK) — a Search in row on the export and hub search: Transcripts, Posts, Live chat, the first two on by default; the row governs the default leaf (a leaf asked for by name is not changed), all three off refuses the Search, chat hits wear their badge, the toggles persist with the working filters and profiles; a layer with nothing to read now finishes at once (also a hang main had); reviewed SHIP
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
22 files changed, 2007 insertions(+), 87 deletions(-)
diff --git a/common/components/FiltersContainer.tsx b/common/components/FiltersContainer.tsx
@@ -28,9 +28,13 @@ import { useMediaQuery } from "../lib/useMediaQuery";
export function FiltersTrigger({
activeCount,
onApply,
+ applyDisabled = false,
}: {
activeCount: number;
onApply: () => void;
+ // Refused exactly when Search is ("Search in" has nothing ticked); the
+ // panel above it says which row to fix.
+ applyDisabled?: boolean;
}) {
const [open, setOpen] = useState(false);
// From sm the sheet comes in from the right like the menu; on a phone it
@@ -72,6 +76,7 @@ export function FiltersTrigger({
<Button
type="button"
className="w-full"
+ disabled={applyDisabled}
onClick={() => {
onApply();
setOpen(false);
diff --git a/common/components/FiltersPanel.tsx b/common/components/FiltersPanel.tsx
@@ -27,6 +27,7 @@ import { clearAdvanced } from "./exportAdvancedStorage";
import { ymdToInput, inputToYmd } from "../lib/ymd";
import {
useSearchSession,
+ SEARCH_IN_REFUSAL,
DEFAULT_MAX_HITS,
DEFAULT_FETCH_CONCURRENCY,
DEFAULT_FLUSH_INTERVAL_MS,
@@ -75,6 +76,13 @@ export default function FiltersPanel({
draftNov,
draftNop,
setDraftNop,
+ draftNotr,
+ setDraftNotr,
+ draftLc,
+ setDraftLc,
+ draftSearchInEmpty,
+ hasPostsCorpus,
+ hasSubs,
setDraftNov,
draftNol,
setDraftNol,
@@ -99,11 +107,6 @@ export default function FiltersPanel({
setHitLimit,
} = useSearchSession();
- // Only offer the Posts type toggle on a site that actually ships a posts
- // corpus, so a pure-video deployment's filter row is unchanged.
- const { postsManifest } = useSearchData();
- const hasPostsCorpus = (postsManifest?.channels.length ?? 0) > 0;
-
const toggleState = useCallback(
(state: VideoState, keep: boolean) => {
setDraftStates((prev) => {
@@ -184,6 +187,7 @@ export default function FiltersPanel({
onDelete={handleDeleteProfile}
onRevert={handleRevertProfile}
onShareCurrentSearch={handleShareCurrentSearch}
+ saveDisabled={draftSearchInEmpty}
/>
{channelOptions.length > 0 && (
@@ -407,6 +411,60 @@ export default function FiltersPanel({
</div>
)}
<TagChipRow />
+ {/* What a plain query reads — the default "transcripts" leaf, not a
+ leaf asked for by name in the builder. Posts is offered only
+ where the site ships posts and Live chat only where it has live
+ chat; with nothing ticked Search is refused and this row says so.
+ Type below still says which records are listed. */}
+ <div
+ className="flex flex-wrap items-center gap-x-3 gap-y-1"
+ data-testid="search-in-row"
+ >
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Search in
+ </span>
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNotr}
+ onCheckedChange={(value) => setDraftNotr(value !== true)}
+ />
+ Transcripts
+ </label>
+ {hasPostsCorpus && (
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={!draftNop}
+ onCheckedChange={(value) => setDraftNop(value !== true)}
+ />
+ Posts
+ </label>
+ )}
+ {hasSubs && (
+ <label className="flex items-center gap-1.5 select-none">
+ <Checkbox
+ checked={draftLc}
+ onCheckedChange={(value) => setDraftLc(value === true)}
+ />
+ Live chat
+ </label>
+ )}
+ {/* Always mounted, empty unless Search is refused: a live region
+ is announced when its content changes, which several screen
+ readers miss for a region that appears with its text. A polite
+ live region rather than role="status": this panel is always
+ mounted from xl, and a page-wide second "status" would be a
+ second answer to `getByRole("status")` (modal-digest.spec).
+ The Search button is described by the bar's own copy of these
+ words (this panel may be a closed sheet). */}
+ <span
+ aria-live="polite"
+ aria-atomic="true"
+ data-testid="search-in-empty"
+ className={cn("text-xs text-warning", draftSearchInEmpty && "basis-full")}
+ >
+ {draftSearchInEmpty ? SEARCH_IN_REFUSAL : ""}
+ </span>
+ </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
@@ -425,19 +483,6 @@ export default function FiltersPanel({
/>
Livestreams
</label>
- {/* The social-post corpus is a third media kind, not a video
- sub-type — without its own toggle the video/livestream pair
- would silently drop every post. Only offered when the site
- actually ships posts. */}
- {hasPostsCorpus && (
- <label className="flex items-center gap-1.5 select-none">
- <Checkbox
- checked={!draftNop}
- onCheckedChange={(value) => setDraftNop(value !== true)}
- />
- Posts
- </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">
@@ -625,6 +670,7 @@ function ProfilesRow({
onDelete,
onRevert,
onShareCurrentSearch,
+ saveDisabled,
}: {
profileNames: string[];
activeProfileName: string | null;
@@ -637,6 +683,9 @@ function ProfilesRow({
onDelete: () => void;
onRevert: () => void;
onShareCurrentSearch: () => void;
+ // A save commits the draft, so it is refused when Search is ("Search in"
+ // has nothing ticked).
+ saveDisabled: boolean;
}) {
const showRevert = activeProfileName != null && profileDirty;
return (
@@ -675,7 +724,7 @@ function ProfilesRow({
variant="link"
size="sm"
onClick={onSave}
- disabled={!activeProfileName || !profileDirty}
+ disabled={!activeProfileName || !profileDirty || saveDisabled}
className="text-xs text-muted-foreground"
>
Save
@@ -685,6 +734,7 @@ function ProfilesRow({
variant="link"
size="sm"
onClick={onSaveAs}
+ disabled={saveDisabled}
className="text-xs text-muted-foreground"
>
Save as…
diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx
@@ -18,7 +18,7 @@ import { Button } from "./ui/button";
import QueryBuilder, { compactLayerActions } from "./QueryBuilder";
import { FiltersTrigger } from "./FiltersContainer";
import { useMediaQuery } from "../lib/useMediaQuery";
-import { useSearchSession } from "./SearchSessionContext";
+import { SEARCH_IN_REFUSAL, useSearchSession } from "./SearchSessionContext";
export default function SearchBar({ nav }: { nav?: ReactNode }) {
const {
@@ -41,6 +41,9 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
draftNov,
draftNol,
draftNop,
+ draftNotr,
+ draftLc,
+ draftSearchInEmpty,
draftNaa,
draftNar,
draftStates,
@@ -57,11 +60,15 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
const compact = isCompactRoot(draftRoot);
const layers = compactLayerActions(draftRoot, setDraftRoot);
- // What the Filters chip's badge counts: anything the reader has narrowed.
+ // What the Filters chip's badge counts: anything the reader has narrowed —
+ // and the "Search in" row off its default, which can widen (Live chat) as
+ // well as narrow, because either way a plain query no longer reads what it
+ // reads by default and the panel that says so may be behind the chip.
const activeFilters = useMemo(() => {
let n = 0;
if (draftExcludedChannels.size > 0) n += 1;
- if (draftNov || draftNol || draftNop) n += 1;
+ if (draftNotr || draftNop || draftLc) n += 1;
+ if (draftNov || draftNol) n += 1;
if (draftNaa || draftNar) n += 1;
if (draftStates.size < MISSING_STATES.length + 1) n += 1;
if (draftDateFrom || draftDateTo) n += 1;
@@ -72,6 +79,8 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
draftNov,
draftNol,
draftNop,
+ draftNotr,
+ draftLc,
draftNaa,
draftNar,
draftStates,
@@ -82,14 +91,25 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
// The line under the bar says what to do: after an edit that is not applied
// yet, and before the first Search of the page life, when the results area
- // is empty and waits for it.
- const promptApply = queryDirty || filtersDirty || !searchedThisPageLife;
+ // is empty and waits for it. Not while Search is refused ("Search in" has
+ // nothing ticked) — the panel's own line says what to fix.
+ const promptApply =
+ !draftSearchInEmpty && (queryDirty || filtersDirty || !searchedThisPageLife);
+ // The hint points at the row, so it goes once the box is ticked.
+ const chatHint = hasSubs && !draftLc;
const submit = (
<Button
type="submit"
data-testid="search-submit"
data-dirty={queryDirty || filtersDirty ? "true" : "false"}
+ // "Search in" with nothing ticked reads nothing; commitSearch refuses it
+ // too, for Enter and the sheet's Apply. The reason is its description,
+ // from the always-mounted copy below (not only a `title`, which neither
+ // touch nor a keyboard reaches).
+ disabled={draftSearchInEmpty}
+ aria-describedby="search-in-refusal"
+ title={draftSearchInEmpty ? SEARCH_IN_REFUSAL : undefined}
variant={queryDirty || filtersDirty ? "default" : "outline"}
className="shrink-0 h-11 sm:h-9"
>
@@ -155,6 +175,13 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
{!compact && (
<div className="flex items-center gap-2 flex-wrap">{submit}</div>
)}
+ {/* The Search button's description. Here rather than pointing at the
+ Filters panel's line, which below xl lives in a sheet that is not
+ mounted while closed. Not a live region: the panel's line is the
+ one announced, where the box was unticked. */}
+ <span id="search-in-refusal" className="sr-only">
+ {draftSearchInEmpty ? SEARCH_IN_REFUSAL : ""}
+ </span>
{/* The chip row. Rendered OUTSIDE the `mounted` gate so the workspace
nav exists as plain anchors before hydration — a nav click lost to
@@ -199,6 +226,7 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
<FiltersTrigger
activeCount={activeFilters}
onApply={commitSearch}
+ applyDisabled={draftSearchInEmpty}
/>
)}
{!inlineFilters && (
@@ -226,7 +254,7 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
{/* One status line under the bar instead of two hints competing for room
inside it — and a SIBLING of the form, so the pinned block is never
more than the input row plus the chips. */}
- {(promptApply || hasSubs) && (
+ {(promptApply || chatHint) && (
<p className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
{/* The site's accent, not the warning hue: an unapplied edit is
the next step, not a fault. `--brand` clears 4.5:1 on every
@@ -236,13 +264,14 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
Press Enter or click Search to apply
</span>
)}
- {hasSubs && (
+ {chatHint && (
<span
className="text-muted-foreground"
title={`${liveChatTotalCount} videos have live chat`}
>
Live chat available on {liveChatTotalCount} video
- {liveChatTotalCount === 1 ? "" : "s"} — try scope: Live chat.
+ {liveChatTotalCount === 1 ? "" : "s"} — tick Live chat under
+ Search in.
</span>
)}
</p>
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -58,6 +58,13 @@ export type SearchDataValue = {
// many). Merged across origins in hub mode. Null / empty channel list when
// this site ships no posts corpus.
postsManifest: PostsManifest | null;
+ // The two manifests above have answered (or failed), so a null or an empty
+ // one means "this site has none", not "not answered yet". The "Search in"
+ // row waits for it before it refuses a Search or runs one: until then a
+ // stored row cannot be told apart from an empty one. Hub mode: the merge
+ // covers the ready archives only, and an archive is ready only once both
+ // its manifests have settled, so it is `summariesReady`.
+ manifestsSettled: boolean;
// The channel selection model, resolved mode-appropriately.
channels: ChannelOption[];
groups: ChannelGroup[];
@@ -147,8 +154,17 @@ export function useSearchData(): SearchDataValue {
// share links, and saved profiles round-trip exactly as before.
export function SingleSiteDataProvider({ children }: { children: ReactNode }) {
const summariesState = useSummaries("");
- const subsManifest = useSubsManifest("").data ?? null;
- const postsManifest = usePostsManifest("").data ?? null;
+ const subsQuery = useSubsManifest("");
+ const postsQuery = usePostsManifest("");
+ const subsManifest = subsQuery.data ?? null;
+ const postsManifest = postsQuery.data ?? null;
+ // Settled at the first failure, not after the retry: a site with no live
+ // chat ships no subs manifest, and its 404 must not hold a search for the
+ // retry's second. A retry that does answer later still arrives (the row then
+ // re-runs), as a late manifest always did.
+ const manifestsSettled =
+ (subsQuery.data !== undefined || subsQuery.isError || subsQuery.failureCount > 0) &&
+ (postsQuery.data !== undefined || postsQuery.isError || postsQuery.failureCount > 0);
const aliases = useSearchAliases("");
const curatedTags = useCuratedTags("");
const manifest = summariesState.manifest;
@@ -192,6 +208,7 @@ export function SingleSiteDataProvider({ children }: { children: ReactNode }) {
summariesState,
subsManifest,
postsManifest,
+ manifestsSettled,
channels,
groups,
defaultGroupId,
@@ -204,6 +221,7 @@ export function SingleSiteDataProvider({ children }: { children: ReactNode }) {
summariesState,
subsManifest,
postsManifest,
+ manifestsSettled,
channels,
groups,
defaultGroupId,
@@ -815,6 +833,7 @@ export function MultiSiteDataProvider({
summariesState,
subsManifest,
postsManifest,
+ manifestsSettled: summariesState.summariesReady,
channels,
groups,
defaultGroupId,
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -34,6 +34,7 @@ import { vodExpiry } from "../lib/vodExpiry";
import { Button } from "./ui/button";
import { LayerSwatch } from "./LayerSwatch";
import { SCOPE_LABELS } from "./QueryLeafView";
+import type { LayerScope } from "../lib/searchQuery";
import { ChartShapeControls } from "./charts/ChartShapeControls";
import { SearchChartPanel } from "./charts/SearchChartPanel";
import {
@@ -770,7 +771,15 @@ const ResultCard = memo(function ResultCard({
of them "Transcripts", which is where the hits did NOT come
from. */}
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
- {SCOPE_LABELS[leafInfo.scope]}
+ {
+ SCOPE_LABELS[
+ sectionScope(
+ leafInfo.scope,
+ buckets.get(leafId) ?? hits,
+ !!group.post,
+ )
+ ]
+ }
</span>
<span className="font-mono text-xs text-muted-foreground truncate">
{leafInfo.query}
@@ -918,9 +927,10 @@ const HitRow = memo(function HitRow({
{hit.scope === "metadata" ? "—" : formatSeconds(hit.start)}
</span>
)}
- {hit.track && hit.track !== "live_chat" && (
- <TrackBadge track={hit.track} />
- )}
+ {/* Every track wears its badge, live chat included: with "Search in"
+ a plain query's live-chat hits share a section with its transcript
+ hits, and the badge is what tells the two apart. */}
+ {hit.track && <TrackBadge track={hit.track} />}
<span className="flex-1 min-w-0 text-sm">
{highlight(hit.text, query, useRegex)}
</span>
@@ -930,6 +940,23 @@ const HitRow = memo(function HitRow({
});
HitRow.displayName = "HitRow";
+// What a section's bar names. Its leaf's scope — except that a plain
+// ("transcripts") leaf reads what "Search in" ticks, so its section on a post's
+// card holds post hits, and on a video whose only matches were in its live chat
+// holds chat hits; the bar names what is there rather than a kind that was not
+// read. Mixed transcript and chat hits keep "Transcripts", each chat hit
+// wearing its "live chat" badge.
+function sectionScope(
+ scope: LayerScope,
+ hits: ReadonlyArray<LayerHit>,
+ isPost: boolean,
+): LayerScope {
+ if (scope !== "transcripts") return scope;
+ if (isPost) return "posts";
+ if (hits.length > 0 && hits.every((h) => h.scope === "chat")) return "chat";
+ return scope;
+}
+
// Which social platform a post came from. Reuses the TrackBadge shape so post
// rows sit visually alongside chat rows rather than inventing a new idiom.
function PostBadge({ platform }: { platform: string }) {
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -35,16 +35,21 @@ import {
type TreeProgress,
} from "../lib/searchEval";
import {
+ applySearchIn,
canonicalHash,
emptyRoot,
forEachLeaf,
isNodeActive,
parseRoot,
rootFromLegacy,
+ searchInReadsNothing,
+ searchInUnderTags,
stringifyRoot,
type GroupNode,
type LayerScope,
+ type SearchIn,
} from "../lib/searchQuery";
+import { foldSearchIn } from "../lib/search/searchIn";
import {
VIDEO_STATES,
summaryState,
@@ -179,6 +184,10 @@ export type LeafInfo = {
// Advanced-pipeline defaults, surfaced so the bar's Advanced-options controls
// (and their "Reset to defaults" button) share the exact same constants.
export const DEFAULT_MAX_HITS = 500;
+
+// The words the Filters panel's "Search in" row and the Search button's
+// description say while nothing the site offers is ticked.
+export const SEARCH_IN_REFUSAL = "Search in: pick at least one";
export const DEFAULT_FETCH_CONCURRENCY = 6;
export const DEFAULT_FLUSH_INTERVAL_MS = 120;
@@ -392,10 +401,17 @@ function useSearchSessionState() {
>(() => new Set());
const [committedNov, setCommittedNov] = useState(false);
const [committedNol, setCommittedNol] = useState(false);
- // Third media kind: social posts. Videos and livestreams are a binary split
- // (isLivestream ? nol : nov); a post is neither, so it needs its own toggle
- // or the existing two would silently drop the whole posts corpus.
+ // Posts, the "Search in" row's second box (it was a third media kind in the
+ // Type row until release 16): unticked, a plain query reads no posts. A leaf
+ // of scope "Posts" reads them whatever it says.
const [committedNop, setCommittedNop] = useState(false);
+ // The rest of the "Search in" row (release 16) beside `nop`: what a plain
+ // query — a "transcripts" leaf — reads. Stored as off-the-default booleans
+ // like the others: `notr` = Transcripts unticked, `lc` = Live chat ticked.
+ // The row is applied to the committed tree just before it runs
+ // (`applySearchIn`); the tree itself, its hash and its `qt=` do not change.
+ const [committedNotr, setCommittedNotr] = useState(false);
+ const [committedLc, setCommittedLc] = useState(false);
const [committedNaa, setCommittedNaa] = useState(false);
const [committedNar, setCommittedNar] = useState(false);
// Availability is a six-state enum (VideoState), not a set of "exclude X"
@@ -446,6 +462,8 @@ function useSearchSessionState() {
>(() => new Set());
const [draftNov, setDraftNov] = useState(false);
const [draftNop, setDraftNop] = useState(false);
+ const [draftNotr, setDraftNotr] = useState(false);
+ const [draftLc, setDraftLc] = useState(false);
const [draftNol, setDraftNol] = useState(false);
const [draftNaa, setDraftNaa] = useState(false);
const [draftNar, setDraftNar] = useState(false);
@@ -508,6 +526,7 @@ function useSearchSessionState() {
summariesState,
subsManifest,
postsManifest,
+ manifestsSettled,
channels: channelModel,
groups: manifestGroups,
channelKeyOf,
@@ -523,13 +542,72 @@ function useSearchSessionState() {
// only load subs manifests when a chat leaf is actually in the tree.
const liveChatTotalCount = subsManifest?.liveChatTotalCount ?? 0;
const hasSubs = subsManifest !== null && liveChatTotalCount > 0;
+ const hasPostsCorpus = (postsManifest?.channels.length ?? 0) > 0;
+
+ // ─── Search in ────────────────────────────────────────────────────────────
+ // What the row ticks, as far as this site can honour it: Posts counts only
+ // where the site ships posts and Live chat only where it has live chat (the
+ // panel offers neither box otherwise), so a stored `lc` on a site without
+ // chat asks for no manifest that is not there.
+ const draftOffered = useMemo<SearchIn>(
+ () => ({
+ transcripts: !draftNotr,
+ posts: !draftNop && hasPostsCorpus,
+ chat: draftLc && hasSubs,
+ }),
+ [draftNotr, draftNop, draftLc, hasPostsCorpus, hasSubs],
+ );
+ // What the rewrite reads: the same, less a posts copy under a tag filter,
+ // which no post can pass (`searchInUnderTags`).
+ const committedSearchIn = useMemo<SearchIn>(
+ () =>
+ searchInUnderTags(
+ {
+ transcripts: !committedNotr,
+ posts: !committedNop && hasPostsCorpus,
+ chat: committedLc && hasSubs,
+ },
+ committedTags.size > 0,
+ ),
+ [committedNotr, committedNop, committedLc, hasPostsCorpus, hasSubs, committedTags],
+ );
+ const draftSearchIn = useMemo<SearchIn>(
+ () => searchInUnderTags(draftOffered, draftTags.size > 0),
+ [draftOffered, draftTags],
+ );
+ // Nothing ticked that this site offers: Search, Enter, Apply and the profile
+ // saves all refuse, and the panel says which row to fix. Only once the
+ // manifests have answered: until then Posts and Live chat read as not
+ // offered, and a stored row with Transcripts unticked would read as empty.
+ // The run waits for them too (below), so the decision is held, not guessed.
+ const draftSearchInEmpty =
+ manifestsSettled && searchInReadsNothing(draftOffered);
+ // The committed tree as it RUNS: every "transcripts" leaf rewritten to read
+ // the kinds ticked. `committedRoot` stays the tree the visitor built — the
+ // builder, the result cards' leaf sections and `qt=` read that one, and the
+ // run's progress is folded back onto it (`foldSearchIn`).
+ const committedTree = useMemo(
+ () => applySearchIn(committedRoot, committedSearchIn),
+ [committedRoot, committedSearchIn],
+ );
+ const committedTreeHash = useMemo(
+ () => canonicalHash(committedTree.root),
+ [committedTree],
+ );
+ const draftTreeRoot = useMemo(
+ () => applySearchIn(draftRoot, draftSearchIn).root,
+ [draftRoot, draftSearchIn],
+ );
+ // Read from the rewritten trees, so a plain query with Live chat ticked
+ // loads the subs manifests (and with Posts ticked, the posts manifests)
+ // exactly as a leaf asked for by name always has.
const draftHasChatLeaf = useMemo(
- () => anyLeafHasScope(draftRoot, "chat"),
- [draftRoot],
+ () => anyLeafHasScope(draftTreeRoot, "chat"),
+ [draftTreeRoot],
);
const committedHasChatLeaf = useMemo(
- () => anyLeafHasScope(committedRoot, "chat"),
- [committedRoot],
+ () => anyLeafHasScope(committedTree.root, "chat"),
+ [committedTree],
);
const needsChatManifests = draftHasChatLeaf || committedHasChatLeaf;
// Origin-qualified refs. In single-site mode the manifest slugs are bare, so
@@ -576,12 +654,12 @@ function useSearchSessionState() {
// manifests when a posts leaf is actually present, then flatten them into the
// set of post slugs the tree may match.
const draftHasPostsLeaf = useMemo(
- () => anyLeafHasScope(draftRoot, "posts"),
- [draftRoot],
+ () => anyLeafHasScope(draftTreeRoot, "posts"),
+ [draftTreeRoot],
);
const committedHasPostsLeaf = useMemo(
- () => anyLeafHasScope(committedRoot, "posts"),
- [committedRoot],
+ () => anyLeafHasScope(committedTree.root, "posts"),
+ [committedTree],
);
const needsPostsManifests = draftHasPostsLeaf || committedHasPostsLeaf;
const postsRefs = useMemo(
@@ -684,11 +762,12 @@ function useSearchSessionState() {
const pipelineRef = useRef<TreeController | null>(null);
const { openTranscript } = usePlayer();
- // Reset the live hit cap on each new committed query.
+ // Reset the live hit cap on each new committed query — including the same
+ // query read in other kinds ("Search in").
useEffect(() => {
setHitLimit(hitBatchSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [committedHash]);
+ }, [committedHash, committedTreeHash]);
// ─── Channel-grouping setup ────────────────────────────────────────────────
// Selection identity is the stable channelKey from the provider's model. In
@@ -796,7 +875,7 @@ function useSearchSessionState() {
[committedTags],
);
- const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNop ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedStatesKey}|${committedDateFrom}|${committedDateTo}|${committedTagsKey}`;
+ const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNop ? 1 : 0}|${committedNotr ? 1 : 0}|${committedLc ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedStatesKey}|${committedDateFrom}|${committedDateTo}|${committedTagsKey}`;
// The scope universe is videos AND posts. The two namespaces are disjoint;
// searchEval partitions them per-leaf (see EvalCtx.postScopeSlugs) so a posts
@@ -811,12 +890,17 @@ function useSearchSessionState() {
// passesFilter gives an untagged video. See tagFilteredPostScope below:
// the DEFAULT scope is only half of it, because a `posts`-scope leaf
// carries its own slug set past this.
- if (!committedNop && committedTags.size === 0) {
+ //
+ // Not gated on `nop` (release 16, review M2): the Posts box governs the
+ // plain query's posts copy, through `applySearchIn`, and a leaf of scope
+ // "Posts" was asked for by name and reads posts whatever the row says.
+ // `postScopeSlugs` is empty unless some leaf reads posts.
+ if (committedTags.size === 0) {
for (const slug of postScopeSlugs) out.push(slug);
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [transcripts, filterKey, postScopeSlugs, committedNop]);
+ }, [transcripts, filterKey, postScopeSlugs]);
// The posts slug set as the PIPELINE sees it. A `posts`-scope leaf is fed
// from here and not from the global scope above, so gating only the global
@@ -854,11 +938,15 @@ function useSearchSessionState() {
return;
}
if (!transcripts) return;
+ // "Search in" counts Posts and Live chat only once the site's manifests
+ // say it has them; a run before that would read the wrong kinds (with
+ // Transcripts unticked, transcripts) and run again. Hold it.
+ if (!manifestsSettled) return;
if (needsChatManifests && !subsManifestReady) return;
if (needsPostsManifests && !postsManifestReady) return;
const controller = runQueryTree({
- root: committedRoot,
+ root: committedTree.root,
runtime: searchRuntime,
globalScope: globalScopeSlugs,
summaries: transcripts,
@@ -867,7 +955,9 @@ function useSearchSessionState() {
initialHitLimit: hitLimit,
concurrency: fetchConcurrency,
flushIntervalMs,
- emit: (p) => setTreeProgress(p),
+ // The copies "Search in" made report under ids of their own; fold them
+ // back under the leaf the visitor built before anything reads them.
+ emit: (p) => setTreeProgress(foldSearchIn(p, committedTree)),
});
pipelineRef.current = controller;
return () => {
@@ -881,8 +971,10 @@ function useSearchSessionState() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
committedHash,
+ committedTreeHash,
hasActiveQuery,
transcripts,
+ manifestsSettled,
filterKey,
needsChatManifests,
subsManifestReady,
@@ -1048,7 +1140,9 @@ function useSearchSessionState() {
const indexLoading =
hasActiveQuery &&
- (!summariesReady || (needsChatManifests && !subsManifestReady));
+ (!summariesReady ||
+ !manifestsSettled ||
+ (needsChatManifests && !subsManifestReady));
// Browse mode still needs the summaries before it can list anything.
const summariesLoading = !summariesReady;
const searching = hasActiveQuery && totalToProcess > 0;
@@ -1060,6 +1154,8 @@ function useSearchSessionState() {
!sameSet(draftExcludedChannels, committedChannels) ||
draftNov !== committedNov ||
draftNop !== committedNop ||
+ draftNotr !== committedNotr ||
+ draftLc !== committedLc ||
draftNol !== committedNol ||
draftNaa !== committedNaa ||
draftNar !== committedNar ||
@@ -1076,6 +1172,11 @@ function useSearchSessionState() {
);
const snap: FilterSnapshot = { channels: deltas };
if (committedNov) snap.nov = true;
+ // `nop` was missing here, so a profile with Posts unticked always read as
+ // changed against the committed state it had just loaded.
+ if (committedNop) snap.nop = true;
+ if (committedNotr) snap.notr = true;
+ if (committedLc) snap.lc = true;
if (committedNol) snap.nol = true;
if (committedNaa) snap.naa = true;
if (committedNar) snap.nar = true;
@@ -1092,6 +1193,9 @@ function useSearchSessionState() {
channelOptions,
defaultSelectedChannels,
committedNov,
+ committedNop,
+ committedNotr,
+ committedLc,
committedNol,
committedNaa,
committedNar,
@@ -1112,6 +1216,8 @@ function useSearchSessionState() {
};
if (draftNov) snap.nov = true;
if (draftNop) snap.nop = true;
+ if (draftNotr) snap.notr = true;
+ if (draftLc) snap.lc = true;
if (draftNol) snap.nol = true;
if (draftNaa) snap.naa = true;
if (draftNar) snap.nar = true;
@@ -1127,6 +1233,8 @@ function useSearchSessionState() {
defaultSelectedChannels,
draftNov,
draftNop,
+ draftNotr,
+ draftLc,
draftNol,
draftNaa,
draftNar,
@@ -1141,6 +1249,8 @@ function useSearchSessionState() {
setCommittedExcludedChannels(new Set(draftExcludedChannels));
setCommittedNov(draftNov);
setCommittedNop(draftNop);
+ setCommittedNotr(draftNotr);
+ setCommittedLc(draftLc);
setCommittedNol(draftNol);
setCommittedNaa(draftNaa);
setCommittedNar(draftNar);
@@ -1155,6 +1265,8 @@ function useSearchSessionState() {
draftExcludedChannels,
draftNov,
draftNop,
+ draftNotr,
+ draftLc,
draftNol,
draftNaa,
draftNar,
@@ -1264,12 +1376,20 @@ function useSearchSessionState() {
let resolvedRoot = rootFromUrl;
// True only when the query came from the stored snapshot, never the URL.
let holdRestored = false;
+ // The stored profile / working snapshot.
+ const snapshot =
+ stored?.activeProfileName != null
+ ? (stored.profiles[stored.activeProfileName] ?? stored.working)
+ : (stored?.working ?? null);
+ // The "Search in" row always comes from here, whatever the URL carries: no
+ // link carries it (share-v1 has no key for it, by ruling), so a shared
+ // link reads with the visitor's own row. Before the row, `nop` was read
+ // from nowhere, and a stored Posts-unticked came back ticked on reload.
+ const initialNop = snapshot?.nop === true;
+ const initialNotr = snapshot?.notr === true;
+ const initialLc = snapshot?.lc === true;
if (!initialExcluded) {
// Fall back to stored profile / working snapshot.
- const snapshot =
- stored?.activeProfileName != null
- ? (stored.profiles[stored.activeProfileName] ?? stored.working)
- : (stored?.working ?? null);
const excluded = snapshotToExcluded(
snapshot,
channelOptions,
@@ -1307,7 +1427,9 @@ function useSearchSessionState() {
setDraftExcludedChannels(initialExcluded);
setDraftNov(initialNov);
- setDraftNop(false);
+ setDraftNop(initialNop);
+ setDraftNotr(initialNotr);
+ setDraftLc(initialLc);
setDraftNol(initialNol);
setDraftNaa(initialNaa);
setDraftNar(initialNar);
@@ -1317,6 +1439,9 @@ function useSearchSessionState() {
setDraftTags(new Set(initialTags ?? []));
setCommittedExcludedChannels(initialExcluded);
setCommittedNov(initialNov);
+ setCommittedNop(initialNop);
+ setCommittedNotr(initialNotr);
+ setCommittedLc(initialLc);
setCommittedNol(initialNol);
setCommittedNaa(initialNaa);
setCommittedNar(initialNar);
@@ -1416,6 +1541,9 @@ function useSearchSessionState() {
}, [persistUiCollapse]);
const commitSearch = () => {
+ // "Search in" with nothing ticked reads nothing: refused here, which is
+ // where Search, Enter and the sheet's Apply all arrive.
+ if (draftSearchInEmpty) return;
setRunHeld(false);
ranThisPageLife = true;
askedThisPageLife = true;
@@ -1464,6 +1592,8 @@ function useSearchSessionState() {
setDraftExcludedChannels(excluded);
setDraftNov(snapshot?.nov === true);
setDraftNop(snapshot?.nop === true);
+ setDraftNotr(snapshot?.notr === true);
+ setDraftLc(snapshot?.lc === true);
setDraftNol(snapshot?.nol === true);
setDraftNaa(snapshot?.naa === true);
setDraftNar(snapshot?.nar === true);
@@ -1494,6 +1624,11 @@ function useSearchSessionState() {
);
setCommittedExcludedChannels(excluded);
setCommittedNov(snapshot?.nov === true);
+ // `nop` was not committed here, so a loaded profile with Posts unticked
+ // kept searching posts until the next Search.
+ setCommittedNop(snapshot?.nop === true);
+ setCommittedNotr(snapshot?.notr === true);
+ setCommittedLc(snapshot?.lc === true);
setCommittedNol(snapshot?.nol === true);
setCommittedNaa(snapshot?.naa === true);
setCommittedNar(snapshot?.nar === true);
@@ -1547,6 +1682,8 @@ function useSearchSessionState() {
const handleSaveProfile = useCallback(() => {
if (!activeProfileName) return;
+ // A save commits the draft, so it refuses what Search refuses.
+ if (draftSearchInEmpty) return;
const snap = buildDraftSnapshot();
promoteDraftsToCommitted();
setProfiles((prev) => {
@@ -1559,6 +1696,7 @@ function useSearchSessionState() {
});
}, [
activeProfileName,
+ draftSearchInEmpty,
buildDraftSnapshot,
promoteDraftsToCommitted,
writeStorage,
@@ -1566,6 +1704,7 @@ function useSearchSessionState() {
const handleSaveAsProfile = useCallback(() => {
if (typeof window === "undefined") return;
+ if (draftSearchInEmpty) return;
const raw = window.prompt("Save current filters as a new profile named:");
if (raw == null) return;
const name = raw.trim();
@@ -1586,7 +1725,13 @@ function useSearchSessionState() {
return next;
});
setActiveProfileName(name);
- }, [profiles, buildDraftSnapshot, promoteDraftsToCommitted, writeStorage]);
+ }, [
+ profiles,
+ draftSearchInEmpty,
+ buildDraftSnapshot,
+ promoteDraftsToCommitted,
+ writeStorage,
+ ]);
const handleRenameProfile = useCallback(() => {
if (typeof window === "undefined") return;
@@ -2043,6 +2188,14 @@ function useSearchSessionState() {
setDraftNov,
draftNop,
setDraftNop,
+ // ── Search in (the rest of the row; Posts is `draftNop` above) ──
+ draftNotr,
+ setDraftNotr,
+ draftLc,
+ setDraftLc,
+ // Nothing the site offers is ticked: Search is disabled and refused.
+ draftSearchInEmpty,
+ hasPostsCorpus,
draftNol,
setDraftNol,
draftNaa,
diff --git a/common/components/exportFilterStorage.test.ts b/common/components/exportFilterStorage.test.ts
@@ -0,0 +1,83 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ emptySnapshot,
+ emptyStoredState,
+ loadStoredState,
+ parseSnapshot,
+ saveStoredState,
+ snapshotsEqual,
+} from "./exportFilterStorage";
+
+// The "Search in" row's two new keys (release 16): `notr` (Transcripts
+// unticked) and `lc` (Live chat ticked), stored beside `nop` only when off the
+// default, in the working snapshot and in every profile.
+
+test("notr and lc survive a JSON round trip through the parser", () => {
+ const snap = { ...emptySnapshot(), notr: true, nop: true, lc: true };
+ const back = parseSnapshot(JSON.parse(JSON.stringify(snap)));
+ assert.ok(back);
+ assert.equal(back.notr, true);
+ assert.equal(back.nop, true);
+ assert.equal(back.lc, true);
+ assert.ok(snapshotsEqual(snap, back));
+});
+
+test("absent reads as the default, and the default is not written", () => {
+ const back = parseSnapshot({ channels: { included: [], excluded: [] } });
+ assert.ok(back);
+ assert.equal("notr" in back, false);
+ assert.equal("lc" in back, false);
+ // A profile saved before the row existed equals one that spells the
+ // defaults out.
+ assert.ok(snapshotsEqual(back, { ...emptySnapshot(), notr: false, lc: false }));
+});
+
+test("a value that is not a boolean is dropped, not coerced", () => {
+ const back = parseSnapshot({
+ channels: { included: [], excluded: [] },
+ notr: "yes",
+ lc: 1,
+ });
+ assert.ok(back);
+ assert.equal(back.notr, undefined);
+ assert.equal(back.lc, undefined);
+});
+
+test("snapshotsEqual tells each of the row's keys apart", () => {
+ const base = emptySnapshot();
+ assert.ok(!snapshotsEqual(base, { ...base, notr: true }));
+ assert.ok(!snapshotsEqual(base, { ...base, lc: true }));
+ assert.ok(!snapshotsEqual({ ...base, notr: true }, { ...base, lc: true }));
+});
+
+test("the working snapshot and a profile keep the row through storage", () => {
+ const store = new Map<string, string>();
+ const g = globalThis as { window?: unknown };
+ const before = g.window;
+ g.window = {
+ localStorage: {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => void store.set(k, v),
+ removeItem: (k: string) => void store.delete(k),
+ },
+ };
+ try {
+ const state = emptyStoredState();
+ state.working = { ...emptySnapshot(), lc: true };
+ state.profiles = { chatOnly: { ...emptySnapshot(), notr: true, nop: true, lc: true } };
+ state.activeProfileName = "chatOnly";
+ saveStoredState(state);
+ const loaded = loadStoredState();
+ assert.ok(loaded);
+ assert.equal(loaded.working.lc, true);
+ assert.equal(loaded.working.notr, undefined);
+ assert.deepEqual(
+ [loaded.profiles.chatOnly.notr, loaded.profiles.chatOnly.nop, loaded.profiles.chatOnly.lc],
+ [true, true, true],
+ );
+ assert.equal(loaded.activeProfileName, "chatOnly");
+ } finally {
+ g.window = before;
+ }
+});
diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts
@@ -26,9 +26,18 @@ const VERSION = 1;
export type FilterSnapshot = {
channels: { included: string[]; excluded: string[] };
nov?: boolean;
- // Exclude the social-post corpus — the third media kind beside videos and
- // livestreams (see SearchSessionContext.passesFilter).
+ // The "Search in" row (release 16): what a plain query — a "transcripts"
+ // leaf — reads. Each is stored only off its default, so a profile saved
+ // before the row existed reads transcripts and posts, as it always did.
+ // notr — Transcripts unticked (the transcript cues are not read)
+ // nop — Posts unticked (the social-post corpus; before the row this was
+ // the Posts box in the Type row, and it also emptied a leaf of
+ // scope "Posts" — since release 16 it does not: a leaf asked for by
+ // name reads what it names)
+ // lc — Live chat ticked (the live_chat track is read; off by default)
+ notr?: boolean;
nop?: boolean;
+ lc?: boolean;
nol?: boolean;
naa?: boolean;
nar?: boolean;
@@ -124,7 +133,8 @@ function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((x) => typeof x === "string");
}
-function parseSnapshot(raw: unknown): FilterSnapshot | null {
+// Exported for the round-trip test; the app reads through loadStoredState.
+export function parseSnapshot(raw: unknown): FilterSnapshot | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
const ch = r.channels as Record<string, unknown> | undefined;
@@ -140,6 +150,8 @@ function parseSnapshot(raw: unknown): FilterSnapshot | null {
// `nop` and `nu` were written by committedSnapshot but never read back here,
// so both were silently dropped on every reload.
if (typeof r.nop === "boolean") snap.nop = r.nop;
+ if (typeof r.notr === "boolean") snap.notr = r.notr;
+ if (typeof r.lc === "boolean") snap.lc = r.lc;
if (typeof r.nol === "boolean") snap.nol = r.nol;
if (typeof r.naa === "boolean") snap.naa = r.naa;
if (typeof r.nar === "boolean") snap.nar = r.nar;
@@ -257,6 +269,8 @@ export function snapshotsEqual(a: FilterSnapshot, b: FilterSnapshot): boolean {
}
if ((a.nov ?? false) !== (b.nov ?? false)) return false;
if ((a.nop ?? false) !== (b.nop ?? false)) return false;
+ if ((a.notr ?? false) !== (b.notr ?? false)) return false;
+ if ((a.lc ?? false) !== (b.lc ?? false)) return false;
if ((a.nol ?? false) !== (b.nol ?? false)) return false;
if ((a.naa ?? false) !== (b.naa ?? false)) return false;
if ((a.nar ?? false) !== (b.nar ?? false)) return false;
diff --git a/common/lib/search/leafPipeline.ts b/common/lib/search/leafPipeline.ts
@@ -194,9 +194,20 @@ export function createSearchPipeline(
for (let i = 0; i < needed; i++) worker();
};
+ // `worker()` counts itself in synchronously, before its first await, so
+ // right after ensureWorkers a zero here means none is running or will.
+ const settleIfIdle = () => {
+ if (activeWorkers === 0 && !done) finalize();
+ };
+
// Emit initial snapshot synchronously so the UI clears previous results.
pushUpdate();
ensureWorkers();
+ // No worker started (nothing to scan, or the cap is already met): nobody is
+ // left to call finalize, so settle here. Without it an empty scope — which
+ // "Search in" makes from a plain query, e.g. a posts copy under a tag filter
+ // — never reported done and the search read "searching" for ever.
+ settleIfIdle();
return {
cancel() {
@@ -216,6 +227,7 @@ export function createSearchPipeline(
pushUpdate();
}
ensureWorkers();
+ settleIfIdle();
},
};
}
@@ -325,8 +337,15 @@ export function createPostsSearchPipeline(
for (let i = 0; i < needed; i++) worker();
};
+ // `worker()` counts itself in synchronously, before its first await, so
+ // right after ensureWorkers a zero here means none is running or will.
+ const settleIfIdle = () => {
+ if (activeWorkers === 0 && !done) finalize();
+ };
+
pushUpdate();
ensureWorkers();
+ settleIfIdle(); // see createSearchPipeline
return {
cancel() {
@@ -345,6 +364,7 @@ export function createPostsSearchPipeline(
pushUpdate();
}
ensureWorkers();
+ settleIfIdle();
},
};
}
@@ -503,8 +523,15 @@ export function createSubsSearchPipeline(
for (let i = 0; i < needed; i++) worker();
};
+ // `worker()` counts itself in synchronously, before its first await, so
+ // right after ensureWorkers a zero here means none is running or will.
+ const settleIfIdle = () => {
+ if (activeWorkers === 0 && !done) finalize();
+ };
+
pushUpdate();
ensureWorkers();
+ settleIfIdle(); // see createSearchPipeline
return {
cancel() {
@@ -523,6 +550,7 @@ export function createSubsSearchPipeline(
pushUpdate();
}
ensureWorkers();
+ settleIfIdle();
},
};
}
diff --git a/common/lib/search/searchIn.test.ts b/common/lib/search/searchIn.test.ts
@@ -0,0 +1,347 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ applySearchIn,
+ newGroup,
+ newLeaf,
+ type GroupNode,
+ type SearchIn,
+} from "../searchQuery";
+import {
+ runQueryTree,
+ type LayerCache,
+ type TreeProgress,
+} from "../searchEval";
+import type { TranscriptDetail } from "../transcripts";
+import type { SubsDetail } from "../subs";
+import type { Post } from "../posts";
+import {
+ runLeafPipeline,
+ type LayerHit,
+ type LeafController,
+ type LeafFetchers,
+ type LeafProgress,
+ type LeafRunner,
+} from "./leafPipeline";
+import { foldSearchIn } from "./searchIn";
+
+// The "Search in" fold, and the rewrite run end to end through the real tree
+// evaluator with a fake leaf runner: a transcripts leaf reads the kinds ticked,
+// and what the visitor sees is filed under the leaf they built.
+
+// A tiny corpus. v2 is the only video with live chat; p1 is a post.
+const TEXT: Record<string, Record<string, string>> = {
+ transcripts: { v1: "an alpha line", v2: "a beta line", v3: "a gamma line" },
+ chat: { v2: "@u: alpha in chat" },
+ posts: { p1: "a post about alpha" },
+};
+
+const noCache: LayerCache = {
+ key: (a, b) => `${a}__${b}`,
+ getSync: () => null,
+ get: async () => null,
+ put: () => {},
+};
+
+// Matches `leaf.query` against the text for the leaf's scope, one hit per
+// matching slug, and reports it the way runLeafPipeline does (a chat hit
+// carries its track).
+const fakeRunLeaf: LeafRunner = (opts) => {
+ const { leaf, scopeSlugs, emit } = opts;
+ const slugs = new Set<string>();
+ const hits = new Map<string, LayerHit[]>();
+ for (const slug of scopeSlugs) {
+ const text = TEXT[leaf.scope]?.[slug];
+ if (!text || !text.includes(leaf.query)) continue;
+ slugs.add(slug);
+ if (leaf.contributeHits) {
+ hits.set(slug, [
+ {
+ leafId: leaf.id,
+ scope: leaf.scope,
+ ...(leaf.scope === "chat" ? { track: "live_chat" } : {}),
+ start: 1,
+ text,
+ },
+ ]);
+ }
+ }
+ let resolve!: (r: { slugs: Set<string>; hits: Map<string, LayerHit[]> }) => void;
+ const done = new Promise<{ slugs: Set<string>; hits: Map<string, LayerHit[]> }>(
+ (r) => (resolve = r),
+ );
+ queueMicrotask(() => {
+ emit({
+ slugs,
+ hits,
+ totalHits: [...hits.values()].reduce((n, l) => n + l.length, 0),
+ processed: scopeSlugs.length,
+ totalToProcess: scopeSlugs.length,
+ capped: false,
+ done: true,
+ });
+ resolve({ slugs, hits });
+ });
+ const ctrl: LeafController = { cancel() {}, setHitLimit() {}, done };
+ return ctrl;
+};
+
+function run(root: GroupNode, s: SearchIn): Promise<TreeProgress> {
+ const rewritten = applySearchIn(root, s);
+ return new Promise((resolve) => {
+ runQueryTree({
+ root: rewritten.root,
+ runtime: { runLeaf: fakeRunLeaf, cache: noCache },
+ // The session's global scope: the videos, and the posts when Posts is on.
+ globalScope: ["v1", "v2", "v3", ...(s.posts ? ["p1"] : [])],
+ summaries: [],
+ chatScopeSlugs: new Set(["v2"]),
+ postScopeSlugs: new Set(["p1"]),
+ initialHitLimit: 500,
+ concurrency: 2,
+ flushIntervalMs: 10,
+ emit: (p) => {
+ if (p.done) resolve(foldSearchIn(p, rewritten));
+ },
+ });
+ });
+}
+
+const ALPHA = () => newGroup({ id: "root", children: [newLeaf({ id: "a", query: "alpha" })] });
+
+test("Transcripts on: the transcript cues only (today's plain query)", async () => {
+ const p = await run(ALPHA(), { transcripts: true, posts: false, chat: false });
+ assert.deepEqual([...p.slugs].sort(), ["v1"]);
+});
+
+test("Transcripts + Posts (the default): videos and the post, in one result set", async () => {
+ const p = await run(ALPHA(), { transcripts: true, posts: true, chat: false });
+ assert.deepEqual([...p.slugs].sort(), ["p1", "v1"]);
+ assert.equal(p.hits.get("p1")?.[0].scope, "posts");
+ assert.equal(p.hits.get("p1")?.[0].leafId, "a", "filed under the visitor's leaf");
+});
+
+test("Live chat on: a chat hit lands in the same leaf, wearing its track", async () => {
+ const p = await run(ALPHA(), { transcripts: true, posts: false, chat: true });
+ assert.deepEqual([...p.slugs].sort(), ["v1", "v2"]);
+ const chat = p.hits.get("v2")?.[0];
+ assert.equal(chat?.leafId, "a");
+ assert.equal(chat?.scope, "chat");
+ assert.equal(chat?.track, "live_chat");
+ // The copies' states fold into the leaf's: no copy id is left for the
+ // builder to miss, and the count is the union.
+ assert.deepEqual([...p.leafStates.keys()], ["a"]);
+ assert.equal(p.leafStates.get("a")?.slugCount, 2);
+ assert.equal(p.leafStates.get("a")?.totalHits, 2);
+ assert.equal(p.leafStates.get("a")?.active, false);
+});
+
+test("Transcripts off + Live chat on: a word only in the cues returns nothing", async () => {
+ const off: SearchIn = { transcripts: false, posts: false, chat: true };
+ const onlyInCues = newGroup({ children: [newLeaf({ id: "b", query: "beta" })] });
+ assert.equal((await run(onlyInCues, off)).slugs.size, 0);
+ const p = await run(ALPHA(), off);
+ assert.deepEqual([...p.slugs], ["v2"]);
+});
+
+test("a leaf asked for by name ignores the row", async () => {
+ const chatByName = newGroup({
+ children: [newLeaf({ id: "c", query: "alpha", scope: "chat" })],
+ });
+ const p = await run(chatByName, { transcripts: true, posts: true, chat: false });
+ assert.deepEqual([...p.slugs], ["v2"]);
+});
+
+test("NOT reads NOT of the union, and its count is still what it matched", async () => {
+ const root = newGroup({
+ children: [newLeaf({ id: "a", query: "alpha", negate: true })],
+ });
+ const p = await run(root, { transcripts: true, posts: false, chat: true });
+ // alpha is in v1's cues and v2's chat, so only v3 is left.
+ assert.deepEqual([...p.slugs], ["v3"]);
+ assert.equal(p.leafStates.get("a")?.slugCount, 2);
+});
+
+test("the fold before every copy has started: the largest copy, still active", () => {
+ const { origin, unionOf } = applySearchIn(ALPHA(), {
+ transcripts: true,
+ posts: true,
+ chat: true,
+ });
+ const state = (slugCount: number) => ({
+ slugCount,
+ totalHits: slugCount,
+ processed: 1,
+ totalToProcess: 4,
+ capped: false,
+ cached: false,
+ active: false,
+ });
+ const p: TreeProgress = {
+ slugs: new Set(),
+ hits: new Map(),
+ leafStates: new Map([
+ ["a~transcripts", state(3)],
+ ["a~posts", state(1)],
+ ]),
+ // What the evaluator reports while a copy has no result: the whole scope.
+ groupStates: new Map([["a~in", { slugCount: 30_000 }]]),
+ done: false,
+ capped: false,
+ };
+ const folded = foldSearchIn(p, { origin, unionOf });
+ const a = folded.leafStates.get("a");
+ assert.equal(a?.slugCount, 3);
+ assert.equal(a?.active, true);
+ assert.equal(a?.totalHits, 4);
+ assert.equal(a?.processed, 2);
+ assert.equal(a?.totalToProcess, 8);
+});
+
+test("nothing rewritten: the fold hands back the same progress", () => {
+ const p: TreeProgress = {
+ slugs: new Set(["v1"]),
+ hits: new Map(),
+ leafStates: new Map(),
+ groupStates: new Map(),
+ done: true,
+ capped: false,
+ };
+ assert.equal(foldSearchIn(p, { origin: new Map(), unionOf: new Map() }), p);
+});
+
+// ─── An empty scope settles (review M1) ───
+// The rewrite makes copies whose scope can be empty from a plain query: a posts
+// copy under a tag filter or on a channel selection with no posts, a chat copy
+// where no video in scope has chat, a transcripts copy when the Type row keeps
+// no video. The drivers used to start no worker for zero slugs and so never
+// finalize — the query read "searching" for ever. These run the REAL drivers.
+
+const realFetchers: LeafFetchers = {
+ transcript: async (slug) =>
+ ({
+ slug,
+ cues: [{ start: 1, end: 2, text: TEXT.transcripts[slug] ?? "" }],
+ }) as unknown as TranscriptDetail,
+ subs: async (slug) =>
+ ({
+ slug,
+ tracks: { live_chat: [{ start: 1, end: 2, text: TEXT.chat[slug] ?? "" }] },
+ }) as unknown as SubsDetail,
+ post: async (slug) => ({ slug, text: TEXT.posts[slug] ?? "" }) as unknown as Post,
+};
+const realRunLeaf: LeafRunner = (opts) =>
+ runLeafPipeline({ ...opts, fetchers: realFetchers });
+
+function within<T>(p: Promise<T>, ms = 2_000): Promise<T> {
+ return Promise.race([
+ p,
+ new Promise<T>((_, reject) =>
+ setTimeout(() => reject(new Error(`not settled within ${ms} ms`)), ms),
+ ),
+ ]);
+}
+
+for (const scope of ["transcripts", "posts", "chat", "description"] as const) {
+ test(`the real ${scope} driver settles an empty scope, and again after a raised cap`, async () => {
+ const seen: LeafProgress[] = [];
+ const ctrl = runLeafPipeline({
+ leaf: newLeaf({ id: "x", query: "alpha", scope }),
+ scopeSlugs: [],
+ initialHitLimit: 10,
+ concurrency: 2,
+ flushIntervalMs: 5,
+ emit: (p) => seen.push(p),
+ fetchers: realFetchers,
+ });
+ const result = await within(ctrl.done);
+ assert.equal(result.slugs.size, 0);
+ assert.equal(seen.at(-1)?.done, true);
+ // "Show more" on it resumes nothing, and must settle again.
+ ctrl.setHitLimit(1_000);
+ assert.equal(seen.at(-1)?.done, true);
+ });
+}
+
+function runReal(
+ root: GroupNode,
+ s: SearchIn,
+ scopes: { chat: ReadonlySet<string> | null; posts: ReadonlySet<string> | null },
+ // Every folded progress, in order — what the session's readout sees.
+ seen: TreeProgress[] = [],
+): Promise<TreeProgress> {
+ const rewritten = applySearchIn(root, s);
+ return within(
+ new Promise((resolve) => {
+ runQueryTree({
+ root: rewritten.root,
+ runtime: { runLeaf: realRunLeaf, cache: noCache },
+ globalScope: ["v1", "v2", "v3"],
+ summaries: [],
+ chatScopeSlugs: scopes.chat,
+ postScopeSlugs: scopes.posts,
+ initialHitLimit: 500,
+ concurrency: 2,
+ flushIntervalMs: 5,
+ emit: (p) => {
+ const folded = foldSearchIn(p, rewritten);
+ seen.push(folded);
+ if (p.done) resolve(folded);
+ },
+ });
+ }),
+ );
+}
+
+test("a plain query whose posts copy has nothing to read finishes (posts scope null)", async () => {
+ // What the session passes under a tag filter: no posts in scope at all.
+ const seen: TreeProgress[] = [];
+ const p = await runReal(
+ ALPHA(),
+ { transcripts: true, posts: true, chat: false },
+ { chat: null, posts: null },
+ seen,
+ );
+ assert.deepEqual([...p.slugs], ["v1"]);
+ assert.equal(p.leafStates.get("a")?.active, false);
+ // The folded leaf's "searched N/M" only climbs (re-review R-L1): the empty
+ // copy reports 0 of 0, not its parent scope as done, so the readout does
+ // not start full and fall back as the transcripts copy streams.
+ let lastProcessed = 0;
+ let lastFraction = 0;
+ for (const q of seen) {
+ const a = q.leafStates.get("a");
+ if (!a || a.totalToProcess === 0) continue;
+ assert.ok(a.processed >= lastProcessed, `processed fell: ${lastProcessed} → ${a.processed}`);
+ const f = a.processed / a.totalToProcess;
+ assert.ok(f >= lastFraction, `searched fell: ${lastFraction} → ${f}`);
+ lastProcessed = a.processed;
+ lastFraction = f;
+ }
+ assert.equal(p.leafStates.get("a")?.processed, 3);
+ assert.equal(p.leafStates.get("a")?.totalToProcess, 3, "the empty copy adds nothing to the total");
+});
+
+test("… and with an empty posts set (a channel selection with no posts channel)", async () => {
+ const p = await runReal(ALPHA(), { transcripts: true, posts: true, chat: false }, {
+ chat: null,
+ posts: new Set(),
+ });
+ assert.deepEqual([...p.slugs], ["v1"]);
+ assert.equal(p.leafStates.get("a")?.active, false);
+});
+
+test("… and with Live chat ticked where no video in scope has chat", async () => {
+ const p = await runReal(ALPHA(), { transcripts: true, posts: false, chat: true }, {
+ chat: new Set(),
+ posts: null,
+ });
+ assert.deepEqual([...p.slugs], ["v1"]);
+ // Posts only, with no posts: nothing, and done.
+ const none = await runReal(ALPHA(), { transcripts: false, posts: true, chat: false }, {
+ chat: null,
+ posts: null,
+ });
+ assert.equal(none.slugs.size, 0);
+ assert.equal(none.leafStates.get("a")?.active, false);
+});
diff --git a/common/lib/search/searchIn.ts b/common/lib/search/searchIn.ts
@@ -0,0 +1,94 @@
+// The "Search in" fold — a run of the rewritten tree, read as the tree the
+// visitor built.
+//
+// `applySearchIn` (lib/searchQuery.ts) turns a "transcripts" leaf that reads
+// two or three kinds into an OR over one copy per kind, each copy with an id of
+// its own. The run reports under those ids; the builder, the result cards and
+// the session all know only the visitor's leaf. This fold files every copy's
+// hits and counts back under that leaf, so a chat hit lands in the same
+// section of the same video row as the transcript hits, wearing its track.
+//
+// Pure: it copies what it changes and returns the progress it was given when
+// the rewrite changed nothing.
+
+import type { SearchInTree } from "../searchQuery";
+import type { LeafState, TreeProgress } from "../searchEval";
+import type { LayerHit } from "./leafPipeline";
+
+export function foldSearchIn(
+ p: TreeProgress,
+ tree: Pick<SearchInTree, "origin" | "unionOf">,
+): TreeProgress {
+ const { origin, unionOf } = tree;
+ if (origin.size === 0) return p;
+
+ const hits = new Map<string, LayerHit[]>();
+ for (const [slug, list] of p.hits) {
+ let changed = false;
+ const out = list.map((h) => {
+ const to = origin.get(h.leafId);
+ if (to === undefined) return h;
+ changed = true;
+ return { ...h, leafId: to };
+ });
+ hits.set(slug, changed ? out : list);
+ }
+
+ // Each copy's state, grouped under the leaf it stands for. A copy that has
+ // not started yet has no state at all.
+ const copies = new Map<string, LeafState[]>();
+ const copiesExpected = new Map<string, number>();
+ for (const to of origin.values()) {
+ copiesExpected.set(to, (copiesExpected.get(to) ?? 0) + 1);
+ }
+ const leafStates = new Map<string, LeafState>();
+ for (const [id, state] of p.leafStates) {
+ const to = origin.get(id);
+ if (to === undefined) {
+ leafStates.set(id, state);
+ continue;
+ }
+ const list = copies.get(to) ?? [];
+ list.push(state);
+ copies.set(to, list);
+ }
+
+ for (const [to, states] of copies) {
+ const allStarted = states.length === (copiesExpected.get(to) ?? 0);
+ // The OR group's state is the union of what the copies matched, inside the
+ // same scope the leaf itself would have read — the count the leaf shows.
+ // Until every copy has started it is not: the evaluator reads a copy with
+ // no result yet as "everything in scope", so the union would briefly be the
+ // whole scope. Until then the largest copy is the count (a lower bound).
+ const groupId = unionOf.get(to);
+ const union = groupId ? p.groupStates.get(groupId) : undefined;
+ let slugCount = 0;
+ if (allStarted && union) slugCount = union.slugCount;
+ else for (const s of states) slugCount = Math.max(slugCount, s.slugCount);
+ let totalHits = 0;
+ let processed = 0;
+ let totalToProcess = 0;
+ let capped = false;
+ let cached = true;
+ let active = !allStarted;
+ for (const s of states) {
+ totalHits += s.totalHits;
+ processed += s.processed;
+ totalToProcess += s.totalToProcess;
+ capped ||= s.capped;
+ cached &&= s.cached;
+ active ||= s.active;
+ }
+ leafStates.set(to, {
+ slugCount,
+ totalHits,
+ processed,
+ totalToProcess,
+ capped,
+ cached,
+ active,
+ });
+ }
+
+ return { ...p, hits, leafStates };
+}
diff --git a/common/lib/searchEval.ts b/common/lib/searchEval.ts
@@ -345,16 +345,30 @@ async function runLeaf(
// Fully-network leaves (transcripts / chat). Try the layer cache first.
const scopeArr = Array.from(effectiveScope);
+ // Nothing to read — a posts leaf on a site or selection with no posts, a
+ // chat leaf where no video in scope has chat, a video leaf over posts only.
+ // "Search in" makes such leaves from a plain query, so settle it here, at
+ // once and without a cache lookup: matched nothing, done. (The drivers
+ // settle an empty scope too; this skips the cache round trip.)
+ if (scopeArr.length === 0) {
+ return applyCached(
+ leaf,
+ 0,
+ { slugs: new Set(), hits: new Map() },
+ ctx,
+ /*cached*/ false,
+ );
+ }
const scopeHash = hashSlugs(scopeArr);
const key = ctx.runtime.cache.key(canonicalHash(leaf), scopeHash);
const cachedSync = ctx.runtime.cache.getSync(key);
if (cachedSync) {
- return applyCached(leaf, parentScope, cachedSync, ctx, /*cached*/ true);
+ return applyCached(leaf, scopeArr.length, cachedSync, ctx, /*cached*/ true);
}
const cached = await ctx.runtime.cache.get(key);
if (ctx.cancelled) return new Set();
if (cached) {
- return applyCached(leaf, parentScope, cached, ctx, /*cached*/ true);
+ return applyCached(leaf, scopeArr.length, cached, ctx, /*cached*/ true);
}
// Cache miss — kick off a real pipeline. Pure-filter leaves (no hit
@@ -414,9 +428,16 @@ async function runLeaf(
return new Set(result.slugs);
}
+// `scopeSize` is the size of the scope the leaf READ — its effective scope,
+// not its parent's — so its progress is "N of N" for the slugs it covered. The
+// parent scope over-counted: a chat leaf narrowed to the videos with chat, or
+// a "Search in" copy, reported the whole parent as processed, and the folded
+// plain leaf's "searched N/M" started full and then fell back as its other
+// copies streamed. An empty scope reports 0 of 0, which the session's
+// progress readout skips.
function applyCached(
leaf: LeafNode,
- parentScope: Set<string>,
+ scopeSize: number,
cached: CachedResult,
ctx: EvalCtx,
isCacheHit: boolean,
@@ -438,8 +459,8 @@ function applyCached(
setLeafState(ctx, leaf.id, {
slugCount: slugs.size,
totalHits: countHits(hits),
- processed: parentScope.size,
- totalToProcess: parentScope.size,
+ processed: scopeSize,
+ totalToProcess: scopeSize,
capped: false,
cached: isCacheHit,
active: false,
diff --git a/common/lib/searchQuery.test.ts b/common/lib/searchQuery.test.ts
@@ -0,0 +1,201 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ SEARCH_IN_DEFAULT,
+ applySearchIn,
+ canonicalHash,
+ isGroup,
+ isLeaf,
+ newGroup,
+ newLeaf,
+ searchInReadsNothing,
+ searchInUnderTags,
+ stringifyRoot,
+ type GroupNode,
+ type LeafNode,
+ type SearchIn,
+} from "./searchQuery";
+
+// The "Search in" rewrite (release 16, slice CK): what a "transcripts" leaf
+// reads is the row's business, a leaf asked for by name is not.
+
+const T_ONLY: SearchIn = { transcripts: true, posts: false, chat: false };
+
+function tree(...children: GroupNode["children"]): GroupNode {
+ return newGroup({ id: "root", children });
+}
+
+test("the default row with no posts corpus changes nothing — the very same root", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha" }));
+ const r = applySearchIn(root, T_ONLY);
+ assert.equal(r.root, root);
+ assert.equal(r.origin.size, 0);
+ assert.equal(r.unionOf.size, 0);
+});
+
+test("the ruling's default is Transcripts and Posts on, Live chat off", () => {
+ assert.deepEqual(SEARCH_IN_DEFAULT, {
+ transcripts: true,
+ posts: true,
+ chat: false,
+ });
+});
+
+test("Transcripts + Live chat: the leaf becomes OR(transcripts, chat) under derived ids", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha" }));
+ const r = applySearchIn(root, { transcripts: true, posts: false, chat: true });
+ const [or] = r.root.children;
+ assert.ok(isGroup(or));
+ assert.equal(or.id, "a~in");
+ assert.equal(or.op, "OR");
+ assert.equal(or.negate, false);
+ assert.deepEqual(
+ or.children.map((c) => [c.id, (c as LeafNode).scope, (c as LeafNode).query]),
+ [
+ ["a~transcripts", "transcripts", "alpha"],
+ ["a~chat", "chat", "alpha"],
+ ],
+ );
+ assert.deepEqual(
+ [...r.origin],
+ [
+ ["a~transcripts", "a"],
+ ["a~chat", "a"],
+ ],
+ );
+ assert.deepEqual([...r.unionOf], [["a", "a~in"]]);
+ // The root keeps its id; nothing is mutated.
+ assert.equal(r.root.id, "root");
+ assert.equal(root.children[0].id, "a");
+});
+
+test("Live chat only: the same leaf, scope chat, same id — nothing to fold", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha", useRegex: true }));
+ const r = applySearchIn(root, { transcripts: false, posts: false, chat: true });
+ const [leaf] = r.root.children;
+ assert.ok(isLeaf(leaf));
+ assert.equal(leaf.id, "a");
+ assert.equal(leaf.scope, "chat");
+ assert.equal(leaf.useRegex, true);
+ assert.equal(r.origin.size, 0);
+ assert.equal(r.unionOf.size, 0);
+});
+
+test("Posts only: the same leaf, scope posts", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha" }));
+ const r = applySearchIn(root, { transcripts: false, posts: true, chat: false });
+ const [leaf] = r.root.children;
+ assert.ok(isLeaf(leaf));
+ assert.equal(leaf.scope, "posts");
+ assert.equal(leaf.id, "a");
+});
+
+test("all three: one copy per kind, in a fixed order", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha" }));
+ const r = applySearchIn(root, { transcripts: true, posts: true, chat: true });
+ const [or] = r.root.children;
+ assert.ok(isGroup(or));
+ assert.deepEqual(
+ or.children.map((c) => (c as LeafNode).scope),
+ ["transcripts", "posts", "chat"],
+ );
+ assert.equal(r.origin.size, 3);
+});
+
+test("nothing ticked: the tree is left as it is (it reads its transcripts)", () => {
+ const root = tree(newLeaf({ id: "a", query: "alpha" }));
+ const nothing = { transcripts: false, posts: false, chat: false };
+ assert.ok(searchInReadsNothing(nothing));
+ assert.ok(!searchInReadsNothing(T_ONLY));
+ assert.ok(!searchInReadsNothing({ transcripts: false, posts: false, chat: true }));
+ assert.equal(applySearchIn(root, nothing).root, root);
+});
+
+test("a leaf asked for by name, and an empty leaf, are not the row's business", () => {
+ const byName = ["chat", "posts", "metadata", "description", "tags"] as const;
+ const root = tree(
+ ...byName.map((scope) => newLeaf({ id: scope, query: "alpha", scope })),
+ newLeaf({ id: "empty", query: " " }),
+ );
+ const r = applySearchIn(root, { transcripts: false, posts: true, chat: true });
+ assert.equal(r.root, root, "no leaf changed, so the root is the same object");
+});
+
+test("a negated leaf reads NOT of the union: the OR sits inside a negated AND", () => {
+ const root = tree(
+ newLeaf({ id: "a", query: "alpha", negate: true, contributeHits: false }),
+ );
+ const r = applySearchIn(root, { transcripts: true, posts: false, chat: true });
+ const [not] = r.root.children;
+ assert.ok(isGroup(not));
+ assert.equal(not.id, "a~not");
+ assert.equal(not.op, "AND");
+ assert.equal(not.negate, true);
+ const [or] = not.children;
+ assert.ok(isGroup(or));
+ assert.equal(or.id, "a~in");
+ assert.equal(or.negate, false, "the union itself is never negated");
+ for (const c of or.children) {
+ assert.ok(isLeaf(c));
+ assert.equal(c.negate, false);
+ assert.equal(c.contributeHits, false, "the copy keeps contributeHits");
+ }
+ assert.deepEqual([...r.unionOf], [["a", "a~in"]]);
+});
+
+test("nested: only the transcripts leaf is rewritten; untouched subtrees keep identity", () => {
+ const untouched = newGroup({
+ id: "g2",
+ op: "OR",
+ children: [newLeaf({ id: "m", query: "title", scope: "metadata" })],
+ });
+ const inner = newGroup({
+ id: "g1",
+ op: "AND",
+ children: [newLeaf({ id: "t", query: "beta" })],
+ });
+ const root = tree(untouched, inner);
+ const before = stringifyRoot(root);
+ const hash = canonicalHash(root);
+ const r = applySearchIn(root, { transcripts: true, posts: true, chat: false });
+ assert.equal(r.root.children[0], untouched);
+ const g1 = r.root.children[1];
+ assert.ok(isGroup(g1));
+ assert.equal(g1.id, "g1");
+ assert.equal(g1.children[0].id, "t~in");
+ // The visitor's tree — its URL form and its hash — is untouched.
+ assert.equal(stringifyRoot(root), before);
+ assert.equal(canonicalHash(root), hash);
+});
+
+test("two transcripts leaves each get their own copies", () => {
+ const root = tree(
+ newLeaf({ id: "a", query: "alpha" }),
+ newLeaf({ id: "b", query: "beta" }),
+ );
+ const r = applySearchIn(root, { transcripts: true, posts: true, chat: false });
+ assert.deepEqual(
+ r.root.children.map((c) => c.id),
+ ["a~in", "b~in"],
+ );
+ assert.deepEqual(
+ [...r.origin.values()].sort(),
+ ["a", "a", "b", "b"],
+ );
+});
+
+test("under a tag filter the posts copy is left out, unless posts are all the row reads", () => {
+ const all = { transcripts: true, posts: true, chat: true };
+ assert.equal(searchInUnderTags(all, false), all, "no tag filter: unchanged");
+ assert.deepEqual(searchInUnderTags(all, true), { ...all, posts: false });
+ assert.deepEqual(
+ searchInUnderTags({ transcripts: false, posts: true, chat: true }, true),
+ { transcripts: false, posts: false, chat: true },
+ );
+ // Posts alone stay: the copy reads an empty scope (no results), rather than
+ // the row reading nothing and the leaf falling back to its transcripts.
+ const postsOnly = { transcripts: false, posts: true, chat: false };
+ assert.equal(searchInUnderTags(postsOnly, true), postsOnly);
+ const noPosts = { transcripts: true, posts: false, chat: false };
+ assert.equal(searchInUnderTags(noPosts, true), noPosts);
+});
diff --git a/common/lib/searchQuery.ts b/common/lib/searchQuery.ts
@@ -472,3 +472,121 @@ export function forEachLeaf(
}
for (const c of root.children) forEachLeaf(c, fn);
}
+
+// ─── "Search in" ───
+// The Filters panel's "Search in" row says what the DEFAULT leaf reads: a leaf
+// whose scope is "transcripts" (what `emptyRoot` and every plain query make)
+// reads the transcript cues, the posts corpus and the live-chat track, each
+// when its box is ticked. A leaf asked for BY NAME in the builder ("Live chat",
+// "Posts", "Title / channel", …) is not the row's business and is left as it
+// is. The row is not part of the tree: the tree the visitor built, the `qt=`
+// it writes and the canonical hash stay what they were, and this rewrite is
+// applied to the committed tree just before it runs.
+//
+// `posts` and `chat` are what the row ticks AND the site ships — the caller
+// folds "Posts is ticked but this site has no posts" to false, so a rewrite
+// never asks for a corpus that is not there.
+export type SearchIn = { transcripts: boolean; posts: boolean; chat: boolean };
+
+// Transcripts and posts on, live chat off: the ruling's default.
+export const SEARCH_IN_DEFAULT: Readonly<SearchIn> = {
+ transcripts: true,
+ posts: true,
+ chat: false,
+};
+
+export function searchInReadsNothing(s: SearchIn): boolean {
+ return !s.transcripts && !s.posts && !s.chat;
+}
+
+// Under a curated-tag filter no post can match — a post carries no curated
+// tags — so a posts copy would read an empty scope and cost a posts-manifest
+// load for nothing. It is left out, unless posts are all the row reads: then
+// it stays (and reads nothing, which is the answer) so that the leaf does not
+// fall back to reading its transcripts.
+export function searchInUnderTags(s: SearchIn, tagFilterOn: boolean): SearchIn {
+ if (!tagFilterOn || !s.posts || (!s.transcripts && !s.chat)) return s;
+ return { ...s, posts: false };
+}
+
+export type SearchInTree = {
+ root: GroupNode;
+ // A per-kind copy's leaf id → the id of the "transcripts" leaf it stands
+ // for. The result fold (`lib/search/searchIn.ts`) reads it to file the
+ // copies' hits and counts back under the leaf the visitor sees.
+ origin: ReadonlyMap<string, string>;
+ // The "transcripts" leaf's id → the OR group over its copies, whose group
+ // state is the union the leaf's count should read. Only for a leaf that
+ // reads two or three kinds (one kind is the same leaf with another scope).
+ unionOf: ReadonlyMap<string, string>;
+};
+
+// The kinds in the order their copies are made. The order is cosmetic (an OR's
+// children run in parallel and its hash sorts them); fixed so a test can name it.
+const SEARCH_IN_KINDS = ["transcripts", "posts", "chat"] as const;
+
+// Rewrite every ACTIVE "transcripts" leaf to read what the row ticks:
+// transcripts only → the leaf, unchanged (the same object)
+// one other kind only → the leaf with that scope; same id, same negate
+// two or three kinds → an OR group over one copy per kind, each copy with
+// the leaf's `contributeHits`. A negated leaf is NOT
+// of the union: the OR goes inside a negated
+// one-child AND, so the OR's own state is always the
+// union (the count the leaf shows) whatever `negate`.
+// nothing → the leaf, unchanged. The UI never commits it (the
+// Search button, Enter, Apply and the profile saves
+// all refuse); a tree that arrives with nothing ticked
+// some other way reads its transcripts rather than
+// silently matching nothing.
+// Copies get derived ids (`<id>~posts`, `<id>~in`, …) because every leaf in a
+// run keys its own results by id. An unchanged subtree keeps its identity, so
+// the default row returns the very root it was given.
+export function applySearchIn(root: GroupNode, s: SearchIn): SearchInTree {
+ const origin = new Map<string, string>();
+ const unionOf = new Map<string, string>();
+ const kinds = SEARCH_IN_KINDS.filter((k) => s[k]);
+ if (kinds.length === 0 || (kinds.length === 1 && kinds[0] === "transcripts")) {
+ return { root, origin, unionOf };
+ }
+ const out = rewriteSearchIn(root, kinds, origin, unionOf);
+ return { root: out as GroupNode, origin, unionOf };
+}
+
+function rewriteSearchIn(
+ node: QueryNode,
+ kinds: ReadonlyArray<LayerScope>,
+ origin: Map<string, string>,
+ unionOf: Map<string, string>,
+): QueryNode {
+ if (isLeaf(node)) {
+ if (node.scope !== "transcripts" || !isLeafActive(node)) return node;
+ if (kinds.length === 1) return { ...node, scope: kinds[0] };
+ const union: GroupNode = {
+ kind: "group",
+ id: `${node.id}~in`,
+ op: "OR",
+ negate: false,
+ children: kinds.map((scope) => {
+ const id = `${node.id}~${scope}`;
+ origin.set(id, node.id);
+ return { ...node, id, scope, negate: false };
+ }),
+ };
+ unionOf.set(node.id, union.id);
+ if (!node.negate) return union;
+ return {
+ kind: "group",
+ id: `${node.id}~not`,
+ op: "AND",
+ negate: true,
+ children: [union],
+ };
+ }
+ let changed = false;
+ const children = node.children.map((c) => {
+ const r = rewriteSearchIn(c, kinds, origin, unionOf);
+ if (r !== c) changed = true;
+ return r;
+ });
+ return changed ? { ...node, children } : node;
+}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## [Unreleased]
+- **A search with a layer that has nothing to read finishes.** A "Posts" layer under a tag chip, or a "Live chat" layer where no video in the selection has live chat, read "searched N/M…" for ever and never said "No matching videos."; it now finishes at once, having matched nothing. Needs a rebuild and deploy of each site and the hub.
+- **A search reads what the visitor ticks under "Search in": Transcripts, Posts and Live chat.** The Filters panel has a new row, **Search in**, beside Type. **Transcripts** and **Posts** are ticked by default and **Live chat** is not; Posts is offered only on a site that has posts, and Live chat only on a site with live chat. The row decides what a plain query reads: with Posts ticked, a plain query now finds posts as well as videos (before, a post was found only by a layer whose scope was "Posts"); with Live chat ticked, it finds live-chat messages too, shown in the same video's card beside the transcript hits, each marked "live chat"; with Transcripts unticked it reads no transcripts. A layer whose scope is picked by name in the query builder ("Live chat", "Posts", "Title / channel", …) reads what it names, whatever the row says. An empty query still lists every video the Type row keeps. With nothing ticked, Search and Apply filters are disabled and the row says "Search in: pick at least one". The Posts box moved here from the Type row, and unticking it no longer empties a layer whose scope is "Posts". Under a tag chip a plain query reads no posts, since a post carries no tags. The row is remembered, and saved with a profile; a shared link does not carry it, so it opens with the reader's own row. A live-chat hit now wears its "live chat" badge wherever it is shown, and the hint under the search bar says to tick Live chat under Search in. Posts unticked is now also remembered after a reload and restored with a profile, which it was not. Needs a rebuild and deploy of each site and the hub.
+
## [0.11.0] - 2026-09-30
- **The charts count every transcript, once the site is rebuilt.** A transcript that arrived after its video was first indexed was missing from the charts' transcript and cue counts and from "Transcribed over time", and a video with YouTube captions alone had no transcription date. Both are counted now, and a captioned video is dated by when its captions arrived.
- **A social icon that fails the check is shown as its label, and every icon paints inside its box.** The footer inlines a social link's SVG only if it passes the same check a save runs (what an icon may contain is in `SITE.md`); otherwise the link shows its label as text, at most 10rem with an ellipsis. Each icon is clipped to its own box. Needs a rebuild and deploy of each site.
diff --git a/export/e2e/fixtures/data.ts b/export/e2e/fixtures/data.ts
@@ -462,8 +462,12 @@ export function subsPage() {
}
// ─── Social-post corpus fixtures ───
-// "alpha" appears in BOTH the transcript cues and a post body, so a combined
-// (transcripts OR posts) query is provably returning results from both corpora.
+// The posts have words of their own ("kappa", "sigma", "omega"), in no video's
+// cues, title or chat. Since release 16 a plain query reads posts by default
+// ("Search in": Transcripts and Posts ticked), and when the posts said "alpha"
+// and "gamma" like every video's cues, every spec that searches those words
+// for its own reasons got two post cards it was not about. A query that reads
+// both corpora names a word from each (posts-search.spec, search-in.spec).
function makePost(
id: string,
@@ -523,17 +527,17 @@ export function channelPostsManifest() {
export function postsPage() {
return [
- makePost(POST_ROOT_ID, "a post about alpha things", {
+ makePost(POST_ROOT_ID, "a post about kappa things", {
links: ["https://example.com/linked"],
engagement: { likes: 12, reposts: 3, replies: 1 },
}),
- makePost(POST_REPLY_ID, "replying about alpha again", {
+ makePost(POST_REPLY_ID, "replying about kappa again", {
isReply: true,
threadId: POST_ROOT_ID,
replyTo: { platform: "bluesky", id: POST_ROOT_ID },
createdAt: "2026-02-03T11:00:00.000Z",
}),
- makePost(POST_DELETED_ID, "a deleted gamma post", {
+ makePost(POST_DELETED_ID, "a deleted sigma post", {
isDeleted: true,
availability: "deleted",
availabilityCheckedAt: "2026-02-04T00:00:00.000Z",
diff --git a/export/e2e/posts-search.spec.ts b/export/e2e/posts-search.spec.ts
@@ -15,6 +15,11 @@ import { installRoutes } from "./helpers";
// The social-post corpus as a PARALLEL dataset to video transcripts: one
// search, one result set, with a toggle-able mode. Seeded through `?qt=`
// (the composite query tree) exactly like query-tree.spec.ts.
+//
+// The posts say "kappa" (two of them), "sigma" (the deleted one) and "omega";
+// every video's cues say "alpha". Since release 16 a plain query — a
+// "transcripts" leaf — reads posts too while Posts is ticked under Search in
+// (search-in.spec.ts); a leaf whose scope is "Posts" reads them by name.
type SLeaf = {
k: "l";
@@ -63,7 +68,7 @@ test.describe("social-post corpus — search", () => {
const tree: SGroup = {
k: "g",
o: "AND",
- c: [{ k: "l", q: "alpha", s: "posts" }],
+ c: [{ k: "l", q: "kappa", s: "posts" }],
};
await page.goto(`/?qt=${qt(tree)}`);
await expectResultSlugs(page, [POST_ROOT_SLUG, POST_REPLY_SLUG]);
@@ -84,7 +89,7 @@ test.describe("social-post corpus — search", () => {
o: "OR",
c: [
{ k: "l", q: "alpha", s: "transcripts" },
- { k: "l", q: "alpha", s: "posts" },
+ { k: "l", q: "kappa", s: "posts" },
],
};
await page.goto(`/?qt=${qt(tree)}`);
@@ -101,7 +106,7 @@ test.describe("social-post corpus — search", () => {
const tree: SGroup = {
k: "g",
o: "AND",
- c: [{ k: "l", q: "alpha", s: "posts" }],
+ c: [{ k: "l", q: "kappa", s: "posts" }],
};
await page.goto(`/?qt=${qt(tree)}`);
await expectResultSlugs(page, [POST_ROOT_SLUG, POST_REPLY_SLUG]);
@@ -133,7 +138,7 @@ test.describe("social-post corpus — search", () => {
const tree: SGroup = {
k: "g",
o: "AND",
- c: [{ k: "l", q: "alpha", s: "posts" }],
+ c: [{ k: "l", q: "kappa", s: "posts" }],
};
await page.goto(`/?qt=${qt(tree)}`);
await expectResultSlugs(page, [POST_ROOT_SLUG, POST_REPLY_SLUG]);
@@ -147,7 +152,7 @@ test.describe("social-post corpus — search", () => {
await expect(modal).toBeVisible();
// Appears twice by design: as the primary post and again in its thread.
await expect(
- modal.getByText("a post about alpha things").first(),
+ modal.getByText("a post about kappa things").first(),
).toBeVisible();
// Thread context: the reply is archived under the same threadId.
await expect(modal.getByText(/Thread \(2 posts\)/)).toBeVisible();
@@ -157,13 +162,13 @@ test.describe("social-post corpus — search", () => {
).toBeVisible();
});
- test("the Posts type toggle switches the corpus off", async ({ page }) => {
+ test("the Posts box leaves a \"Posts\" leaf reading posts", async ({ page }) => {
const tree: SGroup = {
k: "g",
o: "OR",
c: [
{ k: "l", q: "alpha", s: "transcripts" },
- { k: "l", q: "alpha", s: "posts" },
+ { k: "l", q: "kappa", s: "posts" },
],
};
await page.goto(`/?qt=${qt(tree)}`);
@@ -175,7 +180,9 @@ test.describe("social-post corpus — search", () => {
POST_REPLY_SLUG,
]);
- // Posts are a third media kind beside Videos / Livestreams.
+ // The Posts box sits under Search in (release 16; it was in the Type row,
+ // same key `nop`) and says what a plain query reads. A leaf whose scope is
+ // "Posts" was asked for by name, and still reads its two posts unticked.
await page.getByRole("checkbox", { name: "Posts" }).uncheck();
await page.getByTestId("search-submit").click();
@@ -183,6 +190,8 @@ test.describe("social-post corpus — search", () => {
TRANSCRIPT_ONLY_SLUG,
CHAT_SMALL_SLUG,
CHAT_LARGE_SLUG,
+ POST_ROOT_SLUG,
+ POST_REPLY_SLUG,
]);
});
});
@@ -197,7 +206,7 @@ test("a deleted post is flagged in results and in the modal", async ({ page }) =
o: "AND",
// A term unique to the deleted fixture, so the other specs' expected
// result sets stay untouched.
- c: [{ k: "l", q: "gamma", s: "posts" }],
+ c: [{ k: "l", q: "sigma", s: "posts" }],
};
await page.goto(`/?qt=${qt(tree)}`);
const deletedSlug = `${POST_CHANNEL_SLUG}/${POST_DELETED_ID}`;
diff --git a/export/e2e/search-in.spec.ts b/export/e2e/search-in.spec.ts
@@ -0,0 +1,327 @@
+import { expect, test, type Page } from "@playwright/test";
+import {
+ CHANNEL_SLUG,
+ TAG_COLLAB,
+ POST_CHANNEL_SLUG,
+ POST_REPLY_ID,
+ POST_ROOT_ID,
+ VIDEO_CHAT_LARGE,
+ VIDEO_CHAT_SMALL,
+ VIDEO_TRANSCRIPT_ONLY,
+} from "./fixtures/data";
+import { installRoutes, installTagRoutes, openFilters, showAll } from "./helpers";
+
+// Release 16, slice CK: the Filters panel's "Search in" row — Transcripts,
+// Posts, Live chat — says what a plain query (a "transcripts" leaf) reads.
+// Transcripts and Posts are on by default, Live chat off; a leaf asked for by
+// name in the builder is not the row's business; an empty query still lists
+// what the Type row says.
+//
+// The fixture: every video's cues say "… — alpha line" (and beta, gamma); two
+// posts say "kappa", which no video does; only VIDEO_CHAT_SMALL's live chat
+// says "message".
+
+const STORAGE_KEY = "ytdlp-tb:export-filters";
+
+const TRANSCRIPT_ONLY_SLUG = `${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`;
+const CHAT_SMALL_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`;
+const CHAT_LARGE_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}`;
+const VIDEO_SLUGS = [TRANSCRIPT_ONLY_SLUG, CHAT_SMALL_SLUG, CHAT_LARGE_SLUG];
+const POST_SLUGS = [
+ `${POST_CHANNEL_SLUG}/${POST_ROOT_ID}`,
+ `${POST_CHANNEL_SLUG}/${POST_REPLY_ID}`,
+];
+
+const row = (page: Page) => page.getByTestId("search-in-row");
+const box = (page: Page, name: "Transcripts" | "Posts" | "Live chat") =>
+ row(page).getByRole("checkbox", { name, exact: true });
+const leafInput = (page: Page) =>
+ page.locator('input[data-testid^="leaf-query-"]').first();
+
+async function expectResultSlugs(page: Page, slugs: string[]) {
+ const cards = page.locator("[data-card-header]");
+ await expect(async () => {
+ const got = await cards.evaluateAll((els) =>
+ els.map((e) => e.getAttribute("data-result-slug") ?? ""),
+ );
+ expect(got.slice().sort()).toEqual(slugs.slice().sort());
+ }).toPass({ timeout: 15_000 });
+}
+
+async function search(page: Page, q: string) {
+ await leafInput(page).fill(q);
+ await page.getByTestId("search-submit").click();
+}
+
+// The search has finished: the progress line has lost its ellipsis.
+async function expectFinished(page: Page) {
+ await expect(page.getByText(/^searched \d+\/\d+$/)).toBeVisible({ timeout: 15_000 });
+}
+
+test.describe("Search in", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ test("by default Transcripts and Posts are ticked and Live chat is not", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await openFilters(page);
+ await expect(box(page, "Transcripts")).toBeChecked();
+ await expect(box(page, "Posts")).toBeChecked();
+ await expect(box(page, "Live chat")).not.toBeChecked();
+ // Posts moved here from the Type row: one box of that name on the page.
+ await expect(page.getByRole("checkbox", { name: "Posts", exact: true })).toHaveCount(1);
+ await expect(page.getByRole("checkbox", { name: "Videos" })).toBeVisible();
+ await expect(page.getByRole("checkbox", { name: "Livestreams" })).toBeVisible();
+ // The bar's hint points at the row, and goes once the box is ticked.
+ const hint = page.getByText(/Live chat available on 2 videos — tick Live chat under Search in/);
+ await expect(hint).toBeVisible();
+ await box(page, "Live chat").check();
+ await expect(hint).toHaveCount(0);
+ // The refusal line is always there, and empty unless Search is refused.
+ await expect(page.getByTestId("search-in-empty")).toBeEmpty();
+ });
+
+ test("a plain query reads posts by default; unticking Posts drops them", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await search(page, "kappa");
+ await expectResultSlugs(page, POST_SLUGS);
+ // A post's section says what it holds, not the leaf's "Transcripts".
+ await expect(
+ page.locator(`[data-result-slug="${POST_SLUGS[0]}"] [data-leaf-section]`),
+ ).toContainText("Posts");
+
+ await openFilters(page);
+ await box(page, "Posts").uncheck();
+ await page.getByTestId("search-submit").click();
+ await expect(page.getByTestId("results-summary")).toHaveText("Matching videos (0)");
+ });
+
+ test("one plain leaf reads both corpora into one result set", async ({ page }) => {
+ // A regex leaf of scope "transcripts" — the default leaf — with no posts
+ // leaf beside it: the videos by their cues, the posts by their bodies.
+ const tree = {
+ k: "g",
+ o: "AND",
+ c: [{ k: "l", q: "alpha|kappa", s: "transcripts", r: 1 }],
+ };
+ await page.goto(`/?qt=${encodeURIComponent(JSON.stringify(tree))}`);
+ await expectResultSlugs(page, [...VIDEO_SLUGS, ...POST_SLUGS]);
+ await expect(page.getByTestId("results-summary")).toHaveText(
+ "Matching videos (5 videos, 5 hits)",
+ );
+ });
+
+ test("ticking Live chat reads the chat: hits appear, badged live chat", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ // "message" is only in VIDEO_CHAT_SMALL's live chat: by default, nothing.
+ await search(page, "message");
+ await expect(page.getByTestId("results-summary")).toHaveText("Matching videos (0)");
+
+ await openFilters(page);
+ await box(page, "Live chat").check();
+ await page.getByTestId("search-submit").click();
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+ await expect(page.getByTestId("results-summary")).toHaveText(
+ "Matching videos (1 video, 30 hits)",
+ );
+ const card = page.locator(`[data-result-slug="${CHAT_SMALL_SLUG}"]`);
+ // Every hit shown wears the track badge; the section says Live chat.
+ const hits = card.locator("[data-leaf-section] + ul > li");
+ const badges = card.getByText("live chat", { exact: true });
+ await expect(badges.first()).toBeVisible();
+ expect(await badges.count()).toBe(await hits.count());
+ await expect(card.locator("[data-leaf-section]")).toContainText("Live chat");
+ // The URL carries the query the visitor built, not the rewrite.
+ const qt = new URL(page.url()).searchParams.get("qt") ?? "";
+ expect(JSON.parse(qt)).toEqual({
+ k: "g",
+ o: "AND",
+ c: [{ k: "l", q: "message", s: "transcripts" }],
+ });
+ });
+
+ test("with Transcripts off and Live chat on, a word only in the cues finds nothing", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await openFilters(page);
+ await box(page, "Transcripts").uncheck();
+ await box(page, "Live chat").check();
+
+ // "line" is in every video's cues and nowhere else.
+ await search(page, "line");
+ await expect(page.getByTestId("results-summary")).toHaveText("Matching videos (0)");
+ await expect(page.locator("[data-card-header]")).toHaveCount(0);
+
+ await search(page, "message");
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+
+ // With Transcripts back on, "line" finds all three again.
+ await box(page, "Transcripts").check();
+ await search(page, "line");
+ await expectResultSlugs(page, VIDEO_SLUGS);
+ });
+
+ test("with nothing ticked Search is refused and the row says so", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await openFilters(page);
+ await leafInput(page).fill("kappa");
+ await box(page, "Transcripts").uncheck();
+ await box(page, "Posts").uncheck();
+ // Live chat is off by default: nothing is ticked now.
+ const submit = page.getByTestId("search-submit");
+ const empty = page.getByTestId("search-in-empty");
+ await expect(submit).toBeDisabled();
+ await expect(empty).toHaveText("Search in: pick at least one");
+ await expect(empty).toHaveAttribute("aria-live", "polite");
+ // The disabled button says why, to a screen reader as well as on hover.
+ await expect(submit).toHaveAccessibleDescription("Search in: pick at least one");
+ // A save commits the draft, so Save as… is refused the same way.
+ await expect(page.getByRole("button", { name: "Save as…" })).toBeDisabled();
+ await expect(page.getByText("Press Enter or click Search to apply")).toHaveCount(0);
+ // Enter does not commit it either.
+ await leafInput(page).press("Enter");
+ await page.waitForTimeout(500);
+ expect(new URL(page.url()).searchParams.get("qt")).toBeNull();
+ await expect(page.getByTestId("results-summary")).toHaveCount(0);
+
+ await box(page, "Live chat").check();
+ await expect(submit).toBeEnabled();
+ await expect(empty).toBeEmpty();
+ await expect(submit).toHaveAccessibleDescription("");
+ await expect(page.getByRole("button", { name: "Save as…" })).toBeEnabled();
+ await box(page, "Live chat").uncheck();
+ await expect(submit).toBeDisabled();
+ await box(page, "Posts").check();
+ await expect(submit).toBeEnabled();
+ await submit.click();
+ await expectResultSlugs(page, POST_SLUGS);
+ });
+
+ test("the row survives a reload and a saved profile", async ({ page }) => {
+ await page.goto("/");
+ await openFilters(page);
+ await box(page, "Transcripts").uncheck();
+ await box(page, "Live chat").check();
+ await search(page, "message");
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+
+ await page.reload();
+ await openFilters(page);
+ await expect(box(page, "Transcripts")).not.toBeChecked();
+ await expect(box(page, "Posts")).toBeChecked();
+ await expect(box(page, "Live chat")).toBeChecked();
+
+ // Save the row as a profile.
+ page.once("dialog", (d) => d.accept("chat only"));
+ await page.getByRole("button", { name: "Save as…" }).click();
+ await expect(page.getByTestId("profile-select")).toHaveValue("chat only");
+ await expect(page.getByTestId("profile-dirty-dot")).toHaveCount(0);
+
+ // Back to the default row and Search: the commit leaves the profile.
+ await box(page, "Transcripts").check();
+ await box(page, "Live chat").uncheck();
+ await page.getByTestId("search-submit").click();
+ await expect(page.getByTestId("profile-select")).toHaveValue("");
+
+ await page.reload();
+ await openFilters(page);
+ await expect(box(page, "Transcripts")).toBeChecked();
+ await expect(box(page, "Live chat")).not.toBeChecked();
+
+ // Loading the profile brings its row back, and it does not read as changed.
+ await page.getByTestId("profile-select").selectOption("chat only");
+ await expect(box(page, "Transcripts")).not.toBeChecked();
+ await expect(box(page, "Posts")).toBeChecked();
+ await expect(box(page, "Live chat")).toBeChecked();
+ await expect(page.getByTestId("profile-dirty-dot")).toHaveCount(0);
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+
+ // Stored off the default only: `notr` and `lc`, and no `nop`.
+ const stored = await page.evaluate(
+ (key) => JSON.parse(window.localStorage.getItem(key) ?? "null"),
+ STORAGE_KEY,
+ );
+ const profile = stored.profiles["chat only"];
+ expect(profile.notr).toBe(true);
+ expect(profile.lc).toBe(true);
+ expect("nop" in profile).toBe(false);
+ });
+
+ test("an empty query with Transcripts off still lists every video", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await openFilters(page);
+ await box(page, "Transcripts").uncheck();
+ await showAll(page);
+ await expect(page.getByTestId("results-summary")).toHaveText("All videos (3)");
+ await expectResultSlugs(page, VIDEO_SLUGS);
+ });
+
+ test("a leaf asked for by name is not changed by the row", async ({ page }) => {
+ // A "Live chat" leaf reads the chat with the row at its default (Live
+ // chat unticked)…
+ const chatLeaf = { k: "g", o: "AND", c: [{ k: "l", q: "message", s: "chat" }] };
+ await page.goto(`/?qt=${encodeURIComponent(JSON.stringify(chatLeaf))}`);
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG]);
+ await openFilters(page);
+ await expect(box(page, "Live chat")).not.toBeChecked();
+
+ // …and a "Posts" leaf reads posts with Transcripts and Posts unticked
+ // (Live chat ticked, so the row reads something and can be committed).
+ await box(page, "Transcripts").uncheck();
+ await box(page, "Posts").uncheck();
+ await box(page, "Live chat").check();
+ await page.getByTestId("search-submit").click();
+ const postsLeaf = { k: "g", o: "AND", c: [{ k: "l", q: "kappa", s: "posts" }] };
+ await page.goto(`/?qt=${encodeURIComponent(JSON.stringify(postsLeaf))}`);
+ await openFilters(page);
+ await expect(box(page, "Transcripts")).not.toBeChecked();
+ await expect(box(page, "Posts")).not.toBeChecked();
+ await expectResultSlugs(page, POST_SLUGS);
+ });
+
+ test("a plain query with a tag chip on finishes, with its results or none", async ({
+ page,
+ }) => {
+ // Posts carry no curated tags, so under a chip the plain query reads no
+ // posts; before the fix the posts copy it read had an empty scope and the
+ // search never finished.
+ await installTagRoutes(page);
+ await page.goto("/");
+ await openFilters(page);
+ await page.locator(`[data-testid="tag-chip"][data-tag-id="${TAG_COLLAB}"]`).click();
+ await search(page, "alpha");
+ await expectResultSlugs(page, [CHAT_SMALL_SLUG, CHAT_LARGE_SLUG]);
+ await expectFinished(page);
+
+ // Posts alone under a chip: nothing can match, and it says so.
+ await box(page, "Transcripts").uncheck();
+ await search(page, "kappa");
+ await expect(page.getByText("No matching videos.")).toBeVisible({ timeout: 15_000 });
+ await expectFinished(page);
+ });
+
+ test("with no video type kept, a plain query still reads the posts, and finishes", async ({
+ page,
+ }) => {
+ // The transcripts copy's scope is then empty (the Type row keeps no video).
+ await page.goto("/");
+ await openFilters(page);
+ await page.getByRole("checkbox", { name: "Videos" }).uncheck();
+ await page.getByRole("checkbox", { name: "Livestreams" }).uncheck();
+ await search(page, "kappa");
+ await expectResultSlugs(page, POST_SLUGS);
+ await expectFinished(page);
+ });
+});
diff --git a/export/e2e/tag-chips.spec.ts b/export/e2e/tag-chips.spec.ts
@@ -239,10 +239,10 @@ test.describe("curated tag chips", () => {
// the assertion because the header is where it showed: N videos and a
// hit count that no card on the page accounts for.
//
- // "chat" matches all three video titles; "alpha" matches two posts.
+ // "chat" matches all three video titles; "kappa" matches two posts.
const tree = qt([
{ q: "chat", s: "metadata" },
- { q: "alpha", s: "posts" },
+ { q: "kappa", s: "posts" },
]);
await page.goto(`/?qt=${tree}&tg=${TAG_COLLAB}`);
await waitForHydration(page);
@@ -263,7 +263,7 @@ test.describe("curated tag chips", () => {
// tag filter, never by this change.
const tree = qt([
{ q: "chat", s: "metadata" },
- { q: "alpha", s: "posts" },
+ { q: "kappa", s: "posts" },
]);
await page.goto(`/?qt=${tree}`);
await waitForHydration(page);
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -7876,3 +7876,66 @@ source mirror (homepage)". Anchors are at the branch.
without it the manifest comes back without the block). Its e2e reads a fixture publish from
`E2E_SOURCE_PUBLIC_DIR` while it holds a manifest (`sourcePublicDir`, `:69`; never in a production
build).
+
+## Search in (verified 2026-09-30, branch `r16/search-in`, after its review)
+
+- **The row governs "transcripts"-scope leaves only.** The Filters panel's "Search in" row
+ (`common/components/FiltersPanel.tsx:421`, `data-testid="search-in-row"`) says what a leaf of
+ scope `"transcripts"` reads — the default leaf, `newLeaf({scope: "transcripts"})` in
+ `emptyRoot()` (`common/lib/searchQuery.ts:86`), and every plain query. A leaf asked for by
+ name ("Live chat", "Posts", "Title / channel", "Description", "Keywords") is not the row's
+ business — including "Posts" with Posts unticked: `nop` no longer gates the global scope
+ (`SearchSessionContext.tsx:898` checks only the tag filter). The browse listing (an empty
+ query) is the Type row's.
+- **The mechanism is a rewrite of the committed tree just before it runs**, never of the tree
+ itself: `applySearchIn(root, {transcripts, posts, chat})` (`searchQuery.ts:544`) turns an active
+ transcripts leaf into itself (transcripts only), itself with another scope and the same id (one
+ other kind), or an OR group `<id>~in` over copies `<id>~transcripts` / `~posts` / `~chat` (two or
+ three kinds; a negated leaf is a negated one-child AND `<id>~not` around the OR). The session
+ (`SearchSessionContext.tsx:589`) runs the rewrite and folds the progress back with
+ `foldSearchIn` (`common/lib/search/searchIn.ts:18`; called at `SearchSessionContext.tsx:960`):
+ copies' hits are re-filed under the visitor's leaf id, their states folded into one (the count
+ is the OR group's union once every copy has started). `committedRoot`, its canonical hash and
+ `qt=` stay the visitor's tree. `lib/search/` has a seventh module for this, `searchIn.ts`.
+ Each copy has its own hit cap, so a plain leaf reading two or three kinds can show up to 2–3×
+ "Max hits" before "Load more", as an explicit OR of leaves always could.
+- **Posts, as read by a plain query, is new in release 16.** On `main` before it a transcripts
+ leaf never read posts: posts joined the global scope only when a posts leaf was in the tree
+ (`needsPostsManifests`), and a non-posts leaf subtracts the posts slugs
+ (`searchEval.ts:337`). The row reads them through a posts copy of the leaf.
+- **Under a curated-tag filter no posts copy is made** (`searchInUnderTags`, `searchQuery.ts:507`):
+ a post carries no tags. Unless posts are all the row reads — then the copy stays and reads an
+ empty scope, so the leaf does not fall back to its transcripts.
+- **An empty scope settles.** Each streaming driver in `lib/search/leafPipeline.ts` finalizes when
+ no worker is running after its first `ensureWorkers` and after a raised cap (`settleIfIdle`,
+ `:199`, `:342`, `:528`); before, zero slugs never reported done and the query read "searching"
+ for ever. `searchEval.runLeaf` also answers an empty effective scope at once, with no cache
+ lookup (`searchEval.ts:353`).
+- **What counts as ticked is what the site can honour** (`SearchSessionContext.tsx:552`, `:562`):
+ Posts only where `postsManifest.channels` is non-empty, Live chat only where
+ `subsManifest.liveChatTotalCount > 0` — the same conditions the panel offers the boxes on. **It
+ waits for the manifests:** `SearchDataValue.manifestsSettled` (`SearchDataContext.tsx:67`; the
+ single site's settles at each manifest's first answer or first failure, `:165`; the hub's is
+ `summariesReady`, since an archive is ready only once both its manifests have settled). Until
+ then Search is not refused and the query does not run (`SearchSessionContext.tsx:944`).
+ Nothing ticked (`draftSearchInEmpty`, `:583`) disables Search, `Apply filters`, Save and Save
+ as…, and `commitSearch` and the two profile saves refuse it. The row's line (`FiltersPanel.tsx:462`,
+ `aria-live="polite"`, not `role="status"`: from xl the panel is always mounted, and the video
+ modal's own status is found by role) is always mounted, empty unless refused; the Search button is described
+ by an always-mounted copy in the bar (`SearchBar.tsx:182`, `id="search-in-refusal"`), since the
+ panel below xl is a sheet that is not mounted while closed. The words are
+ `SEARCH_IN_REFUSAL` (`SearchSessionContext.tsx:190`).
+- **The keys:** `notr` (Transcripts unticked) and `lc` (Live chat ticked) beside `nop` in
+ `FilterSnapshot` (`common/components/exportFilterStorage.ts:38-40`), in the working snapshot and
+ the profiles of `ytdlp-tb:export-filters`, each written only off its default. **Share links do
+ not carry them** (nor `nop`): no share-v1 key, no `ShareSelection` field. The row is read from
+ the stored snapshot on hydration whatever the URL carries, so a `qt=` or share link reads with
+ the visitor's own row.
+- **The display:** every track hit wears its `TrackBadge`, live chat included
+ (`SearchResults.tsx:933`); a transcripts leaf's section is named for what it holds
+ (`sectionScope`, `:949`): "Posts" on a post's card, "Live chat" when every hit is a chat hit.
+- **Not governed by the row:** the charts' own search series (`components/charts/useSearchSeries.ts`)
+ and `/ask`'s retrieval (`export/app/lib/askRetrieval.ts`) build and run their own trees; the MCP
+ has no row.
+- **The export e2e fixture's posts have words of their own** (`kappa`, `sigma`, `omega` —
+ `export/e2e/fixtures/data.ts`, `postsPage`), so a plain "alpha" or "gamma" in a spec reads no post.
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -22,7 +22,9 @@ reviewed SHIP). The operator's runbook is `~/reports/release-15/RUNBOOK.html`.
`settings.storage.health` (DT).
- **Follow-ups the reviews named:** the editor's and export's build traces list dot-directories
(cosmetic while `standalone` is off); `REQUIRED_TOKENS` lacks `--chart-other` (the e2e pins it);
- `export`'s `WorkspaceView` `splitOn` has a one-paint flash from a localStorage restore.
+ `export`'s `WorkspaceView` `splitOn` has a one-paint flash from a localStorage restore;
+ "Load more results" never resumes a leaf that settled at its cap (`runQueryTree`'s `setHitLimit`
+ reaches running leaves only; on `main` too — release 16 CK re-review R-I1; wants a spec).
**Now (2026-09-28, night): the stats cache key fix — built, reviewed (SHIP AFTER FIXES, then SHIP
on re-review; every touch-up done), not merged.** The branch is `fix/stats-cache-key`, and [`stats-cache-key.md`](stats-cache-key.md)
diff --git a/plans/release-16.md b/plans/release-16.md
@@ -6,9 +6,11 @@ slice's prompt carries its ruling, and this record carries what was built. Rules
**The standing choices** (not re-opened):
- **A search reads transcripts and posts unless the visitor says otherwise; live chat is read only
- when asked.** Today a plain query already reads the transcript cues and the posts corpus, and
- reads live chat only through a query-builder leaf whose scope is "Live chat". That stays the
- default; the three become one row of toggles.
+ when asked.** On `main` before this release a plain query read the transcript cues only: posts
+ only through a query-builder leaf whose scope is "Posts", live chat only through one whose scope
+ is "Live chat". A plain query now reads transcripts and posts by default; the three become one
+ row of toggles. (Corrected after slice CK found the first wording, "a plain query already reads
+ … the posts corpus", untrue of the code.)
- **The toggles say what a query reads, not which records list.** "Type: Videos, Livestreams"
keeps saying which records are shown; an empty query with Transcripts off still lists videos.
- **A slice that needs another slice's file stops and says so**; it does not edit it.
@@ -54,3 +56,323 @@ slice's prompt carries its ruling, and this record carries what was built. Rules
- Nothing else on those pages changes; no code path changes.
## Record
+
+### Slice CK, as shipped — a search reads what the visitor ticked (2026-09-30)
+
+Branch `r16/search-in` off `main` `6c6dcd04`, worktree `~/Projects/plans-export-header-first-search`
+(editor 3401, test 3411, export 3410; the export suite's server on 3420), one Opus implementer.
+Scratch files `ck-*` in the job's `tmp`. The ruling is above ("Slice CK — the ruling").
+
+**The ruling's "today" was not the code; the slice built the ruling.** On `main` a plain query — a
+leaf of scope "transcripts" — read the transcript cues and nothing else. The Posts box in the Type
+row put the posts corpus into the global scope only when a leaf of scope "Posts" was in the tree
+(`needsPostsManifests`), and every other leaf subtracts the posts slugs (`searchEval.ts:337`), so a
+plain query never read a post. The row as ruled (Posts ticked by default, the row governs the
+transcripts leaf, the toggles say what a query reads) therefore makes a plain query read posts on a
+site that ships them: what a plain search returns there changes. Taking it back is one flag — `posts`
+false in the session's `committedSearchIn` and `draftSearchIn` — and the Posts box would then govern
+only a "Posts" leaf, as before.
+
+**What was built.**
+
+- **The row.** "Search in" beside Type in the Filters panel (`FiltersPanel.tsx`,
+ `data-testid="search-in-row"`): **Transcripts** (ticked), **Posts** (ticked; the box moved out of
+ the Type row with its key `nop`, its accessible name and its gate, `postsManifest.channels`
+ non-empty), **Live chat** (unticked; offered only when `subsManifest.liveChatTotalCount > 0`). The
+ Type row keeps Videos and Livestreams. The hub gets it through the shared components.
+- **Mechanism (a), the smaller: a rewrite of the committed tree just before it runs.**
+ `applySearchIn(root, {transcripts, posts, chat})` (`common/lib/searchQuery.ts`) turns each active
+ "transcripts" leaf into:
+ - the leaf itself, the same object, with Transcripts alone ticked (so the default row on a site
+ with neither posts nor live chat runs the very tree it was given);
+ - the leaf with the one other kind's scope, same id, same `negate`, with one other kind alone;
+ - an OR group `<id>~in` over copies `<id>~transcripts`, `~posts`, `~chat`, each with the leaf's
+ `contributeHits`, with two or three kinds. A negated leaf is NOT of the union: the OR sits inside
+ a negated one-child AND `<id>~not`, so the OR's own group state is the union whatever `negate`;
+ - with nothing ticked, the leaf unchanged (see the refusal below).
+
+ Posts are not left on the global-scope path, as the prompt proposed: that path reads posts only
+ for a "Posts" leaf, so the rewrite makes one (the posts copy) and the path then feeds it. `nop`
+ acts only here, by leaving the posts copy out; it no longer gates the global scope (review M2),
+ so a leaf whose scope is "Posts" reads posts with the box unticked, as ruled. Under a curated-tag
+ filter no posts copy is made either (`searchInUnderTags`; a post carries no tags), unless posts
+ are all the row reads — then the copy stays and reads an empty scope, and the leaf does not fall
+ back to its transcripts. A leaf of any other scope, and an empty leaf, are untouched.
+ (b) would have run up to three pipelines per leaf inside `runLeaf`, each with its own cache key,
+ controller and streaming merge; (a) touches the evaluator only in `runLeaf`'s empty-scope
+ short-circuit (review M1) and in what a cached or empty leaf reports as its progress (re-review
+ R-L1).
+- **The fold.** `foldSearchIn(progress, tree)` (`common/lib/search/searchIn.ts`, new) files the
+ copies' hits back under the visitor's leaf id (a chat hit keeps `scope: "chat"` and
+ `track: "live_chat"`, so it lands in the same section of the same video row, in time order), and
+ folds the copies' states into one: the count is the OR group's union once every copy has started
+ (before that, the evaluator reads a copy with no result as the whole scope, so the largest copy
+ stands in and the leaf shows as active), hits, processed and to-process summed, capped if any,
+ cached if all. Nothing else reads the copies' ids.
+- **The session** (`SearchSessionContext.tsx`). What counts as ticked is what the site can honour:
+ Posts only with a posts corpus, Live chat only with live chat — the panel's own conditions — so a
+ stored `lc` on a site without chat asks for no subs manifest (see "Found and left" for what a
+ "Live chat" leaf does there). The session waits for the two manifests before it decides anything
+ from the row (review L4): `SearchDataValue.manifestsSettled` — the single site's settles at each
+ manifest's first answer or first failure (a 404 subs manifest is not held for the retry), the
+ hub's is `summariesReady`, since an archive is ready only once both its manifests have settled.
+ Until then Search is not refused and the query does not run, so a stored row with Transcripts
+ unticked never reads transcripts first and then re-runs — on a single site; for the progressive
+ hub see "Found and left". The committed tree runs as `applySearchIn(committedRoot, …)` and its progress
+ is folded before `setTreeProgress`; `needsChatManifests` and `needsPostsManifests` read the
+ rewritten trees (draft and committed), so Live chat ticked loads the subs manifests and passes
+ `chatScopeSlugs` exactly as a "Live chat" leaf does (the prompt's `|| committedLiveChat`, but only
+ when there is a leaf to read it). The run and the hit-cap reset also depend on the rewritten tree's
+ hash, so a change of the row alone re-runs. `committedRoot`, its hash, `qt=`, the builder's leaves
+ and the result cards' sections stay the visitor's tree.
+- **State.** `notr` (Transcripts unticked) and `lc` (Live chat ticked) in `FilterSnapshot`, threaded
+ like `nop`: `parseSnapshot`, `snapshotsEqual`, the draft/committed pairs, `buildDraftSnapshot`,
+ `committedSnapshot`, `filtersDirty`, `promoteDraftsToCommitted`, `filterKey`, hydration,
+ `applyDraftSnapshot`, `applySnapshot`, each written only off its default. Not in `UrlParams`, not in
+ `ShareSelection`: **share links do not carry the row this release** (they do not carry `nop`
+ either). Hydration reads the row from the stored snapshot (active profile, else working) whatever
+ the URL carries, so a `qt=` link — and a share-v1 link, whose filters otherwise replace the
+ stored ones — reads with the visitor's own row.
+- **`nop` was not threaded, three ways, and is now.** Hydration set the draft `nop` to false and
+ never set the committed one, so Posts unticked came back ticked on every reload;
+ `committedSnapshot` did not write it, so a profile with Posts unticked always showed as changed
+ against what it had just loaded; `applySnapshot` did not commit it, so a loaded profile with Posts
+ unticked searched posts until the next Search.
+- **Nothing ticked.** When nothing the site offers is ticked (`draftSearchInEmpty`), Search is
+ disabled, `Apply filters` in the sheet is disabled (`FiltersContainer.tsx`, a new `applyDisabled`
+ prop), Save and Save as… are disabled (`ProfilesRow`'s `saveDisabled`, review L2), and
+ `commitSearch` (where Enter and Apply arrive) and both profile saves (which commit the draft)
+ refuse. The row's line, "Search in: pick at least one" (`data-testid="search-in-empty"`,
+ `aria-live="polite"`), is always mounted and empty unless Search is refused, so the change is
+ announced (review L1; a live region, not `role="status"`, which the first cut used and which gave
+ the page a second "status" beside the modal's — `modal-digest.spec` finds that one by role); the Search button is described (`aria-describedby`) by an always-mounted `sr-only`
+ copy of the words in the bar (`id="search-in-refusal"`), because below xl the panel is a sheet
+ that is not mounted while closed; its `title` stays for the mouse. The bar's "Press Enter or click
+ Search to apply" is withheld while Search is refused. A tree committed with nothing ticked
+ some other way (a hand-edited profile) reads its transcripts rather than matching nothing.
+- **Display.** The `hit.track !== "live_chat"` suppression in `SearchResults.tsx` is gone: every track
+ hit wears its `TrackBadge`, so a "Live chat" leaf's hits now carry the badge too (no spec asserted
+ its absence; `live-chat.spec.ts` searches nothing and was not changed). A transcripts leaf's
+ section bar is named for what it holds (`sectionScope`): "Posts" on a post's card, "Live chat" when
+ every hit in it is a chat hit, else "Transcripts".
+- **The bar.** The hint reads "Live chat available on N videos — tick Live chat under Search in." and
+ goes once Live chat is ticked. The Filters chip's count gains one for the row off its default (Live
+ chat ticked widens rather than narrows, but the panel that says so may be behind the chip); Videos
+ and Livestreams keep their own one.
+
+**The fixture.** The export e2e fixture's posts said "alpha" and "gamma", like every video's cues. With
+posts read by default, every spec searching those words for its own reasons got two post cards: a first
+full run at `2d68d9d9` (stopped at 174 of 265) had 17 failures — 7 in `ask-chat`, 5 in `ask-workspace`,
+2 in `posts-search`, 2 in `query-tree`, all post cards, and 1 in `charts` (a browse-mode stacked-bar
+case the row cannot reach; it passed in both later runs). The posts now say "kappa" and "sigma"
+(`omega` was already theirs); `posts-search.spec.ts` and `tag-chips.spec.ts` name them in their posts
+leaves, and `search-in.spec.ts` reads both corpora with a regex `alpha|kappa` in one plain leaf.
+
+**Commits**
+
+| Commit | What |
+|---|---|
+| `0edc628f` | `common:` `applySearchIn`, `SearchIn`, `SEARCH_IN_DEFAULT`, `searchInReadsNothing` in `lib/searchQuery.ts`; `foldSearchIn` in `lib/search/searchIn.ts`; tests. |
+| `41e75543` | `common:` `notr` and `lc` in the filter snapshot; `parseSnapshot` exported; the round-trip test. |
+| `2d68d9d9` | `common:` the row; the session (rewrite, fold, manifests, state, refusal, the three `nop` fixes); the bar; the badge and the section names; `applyDisabled` on the sheet's Apply. |
+| `5699a1e7` | `export(e2e):` `search-in.spec.ts`; the fixture's posts get their own words; `posts-search` and `tag-chips` follow them. |
+| `861baaa0` | `plans:` this section; FACTS "Search in"; the export changelog. |
+| `1eb089da` | Merge `main` (`0fe719b1`, the 0.11.0 cut, which renamed `[Unreleased]` while this branch added to it): the bullet goes under a fresh `[Unreleased]` above `[0.11.0]`. Changelogs only; no code moved on `main`, so no gate was re-run. |
+| `26c63ccb` | `plans:` the two rows above. |
+| `6980ac75` | `common:` review M1 — the three streaming drivers settle when no worker starts (and after a raised cap); `runLeaf` answers an empty scope at once; tests through the real drivers. |
+| `b22198d5` | `common:` review M2, L1, L2, L4 and M1's tag rule — `nop` no longer gates the global scope; `searchInUnderTags`; `manifestsSettled` on both SearchData providers and the held decision; the always-mounted refusal line and the button's description; Save and Save as… disabled. |
+| `aaf3accd` | `export(e2e):` `search-in` cases 9 (the "Posts" leaf), 10 and 11, the refusal's description and Save as…; `posts-search` for M2. |
+| `76deaac7` | `common:` the refusal line is a polite live region, not a second `role="status"` (it broke `modal-digest.spec`'s `getByRole("status")`). |
+| `1d7da806` | `plans:` the review, its rulings and gates in this section; the standing choice amended; FACTS; the changelog. |
+| `500417f3` | `common:` re-review R-L1 — a cached or empty leaf reports its progress over the scope it read (0 of 0 when empty); a real-driver test asserts the folded "searched N/M" only climbs. |
+| this commit | `plans:` the re-review in this section; "Found and left" gains R-L2, R-I2 and R-I1; the mechanism sentence; the follow-up in `STATE.md`. |
+
+**Tests** (unit)
+
+| File | What it pins |
+|---|---|
+| `lib/searchQuery.test.ts` (11, new) | Transcripts only returns the very same root; the default is Transcripts and Posts; Transcripts + Live chat is `OR(a~transcripts, a~chat)` with both mapped to `a` and `a` → `a~in`; Live chat only and Posts only swap the scope and keep the id; all three in a fixed order; nothing ticked is left alone, and `searchInReadsNothing`; leaves of every named scope and an empty leaf are untouched (same root); a negated leaf becomes a negated AND over an un-negated OR whose copies keep `contributeHits: false`; nested: an untouched subtree keeps its identity, group ids are kept, the input's `stringifyRoot` and `canonicalHash` do not change; two leaves get two sets of copies. |
+| `lib/search/searchIn.test.ts` (8, new) | Through the real `runQueryTree` with a fake leaf runner: Transcripts alone reads the cues only; Transcripts + Posts returns the video and the post, the post hit filed under the visitor's leaf with scope `posts`; Live chat ticked adds the chat-only video, its hit under the leaf with `track: "live_chat"`, the leaf's state folded (only `a` left, count 2 = the union, not active); Transcripts off + Live chat on: a cue-only word finds nothing; a "Live chat" leaf ignores the row; NOT reads NOT of the union and still counts what it matched. The fold alone: before every copy has started, the largest copy and active (not the 30,000 the evaluator reports); nothing rewritten returns the same progress. |
+| `components/exportFilterStorage.test.ts` (5, new) | `notr`, `nop`, `lc` survive a JSON round trip; absent reads as the default and a pre-row profile equals one spelling the defaults; a non-boolean is dropped; `snapshotsEqual` tells each apart; the working snapshot and a profile keep the row through `saveStoredState`/`loadStoredState`. |
+
+**e2e** (`export/e2e/search-in.spec.ts`, new, 11 cases after the review): (1) the defaults — Transcripts and Posts
+ticked, Live chat not, exactly one checkbox named "Posts" on the page, Videos and Livestreams still
+in Type, and the bar's hint, gone once Live chat is ticked; (2) a plain "kappa" returns the two posts
+under a section named "Posts", and none with Posts unticked; (3) one plain regex leaf `alpha|kappa`
+returns 3 videos and 2 posts, "5 videos, 5 hits"; (4) "message" (only in one video's chat) finds
+nothing by default, and with Live chat ticked finds that video, "1 video, 30 hits", every shown hit
+badged "live chat", the section named "Live chat", and `qt=` still the visitor's one transcripts
+leaf; (5) Transcripts off + Live chat on: "line" (only in cues) finds nothing, "message" finds the
+chat video, Transcripts back on finds all three; (6) nothing ticked: Search disabled and described
+by the words, Save as… disabled, the line shown (always mounted, empty otherwise), the "Press Enter"
+line withheld, Enter commits nothing (no `qt=`, no results), Live chat or Posts re-enables it; (7) the row survives a reload, is saved with "Save as…", a diverging Search
+leaves the profile, and loading the profile brings the row back with no unsaved-changes dot and its
+results; storage holds `notr` and `lc` and no `nop`; (8) an empty query with Transcripts off lists
+"All videos (3)"; (9) a "Live chat" leaf from `qt=` reads the chat with the row at its default, and
+a "Posts" leaf reads its two posts with Transcripts and Posts unticked; (10) a plain query under a
+tag chip finishes with its two tagged videos, and with posts alone ticked says "No matching
+videos." and finishes; (11) with Videos and Livestreams unticked a plain "kappa" reads the two posts
+and finishes. `posts-search.spec.ts`: unticking Posts leaves a "Posts" leaf's two posts.
+
+#### Gates (logs `$T/ck-*.log`)
+
+- **tsc** (all workspaces): clean before each code commit — 71 s at `2d68d9d9` (after removing a
+ truncated, stale `editor/.next/dev/types/validator.ts` left in this worktree by an earlier dev run,
+ which failed the first attempt with TS1002); export's alone after the e2e edits.
+- **Unit:**
+
+ | Suite | Result |
+ |---|---|
+ | common | **2,372/2,372**, 84 s (24 new: 11 + 8 + 5) |
+ | mcp | **271/271**, 27 s (it imports `lib/searchQuery.ts`, which only gained exports) |
+ | editor unit, `test:scripts` | not run: no editor file, script or anything they import changed |
+
+- **Build:** `pnpm --filter export exec next build` — exit 0, 35 s, `export/public` links refreshed from
+ the primary and none dangling (three that were — `hub-sites.json`, `hub-summary.json`,
+ `duplicates.json`, gone from the primary — removed first). The editor and umtool builds: not run,
+ neither imports a changed module.
+- **e2e** (detached and queued; none waited in the queue):
+
+ | Run | At | Specs | Result |
+ |---|---|---|---|
+ | 1 | `2d68d9d9` | the prompt's eight (`search-in`, `posts-search`, `live-chat`, `filter-profile-persistence`, `first-search`, `browse-all`, `query-tree`, `share-current-search`) | 50 passed, **6 failed**, 4.4 min — `search-in` 8/8; the six were post cards (above) |
+ | 2 | `2d68d9d9`, old fixture | the full export suite | stopped at 174 of 265: 157 passed, **17 failed** (above) |
+ | 3 | `2d68d9d9` + the fixture and spec edits | the full export suite | 264 passed, **1 failed**, 14.0 min — `tag-chips` named "alpha" in a posts leaf |
+ | 4 | `5699a1e7` | the eight + `tag-chips` | **72 passed**, 0 failed, 3.5 min |
+ | 5 | `5699a1e7` | the hub suite (`e2e:hub`) | **36 passed**, 0 failed, 1.6 min — its fixtures ship no posts and no live chat, so the row is a lone Transcripts box and no tree is rewritten |
+ | 6 | `5699a1e7` | the editor suite's `export-search.spec.ts` (it drives the export's filter rows) | **19 passed**, 0 failed, 1.0 min |
+ | 7 | `5699a1e7` | the full export suite | **265 passed**, 0 failed, 15.1 min |
+
+- **Numbers tool:** none.
+
+#### Found and left
+
+- **Not governed by the row:** a chart's own search series (`components/charts/useSearchSeries.ts`
+ runs its own tree), `/ask`'s retrieval (`export/app/lib/askRetrieval.ts`, its own OR of keywords —
+ though the grounding it is handed from the search page is the session's results, which the row
+ governs), and the MCP (no row).
+- **A late manifest after a failure.** The single site's manifests count as settled at their first
+ failure (so a site with no live chat, whose subs manifest 404s, is not held for the retry). A
+ manifest whose retry does answer arrives later and the row re-runs, as a late manifest always
+ did.
+- **A draft tick restarts the committed run** (review I2, left): the manifests load on the draft's
+ need or the committed one's, and the run waits on both, so ticking Posts or Live chat in the
+ draft cancels a committed run in flight, which re-runs once the manifests are in (cheaply, from
+ the layer cache). The same was already so for a draft "Live chat" leaf.
+- **Hub mode is covered by reasoning** (review I3): no hub or two-origin fixture ships posts or
+ live chat, so their suites see a lone Transcripts box and no rewrite. The rewrite never touches
+ slugs, and the posts and chat scopes are built from the merged, origin-qualified manifests.
+- **On the progressive hub, a stored row of posts only or live chat only can read as empty at
+ first** (re-review R-L2). The hub's `manifestsSettled` is `summariesReady`, true once the first
+ archive is ready, and the merged posts and subs manifests grow one ready archive at a time. If
+ that archive has neither, a stored row with Transcripts unticked reads as nothing: the refusal
+ shows, and a `qt=` link runs through the empty-row fallback, reading transcripts, then re-runs
+ when an archive with posts or chat lands. On a single site the manifests settle together, and the
+ claim holds.
+- **The global scope takes the posts when the draft needs them too** (re-review R-I2):
+ `needsPostsManifests` is the draft's need or the committed tree's. With Posts unticked and a
+ committed negated plain query (`NOT x`), a draft that grows a "Posts" leaf re-runs the committed
+ query, and its result gains every post (the plain leaf read none). Consistent with NOT; the
+ result depends on the draft. On `main` the same held with Posts ticked.
+- **"Load more results" never resumes a leaf that settled at its cap** (re-review R-I1; on `main`
+ too): a driver resolves `done` when its workers stop at the cap, `runLeaf` drops its controller,
+ and the tree's `setHitLimit` reaches running leaves only. More plain queries reach the cap now
+ that each copy has its own. A follow-up in `STATE.md`.
+- **A lone Transcripts box.** On a site with neither posts nor live chat (the hub's e2e fixture) the
+ row is one box, whose only effect unticked is to refuse Search. Shown as ruled (Transcripts is not
+ conditional).
+- **Each post twice under an explicit `(transcripts OR posts)` tree.** With Posts ticked the
+ transcripts leaf reads posts too, so a post matched by both leaves shows one section per leaf. The
+ tree says to read posts twice; the row does not merge leaves.
+- **"Matching videos (N videos, …)" counts posts as videos** — as before, for a "Posts" leaf.
+- **The builder's scope select still says "Transcripts"** for the default leaf, which now reads what
+ the row ticks; its placeholder still says "Search transcripts...". Unchanged (the labels are
+ contracts; `filter-profile-persistence` finds the input by that placeholder).
+- **A "Live chat" leaf on a site with no subs manifest at all waits for ever** (as on `main`): the
+ run waits for `subsManifestReady`, which needs a manifest that 404s. The row does not reach it,
+ since it counts Live chat only where `liveChatTotalCount > 0`.
+- **A stored `lc` on a site without live chat** is kept (the panel does not show the box, and the
+ session counts it as unticked); it applies again on a site that has chat, since profiles are per
+ browser, not per site.
+
+#### Decisions the operator could overturn
+
+| What I assumed | The alternative |
+|---|---|
+| **A plain query reads posts by default** (the ruling as written; the ruling's "today" said it already did, and it did not) | Posts in the row governs only a "Posts" leaf, and a plain query reads no posts, as on `main` (one flag) |
+| Mechanism (a), a rewrite before the run plus a fold of the progress | (b), branching inside `runLeaf` |
+| Posts are read through a posts copy of the leaf, not left on the global-scope path, which reads posts only for a "Posts" leaf | — |
+| Posts and Live chat count as ticked only where the site has them, for the rewrite and for "nothing ticked" | The stored booleans as they are, whatever the site ships |
+| The row always renders, even as a lone Transcripts box | Hide it where neither Posts nor Live chat is offered |
+| With nothing ticked, `Apply filters` and the two profile saves refuse too, and the bar's "Press Enter" line is withheld | Only the Search button |
+| A committed tree with nothing ticked (a hand-edited profile) reads its transcripts | Matches nothing |
+| The row is read from the stored snapshot whatever the URL carries, share-v1 links included | A share-v1 link resets the row to its defaults, as it resets the other filters to the link's |
+| A transcripts leaf's section is named for what it holds ("Posts", "Live chat") | Always "Transcripts" |
+| The Filters chip counts the row off its default as one, Live chat ticked included | Only unticks count, as narrowing |
+| The fixture's posts get words of their own, rather than every spec that searches "alpha" or "gamma" growing two post cards (16 failures in four specs by 174 of 265 tests) | Keep the posts' "alpha" and add the posts to each expectation |
+| The hint goes once Live chat is ticked | Always shown where live chat exists |
+| Each copy of a plain leaf has its own hit cap, so one reading two or three kinds can show up to 2–3× "Max hits" before "Load more" (as an explicit OR of leaves can); the header counts each video once | One cap shared by the copies |
+| After the review: no posts copy under a tag filter, unless posts are all the row reads | A posts copy that reads an empty scope |
+| After the review: the single site's manifests settle at their first failure | At the end of the retry (about a second more on a site with no live chat) |
+| After the review: the Search button is described by a copy of the words in the bar | Described by the panel's line (no description while the sheet is closed) |
+
+#### Review
+
+**Verdict: SHIP AFTER FIXES** (`ck-review.md` in the job's scratch). Rulings (parent, 2026-09-30):
+
+| Finding | Ruling | Where |
+|---|---|---|
+| M1: a copy with nothing to read never finished — none of the three streaming drivers finalized for zero slugs, and the rewrite makes such scopes from a plain query (a posts copy under a tag chip or a channel selection with no posts, a chat copy where no video in scope has chat, a transcripts copy when Type keeps no video), so the query read "searched N/M…" for ever | The drivers settle when idle after their first `ensureWorkers` and after a raised cap; `searchEval.runLeaf` answers an empty scope at once. No posts copy under a tag filter (unless posts are all the row reads). Tests through the real drivers; two spec cases | `6980ac75`, `b22198d5`, `aaf3accd` |
+| M2: `nop` still emptied a leaf of scope "Posts", against the ruling | `nop` no longer gates the global scope; it only leaves out the plain query's posts copy. `posts-search`, FACTS, the storage comment, this record and the changelog follow | `b22198d5`, `aaf3accd`, this commit |
+| L1: the refusal line appeared with its text, and the Search button's reason was only a `title` | The line is always mounted; the button is described by an always-mounted copy in the bar | `b22198d5`, `aaf3accd` |
+| L2: Save and Save as… stayed enabled and silently did nothing | Disabled like Search and Apply | `b22198d5`, `aaf3accd` |
+| L3: case (9) claimed a "Posts" leaf it did not check | Checked, with Transcripts and Posts unticked | `aaf3accd` |
+| L4: a stored row with Transcripts unticked read as empty until the manifests answered, and a `qt=` link could read transcripts first | The decision waits for `manifestsSettled`: no refusal, no run | `b22198d5` |
+| I1: the hit cap applies per copy | Recorded (decisions table) | this commit |
+| I2: a draft tick restarts the committed run | Left, recorded ("Found and left") | this commit |
+| I3: hub mode covered by reasoning only | Recorded ("Found and left") | this commit |
+| I4: the standing-choices paragraph said a plain query already read posts | Amended to what `main` did | this commit |
+
+The empty-scope settling also ends a hang that `main` had for a leaf asked for by name: a "Posts"
+leaf under a tag chip, or a "Live chat" leaf where no video in scope has chat, never finished
+there either (its own changelog bullet).
+
+**Tests after the review:** `lib/searchQuery.test.ts` 12 (+1: `searchInUnderTags`);
+`lib/search/searchIn.test.ts` 15 (+7: each of the transcripts, posts, chat and description drivers
+settles an empty scope and settles again after a raised cap; a plain query finishes through
+`runQueryTree` with the real drivers, with the posts scope null, an empty posts set, and an empty
+chat set, and with posts alone and none to read). All seven new cases fail on the code before the
+fix; the drivers' fix alone passes all 15, the `runLeaf` short-circuit alone passes the three
+`runQueryTree` cases.
+
+#### Gates after the review (logs `$T/ck-*.log`)
+
+- **tsc** (all workspaces): clean, 70 s before the fix commits and 42 s at `76deaac7` (common and
+ export alone before that commit).
+- **common:** **2,380/2,380**, 79 s (+8).
+- **Build:** `pnpm --filter export exec next build` — exit 0, 33 s; no dangling `export/public` link.
+- **e2e:**
+
+ | Run | At | Specs | Result |
+ |---|---|---|---|
+ | 8 | `aaf3accd` | `search-in`, `posts-search`, `query-tree`, `tag-chips`, `live-chat` | **56 passed**, 0 failed, 2.5 min |
+ | 9 | `aaf3accd` | the full export suite | stopped at 214 of 267: 213 passed, **1 failed** — `modal-digest`'s `getByRole("status")` found the always-mounted refusal line beside the modal's status; fixed in `76deaac7` |
+ | 10 | `76deaac7` | the full export suite | **267 passed**, 0 failed, 13.5 min |
+ | 11 | `76deaac7` | the hub suite | **36 passed**, 0 failed, 1.5 min |
+
+#### Re-review
+
+**Verdict: SHIP**, with three small things landed before the merge (parent's rulings, 2026-09-30):
+
+| Finding | Ruling | Where |
+|---|---|---|
+| R-L1: an empty-scope copy reported its parent scope as processed, so the folded "searched N/M" started full and then fell back (a cached copy did the same) | `applyCached` takes the size of the scope the leaf read: 0 of 0 when empty, a cached leaf's effective scope otherwise. The real-driver test with the posts scope null asserts the folded leaf's processed and fraction never fall; it fails on the previous code | `500417f3` |
+| R-L3: "(a) touches the evaluator not at all" was stale | The sentence names `runLeaf`'s empty-scope short-circuit and the progress `applyCached` reports | this commit |
+| R-L2: on the progressive hub a stored posts-only or chat-only row can read as empty until an archive with posts or chat is ready | Recorded ("Found and left"); the single-site claim is marked single-site | this commit |
+| R-I2: the global scope takes the posts when the draft needs them too | Recorded ("Found and left") | this commit |
+| R-I1: "Load more results" never resumes a leaf that settled at its cap (on `main` too) | Recorded ("Found and left") and a one-line follow-up in `STATE.md` | this commit |
+
+**Gates after the re-review:** tsc (all workspaces) clean, 48 s; common **2,380/2,380**, 84 s (the
+count is unchanged: one test extended); `pnpm --filter export exec next build` exit 0, 29 s;
+`search-in`, `query-tree` and `posts-search` at `500417f3`: **38 passed**, 0 failed, 1.9 min. The
+full export suite and the hub suite were not re-run, as the parent directed.