commit 5eb7f7af962d2780240d23a5ed49d88cba208fcd
parent 1b686f5b21335162ad5835f6b87ec41d2ddb1ea8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 22 Sep 2026 16:35:26 -0400
merge: export/tags-followups — tag chip groups fold, leaf sections use the scope table, hub /ask prerenders, MCP tag filter skips posts
B1, B2, B4, B5 of the curated-tags follow-ups release. The hub /ask prerender needed the
whole provider stack, not just a search session; e2e:2origin is green for the first time
since the hub /ask page existed (3/3), export 188/188, hub 6/6. Reviewed by Sonnet.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
12 files changed, 435 insertions(+), 67 deletions(-)
diff --git a/common/components/FiltersPanel.tsx b/common/components/FiltersPanel.tsx
@@ -13,7 +13,7 @@
// to be the outer one. It also doubles as the inline collapse, driven by the
// session's persisted `filtersCollapsed`.
-import { Fragment, useCallback, useMemo } from "react";
+import { Fragment, useCallback, useMemo, useState } from "react";
import { ChevronRightIcon } from "lucide-react";
import {
MISSING_STATES,
@@ -36,6 +36,7 @@ import {
groupPublishedTags,
selectableTags,
} from "../lib/publishedTags";
+import type { PublishedTag } from "../lib/curatedTags";
export default function FiltersPanel({
// Inside the sheet the sheet itself IS the disclosure, so the wrapper stays
@@ -777,6 +778,62 @@ function TagChipRow() {
.sort();
}, [publishable, draftTags]);
+ // Collapse is per-group and LOCAL, unlike the channel groups' persisted
+ // `collapsedGroups`: a tag row is a handful of chips a reader folds away to
+ // get the results back on screen, not a filter shape worth carrying into
+ // every future session — and a group that came back folded would hide the
+ // vocabulary this site is built around. Every group starts open.
+ const [collapsedTagGroups, setCollapsedTagGroups] = useState<Set<string>>(
+ () => new Set(),
+ );
+ const toggleTagGroup = useCallback((key: string) => {
+ setCollapsedTagGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ }, []);
+
+ const renderChip = (tag: PublishedTag) => {
+ const selected = draftTags.has(tag.id);
+ return (
+ <button
+ key={tag.id}
+ type="button"
+ aria-pressed={selected}
+ data-testid="tag-chip"
+ data-tag-id={tag.id}
+ title={
+ selected
+ ? `Clear "${tag.label}"`
+ : `Keep only videos tagged "${tag.label}"`
+ }
+ onClick={() => toggleDraftTag(tag.id)}
+ className={cn(
+ "flex items-center gap-1.5 rounded border px-2.5 py-1.5 text-sm select-none min-w-0 transition-colors",
+ selected
+ ? "border-primary bg-primary/10 text-foreground"
+ : "border-border bg-card/60 hover:bg-accent hover:text-accent-foreground",
+ )}
+ >
+ {tag.color && (
+ <span
+ aria-hidden="true"
+ className="inline-block size-2 shrink-0 rounded-full"
+ style={{ background: tag.color }}
+ />
+ )}
+ <span className="truncate">{tag.label}</span>
+ {/* The count is this site's, computed at index time — the same number
+ the chip's filter will produce. */}
+ <span className="text-xs text-muted-foreground shrink-0">
+ {tag.count}
+ </span>
+ </button>
+ );
+ };
+
if (groups.length === 0 && unpublished.length === 0) return null;
return (
@@ -794,62 +851,85 @@ function TagChipRow() {
: `${draftTags.size} selected — a video with ANY of them`}
</span>
</div>
- {/* One wrapping flow, phone-first: a group is a label plus its chips and
- wraps as a unit, so a narrow viewport gets one group per line rather
- than a torn row. */}
+ {/* One wrapping flow, phone-first: a group is a disclosure — its label
+ over its chips — and wraps as a unit, so a narrow viewport gets one
+ group per line rather than a torn row, and a reader on a phone can
+ fold a group away to get the results back on screen. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
- {groups.map((group) => (
- <div
- key={group.id || "ungrouped"}
- data-testid="tag-chip-group"
- data-group-id={group.id}
- className="flex flex-wrap items-center gap-1.5 min-w-0"
- >
- {group.label && (
- <span className="text-xs text-muted-foreground shrink-0">
- {group.label}:
- </span>
- )}
- {group.tags.map((tag) => {
- const selected = draftTags.has(tag.id);
- return (
- <button
- key={tag.id}
- type="button"
- aria-pressed={selected}
- data-testid="tag-chip"
- data-tag-id={tag.id}
- title={
- selected
- ? `Clear "${tag.label}"`
- : `Keep only videos tagged "${tag.label}"`
- }
- onClick={() => toggleDraftTag(tag.id)}
+ {groups.map((group) => {
+ const key = group.id || "ungrouped";
+ const chips = group.tags.map(renderChip);
+ // The trailing ungrouped bucket has no label, so there is nothing to
+ // put in a summary and nothing to name what folding it away hides:
+ // it stays a plain wrapping row.
+ if (!group.label) {
+ return (
+ <div
+ key={key}
+ data-testid="tag-chip-group"
+ data-group-id={group.id}
+ className="flex flex-wrap items-center gap-1.5 min-w-0"
+ >
+ {chips}
+ </div>
+ );
+ }
+ const isOpen = !collapsedTagGroups.has(key);
+ // What the summary has to say when the chips are hidden: a folded
+ // group that is narrowing the results must still admit it, or the
+ // reader is left with a short list and no visible cause.
+ const selectedCount = group.tags.reduce(
+ (n, tag) => (draftTags.has(tag.id) ? n + 1 : n),
+ 0,
+ );
+ return (
+ <details
+ key={key}
+ open={isOpen}
+ data-testid="tag-chip-group"
+ data-group-id={group.id}
+ className="min-w-0 max-w-full"
+ >
+ <summary
+ onClick={(e) => {
+ // Drive the open state from React rather than the browser's
+ // default toggle — same rationale as the channel group
+ // chips above.
+ e.preventDefault();
+ toggleTagGroup(key);
+ }}
+ className="cursor-pointer select-none flex items-center gap-1.5 py-1 text-xs text-muted-foreground min-w-0"
+ title={
+ isOpen
+ ? `Hide the ${group.label} tags`
+ : `Show the ${group.label} tags`
+ }
+ >
+ <ChevronRightIcon
+ aria-hidden="true"
className={cn(
- "flex items-center gap-1.5 rounded border px-2.5 py-1.5 text-sm select-none min-w-0 transition-colors",
- selected
- ? "border-primary bg-primary/10 text-foreground"
- : "border-border bg-card/60 hover:bg-accent hover:text-accent-foreground",
- )}
- >
- {tag.color && (
- <span
- aria-hidden="true"
- className="inline-block size-2 shrink-0 rounded-full"
- style={{ background: tag.color }}
- />
+ "size-3.5 shrink-0 transition-transform",
+ isOpen && "rotate-90",
)}
- <span className="truncate">{tag.label}</span>
- {/* The count is this site's, computed at index time — the
- same number the chip's filter will produce. */}
- <span className="text-xs text-muted-foreground shrink-0">
- {tag.count}
+ />
+ <span className="font-medium text-foreground truncate">
+ {group.label}
+ </span>
+ {selectedCount > 0 && (
+ <span
+ data-testid="tag-group-selected"
+ className="shrink-0"
+ >
+ {selectedCount} selected
</span>
- </button>
- );
- })}
- </div>
- ))}
+ )}
+ </summary>
+ <div className="flex flex-wrap items-center gap-1.5 min-w-0 pt-1.5">
+ {chips}
+ </div>
+ </details>
+ );
+ })}
{unpublished.length > 0 && (
<div
data-testid="tag-chip-group"
diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx
@@ -43,7 +43,11 @@ type Props = {
// (lib/curatedTags.ts) — the chip row in the filter panel. The code token, the
// URL and the MCP `scopes` enum stay `"tags"`: renaming those would break every
// saved query and share link to fix a word on a screen.
-const SCOPE_LABELS: Record<LayerScope, string> = {
+// Exported because the result list labels its per-leaf sections with the same
+// words (SearchResults.tsx): a reader picks "Keywords" in the builder and has
+// to find "Keywords" over the hits it produced, so there is one table, not two
+// that drift.
+export const SCOPE_LABELS: Record<LayerScope, string> = {
transcripts: "Transcripts",
chat: "Live chat",
posts: "Posts",
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -33,6 +33,7 @@ import { formatTimestamp } from "../lib/vtt";
import { vodExpiry } from "../lib/vodExpiry";
import { Button } from "./ui/button";
import { LayerSwatch } from "./LayerSwatch";
+import { SCOPE_LABELS } from "./QueryLeafView";
import { ChartShapeControls } from "./charts/ChartShapeControls";
import { SearchChartPanel } from "./charts/SearchChartPanel";
import {
@@ -697,14 +698,12 @@ const ResultCard = memo(function ResultCard({
data-leaf-section={leafId}
>
<LayerSwatch leafId={leafId} size="xs" />
+ {/* The builder's table, not a second copy of it: this chain
+ predated the description and tags scopes and called both
+ of them "Transcripts", which is where the hits did NOT come
+ from. */}
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
- {leafInfo.scope === "metadata"
- ? "Title / channel"
- : leafInfo.scope === "chat"
- ? "Live chat"
- : leafInfo.scope === "posts"
- ? "Posts"
- : "Transcripts"}
+ {SCOPE_LABELS[leafInfo.scope]}
</span>
<span className="font-mono text-xs text-muted-foreground truncate">
{leafInfo.query}
diff --git a/export/app/ask/AskHub.tsx b/export/app/ask/AskHub.tsx
@@ -5,10 +5,14 @@
// across every shelved archive. Mirrors HubHome's MultiSiteDataProvider wiring.
import { useMemo } from "react";
+import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
+import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
+import PostModal from "yt-dlp-transcript-common/components/PostModal";
import {
MultiSiteDataProvider,
type FederatedSite,
} from "yt-dlp-transcript-common/components/SearchDataContext";
+import { SearchSessionProvider } from "yt-dlp-transcript-common/components/SearchSessionContext";
import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
import AskChat from "./AskChat";
@@ -24,9 +28,28 @@ export default function AskHub() {
[sites],
);
+ // The whole provider stack, in the order SiteWorkspace mounts it for a single
+ // site — PlayerProvider, then the data source, then the session — because
+ // hub mode bypasses that shell entirely and /ask is on its own here.
+ //
+ // None of these are optional: AskChat's retrieval reads the committed query
+ // tree, the filters and runQueryTree out of useSearchSession, and the session
+ // itself calls usePlayer (it opens a transcript at a cited timestamp). A
+ // missing provider THROWS, so the hub build's prerender of /ask failed and
+ // took the whole route with it — which is why e2e:2origin was red. The fix is
+ // the stack, never an opt-out of prerendering.
return (
- <MultiSiteDataProvider sites={federated}>
- <AskChat />
- </MultiSiteDataProvider>
+ <PlayerProvider>
+ <MultiSiteDataProvider sites={federated}>
+ <SearchSessionProvider>
+ <AskChat />
+ </SearchSessionProvider>
+ </MultiSiteDataProvider>
+ {/* The viewers a citation opens into, siblings of the session exactly as
+ HubHome and SiteWorkspace mount them — a cited link with nothing to
+ open is the failure this avoids. */}
+ <TranscriptModal />
+ <PostModal />
+ </PlayerProvider>
);
}
diff --git a/export/e2e-hub/ask.spec.ts b/export/e2e-hub/ask.spec.ts
@@ -0,0 +1,55 @@
+import { expect, test, type Page } from "@playwright/test";
+
+// The hub's /ask route.
+//
+// Hub mode bypasses the workspace shell that mounts the search session for a
+// single site, so AskHub has to supply its own — AskChat's retrieval reads the
+// committed query tree and filters out of it. When it did not, `next build`
+// with INSTANCE_MODE=hub threw while prerendering /ask ("useSearchSession must
+// be used within a SearchSessionProvider") and took the whole route with it.
+// This spec is the cheap guard on the rendered page; the build itself is the
+// other half, covered by the 2-origin suite which builds the hub for real.
+
+// No built-in pool: this route has to stand up on a hub with an empty shelf,
+// which is what a fresh hub is.
+async function stubBuiltins(page: Page) {
+ await page.route("**/hub-sites.json", (r) =>
+ r.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: "[]",
+ }),
+ );
+}
+
+test.describe("hub /ask", () => {
+ test("renders the chat with its composer", async ({ page }) => {
+ const errors: string[] = [];
+ page.on("pageerror", (e) => errors.push(String(e)));
+
+ await stubBuiltins(page);
+ await page.goto("/ask");
+
+ // The route's own header — hub mode says "the federation", not
+ // "the transcripts".
+ await expect(
+ page.getByRole("heading", { name: /Ask a question about the federation/ }),
+ ).toBeVisible();
+
+ // The composer is AskChat's whole point, and it only renders once the
+ // provider stack AskHub now mounts is there. Located by its placeholder,
+ // the way ask-chat.spec.ts does it — the first textbox on the page is the
+ // provider panel's API-key field, not this.
+ const box = page.getByPlaceholder(
+ /Ask about the transcripts|Loading transcripts/,
+ );
+ await expect(box).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Ask", exact: true }),
+ ).toBeVisible();
+
+ // A missing provider surfaces as a client-side throw, not a blank page.
+ expect(errors).toEqual([]);
+ });
+});
diff --git a/export/e2e/inline-channel-chips.spec.ts b/export/e2e/inline-channel-chips.spec.ts
@@ -116,6 +116,17 @@ async function installRoutes(page: Page) {
generatedAt: new Date().toISOString(),
});
});
+ // No curated tags: this fixture is about channel chips, and the tag groups
+ // are <details> too — a real /tags.json from the dev server's public/ would
+ // put open tag disclosures inside the same "details details" the
+ // expand-nothing assertions below count.
+ await page.route("**/tags.json", async (route) => {
+ await route.fulfill({
+ status: 404,
+ contentType: "application/json",
+ body: "{}",
+ });
+ });
}
async function waitForHydration(page: Page) {
diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts
@@ -208,6 +208,42 @@ test.describe("composite search — query tree", () => {
await expectResultSlugs(page, [CHAT_LARGE_SLUG]);
});
+ test("a leaf section is labelled with its own scope", async ({ page }) => {
+ // The section bar over a card's hits used to be a four-way ternary that
+ // predated the description and tags scopes, so hits from either were
+ // filed under "Transcripts" — the one place they demonstrably did not
+ // come from. It reads the builder's own table now, which is also why
+ // `tags` says "Keywords": the curated vocabulary is what "Tags" means.
+ const tree: SGroup = {
+ k: "g",
+ o: "OR",
+ c: [
+ { k: "l", q: "zebra", s: "description" },
+ { k: "l", q: "gaming", s: "tags" },
+ ],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+ await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG, CHAT_LARGE_SLUG]);
+
+ await expect(
+ page.locator(
+ `[data-result-slug="${TRANSCRIPT_ONLY_SLUG}"] [data-leaf-section]`,
+ ),
+ ).toContainText("Description");
+ await expect(
+ page.locator(
+ `[data-result-slug="${CHAT_LARGE_SLUG}"] [data-leaf-section]`,
+ ),
+ ).toContainText("Keywords");
+ // Neither says Transcripts, which is what the old chain said for both.
+ // textContent, not innerText: the bar is CSS-uppercased.
+ const sections = page.locator("[data-leaf-section]");
+ await expect(sections).toHaveCount(2);
+ expect((await sections.allTextContents()).join(" ")).not.toContain(
+ "Transcripts",
+ );
+ });
+
test("legacy URL: ?q=alpha auto-migrates to a single-leaf transcripts query", async ({
page,
}) => {
diff --git a/export/e2e/tag-chips.spec.ts b/export/e2e/tag-chips.spec.ts
@@ -50,6 +50,21 @@ function chip(page: Page, id: string) {
return page.locator(`[data-testid="tag-chip"][data-tag-id="${id}"]`);
}
+function tagGroup(page: Page, groupId: string) {
+ return page.locator(
+ `[data-testid="tag-chip-group"][data-group-id="${groupId}"]`,
+ );
+}
+
+// Fold/unfold by clicking the group label in its summary — the same gesture
+// the channel group chips take (channel-group-chips.spec.ts).
+async function collapseTagGroup(page: Page, groupId: string, label: string) {
+ await tagGroup(page, groupId)
+ .locator("summary")
+ .getByText(label, { exact: true })
+ .click();
+}
+
function card(page: Page, id: string) {
return page.locator(`[data-result-slug="${slugOf(id)}"]`);
}
@@ -90,6 +105,44 @@ test.describe("curated tag chips", () => {
await expect(chip(page, TAG_COLLAB)).toHaveAttribute("aria-pressed", "false");
});
+ test("a tag group collapses and its selection count survives on the summary", async ({
+ page,
+ }) => {
+ // A site can publish several groups of several chips each, and on a
+ // phone that row is the whole viewport before a single result. Each
+ // group is a <details>, open by default — folding one away must not
+ // hide that it is still narrowing the list, so the count moves to the
+ // summary.
+ const group = tagGroup(page, "eva");
+ await expect(group).toHaveAttribute("open", "");
+ await expect(chip(page, TAG_COLLAB)).toBeVisible();
+ // Nothing selected: no count on the summary, not a zero.
+ await expect(group.getByTestId("tag-group-selected")).toHaveCount(0);
+
+ await chip(page, TAG_COLLAB).click();
+ await expect(group.getByTestId("tag-group-selected")).toHaveText(
+ "1 selected",
+ );
+
+ await collapseTagGroup(page, "eva", "Eva");
+ await expect(group).not.toHaveAttribute("open");
+ await expect(chip(page, TAG_COLLAB)).toBeHidden();
+ // Still visible, still saying what it is doing to the results.
+ await expect(group.getByTestId("tag-group-selected")).toHaveText(
+ "1 selected",
+ );
+
+ // ...and the selection survives the fold, chip state included.
+ await collapseTagGroup(page, "eva", "Eva");
+ await expect(group).toHaveAttribute("open", "");
+ await expect(chip(page, TAG_COLLAB)).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await apply(page);
+ await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0);
+ });
+
test("a tagged card shows its tags; an untagged one shows none", async ({
page,
}) => {
@@ -293,6 +346,27 @@ test.describe("curated tag chips", () => {
await expect(trigger).toContainText("1");
await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0);
});
+
+ test("a group folds away inside the sheet", async ({ page }) => {
+ // 390px is where a multi-group row costs the most, so the fold has to
+ // work in the sheet and not only in the inline panel.
+ await installRoutes(page);
+ await installTagRoutes(page);
+ await page.goto("/");
+ await waitForHydration(page);
+
+ await page.getByTestId("filters-trigger").click();
+ const group = tagGroup(page, "eva");
+ await expect(chip(page, TAG_COLLAB)).toBeVisible();
+
+ await collapseTagGroup(page, "eva", "Eva");
+ await expect(group).not.toHaveAttribute("open");
+ await expect(chip(page, TAG_COLLAB)).toBeHidden();
+ // The summary is still one line inside the sheet, not a torn row.
+ const box = await group.locator("summary").boundingBox();
+ expect(box).not.toBeNull();
+ expect(box!.width).toBeLessThanOrEqual(390);
+ });
});
test.describe("on a site that publishes none", () => {
diff --git a/mcp/src/protocol.test.ts b/mcp/src/protocol.test.ts
@@ -259,6 +259,7 @@ test("stdio: a site with no /tags.json says so instead of returning nothing", as
}),
);
assert.match(search, /No matches for "coffee"/);
+ assert.match(search, /posts: skipped — a tag filter was given/);
assert.match(search, /publishes no \/tags\.json/);
assert.match(search, /not evidence of absence/);
});
@@ -309,6 +310,14 @@ test("stdio: enumerate_matches filters by tag and names the filter", async (t) =
assert.match(tagged, /filters — tags: eva-collab/);
// …and the complete-set line is still the honest one.
assert.match(tagged, /complete set: yes/);
+ // The posts corpus is not part of that count and the footer says why: a post
+ // carries no curated tags, so a tag filter drops the corpus whole. Without
+ // the sentence "no post matches" and "posts were never searched" read the
+ // same on the wire.
+ assert.match(
+ tagged,
+ /posts: skipped — a tag filter was given and posts carry no curated tags \(the export UI does the same\)/,
+ );
});
test("stdio: prompts are served on both eras", async (t) => {
diff --git a/mcp/src/search.test.ts b/mcp/src/search.test.ts
@@ -1117,6 +1117,51 @@ test("posts: a posts-scope spec leaf matches post text", async () => {
assert.deepEqual(r.hits.map((h) => h.videoId).sort(), ["p1", "p2"]);
});
+test("posts: a tag filter takes the posts corpus out of the search", async () => {
+ // Curated tags live on video records, so every post fails a tag filter.
+ // Scanning them to drop them all costs a manifest probe and a shard read per
+ // posting channel — and counting them before the drop is the bug the export
+ // viewer had (tag-chips.spec.ts). The corpus is skipped, and the result says
+ // so rather than leaving a caller to read "0 posts" as "searched, unmatched".
+ const src = new StubSource();
+ const r = await searchTranscripts(src, {
+ query: "zephyrpost",
+ filters: { ...KEEP_ALL, curatedTags: ["eva-collab"] },
+ });
+ assert.equal(r.total, 0);
+ assert.equal(r.postsScanned.skippedForTagFilter, true);
+ assert.equal(r.postsScanned.requested, false);
+ assert.equal(r.postsScanned.channels, 0, "no manifest was even probed");
+});
+
+test("posts: without a tag filter the same query still searches them", async () => {
+ // The control: posts are silenced BY the tag filter, never by this change.
+ const src = new StubSource();
+ const r = await searchTranscripts(src, {
+ query: "zephyrpost",
+ filters: { ...KEEP_ALL },
+ });
+ assert.equal(r.total, 2);
+ assert.equal(r.postsScanned.skippedForTagFilter, false);
+ assert.equal(r.postsScanned.requested, true);
+});
+
+test("posts: a posts-scope spec leaf under a tag filter matches nothing", async () => {
+ // The spec path is the other half: an explicit posts leaf carries its own
+ // slug set, so the default-scope exclusion above does not cover it.
+ const src = new StubSource();
+ const root = newGroup({
+ op: "AND",
+ children: [newLeaf({ id: "l1", query: "zephyrpost", scope: "posts" })],
+ });
+ const channels = await src.listChannels();
+ const r = await runSearchSpec(src, channels, {
+ tree: root,
+ filters: { ...KEEP_ALL, curatedTags: ["eva-collab"] },
+ });
+ assert.equal(r.total, 0);
+});
+
test("posts: findPost resolves by id and getThread returns the whole thread", async () => {
const src = new StubSource();
const found = await findPost(src, "p2");
diff --git a/mcp/src/search.ts b/mcp/src/search.ts
@@ -234,7 +234,16 @@ export type SearchResult = {
// across 0 channel(s)" — indistinguishable from "searched everything, found
// nothing". `channels` counts the channels that actually HAVE a posts index,
// so 0 with `requested` true means the post corpus is empty here.
- postsScanned: { requested: boolean; channels: number; pages: number };
+ // `skippedForTagFilter` is the one way `requested: false` is worth saying out
+ // loud: a post carries no curated tags, so a tag filter drops the whole posts
+ // corpus rather than reporting it as searched-and-unmatched. The export UI
+ // makes the same call (SearchSessionContext's globalScopeSlugs).
+ postsScanned: {
+ requested: boolean;
+ channels: number;
+ pages: number;
+ skippedForTagFilter: boolean;
+ };
// What duplicate collapsing did to the count. `available: false` means this
// corpus ships no duplicates.json, so no claim about mirrors can be made
// either way — distinct from "checked, found none".
@@ -541,8 +550,18 @@ export async function searchTranscripts(
// content_types:["video"] must not be widened by a posts scope.
const wantVideos =
contentTypes.includes("video") && (!scopes || scopes.some((s) => s !== "posts"));
- const wantPosts =
+ const postsAsked =
contentTypes.includes("post") && (!scopes || scopes.includes("posts"));
+ // A tag filter takes the posts corpus out of the search entirely: curated
+ // tags live on VIDEO records (lib/curatedTags.ts), so every post would fail
+ // the filter, and scanning them to drop them all is a manifest probe and a
+ // shard read per posting channel spent on a foregone conclusion. Counting
+ // them as hits would be worse: the export viewer hit exactly that bug (see
+ // tag-chips.spec.ts) — rows dropped by the display fold that the header had
+ // already counted.
+ const postsSkippedForTagFilter =
+ postsAsked && (opts.filters?.curatedTags?.length ?? 0) > 0;
+ const wantPosts = postsAsked && !postsSkippedForTagFilter;
// Counted apart from the video pass so "no posts index anywhere in scope" is
// distinguishable from "searched the posts and found nothing".
let postChannelsScanned = 0;
@@ -774,6 +793,7 @@ export async function searchTranscripts(
requested: wantPosts,
channels: postChannelsScanned,
pages: postPagesScanned,
+ skippedForTagFilter: postsSkippedForTagFilter,
},
duplicates,
truncated,
@@ -1131,7 +1151,11 @@ export async function runSearchSpec(
// Only run when the tree actually has a posts leaf: a video-only spec must
// not pay a manifest probe per channel. Post records reuse the same evalNode
// with `postText` set and no cues, so AND/OR/negate semantics are identical.
- const wantsPosts = [...matchers.values()].some((m) => m.scope === "posts");
+ // …and the same exclusion as the plain path: a posts leaf under a tag filter
+ // can only ever match nothing, because a post carries no curated tags.
+ const wantsPosts =
+ [...matchers.values()].some((m) => m.scope === "posts") &&
+ (filters?.curatedTags?.length ?? 0) === 0;
if (wantsPosts) {
postsOuter: for (const ch of channels) {
let pm;
diff --git a/mcp/src/server.ts b/mcp/src/server.ts
@@ -1366,6 +1366,14 @@ function incompletePageBanner(
// `scanned 0 page(s) across 0 channel(s)`, which reads like nothing ran.
function describePostsPass(result: SearchResult): string {
const p = result.postsScanned;
+ // Said out loud, because the alternative is a caller concluding from silence
+ // that the posts were searched and matched nothing.
+ if (p.skippedForTagFilter) {
+ return (
+ "posts: skipped — a tag filter was given and posts carry no curated " +
+ "tags (the export UI does the same)"
+ );
+ }
if (!p.requested) return "";
if (p.channels === 0) {
return (