commit a95751bd705523e73648fbece4fe7dd024d636f5
parent a2c6ef055e083b2a39d4833f86b117e2a5fd63d7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 22 Jul 2026 00:33:05 -0400
Channel-group filters: wrapping chips with tri-state group checkboxes
Collapsed groups now shrink to compact chips (chevron + tri-state
checkbox + accent dot + name + n/m count) that wrap several per line;
expanding breaks a group out to a full-width card with per-channel
checkboxes and All/None. The chip's checkbox bulk-toggles the whole
group without expanding (mixed → select all, Radix cycle). Groups
default to collapsed on multi-group sites unless the user has ever
touched collapse state (stored collapsedGroups undefined = untouched).
- ui/checkbox: indeterminate state renders a minus with primary fill
- WorkspaceSearchBar: chip summary drives <details> open from React
(no onToggle), checkbox isolated from summary click; All/None only
when open; collapsed chip carries group description as tooltip
- SearchSessionContext: first-visit default collapse when >1 group
- New self-contained spec: export/e2e/channel-group-chips.spec.ts
(wrap layout, no-expand bulk toggle, indeterminate, full-width
expand, collapse persistence)
Selection model, storage schema, profiles, and share links unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 352 insertions(+), 43 deletions(-)
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -909,11 +909,18 @@ function useSearchSessionState() {
if (stored) {
setProfiles(stored.profiles);
setActiveProfileName(stored.activeProfileName);
- setCollapsedGroups(new Set(stored.collapsedGroups ?? []));
if (typeof stored.filtersCollapsed === "boolean") {
setFiltersCollapsed(stored.filtersCollapsed);
}
}
+ if (stored?.collapsedGroups !== undefined) {
+ // User preference honored (a stored [] — all groups open — stays).
+ setCollapsedGroups(new Set(stored.collapsedGroups));
+ } else if (channelGroupings.length > 1) {
+ // Collapse state never touched on a multi-group site: start compact.
+ // The chips still show selection state, so nothing important is hidden.
+ setCollapsedGroups(new Set(channelGroupings.map((g) => g.group.id)));
+ }
const search = typeof window !== "undefined" ? window.location.search : "";
const params = new URLSearchParams(search);
@@ -1039,7 +1046,7 @@ function useSearchSessionState() {
setHydrated(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [channelOptions.length === 0, hydrated]);
+ }, [channelOptions.length === 0, hydrated, channelGroupings]);
// Reflect the chart view + shape in the URL live (post-hydration only), so the
// address bar always carries the current chart — mirrors how qt= is written.
diff --git a/common/components/WorkspaceSearchBar.tsx b/common/components/WorkspaceSearchBar.tsx
@@ -6,9 +6,11 @@
// (and keeps its committed search) across `/` ⇄ `/ask`. All of its state comes
// from the shared SearchSession.
+import { ChevronRightIcon } from "lucide-react";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import QueryBuilder from "./QueryBuilder";
+import { cn } from "../lib/utils";
import { clearAdvanced } from "./exportAdvancedStorage";
import { ymdToInput, inputToYmd } from "../lib/ymd";
import {
@@ -233,7 +235,7 @@ export default function WorkspaceSearchBar() {
Reset everything
</Button>
</div>
- <div className="flex flex-col gap-1.5">
+ <div className="flex flex-wrap items-start gap-1.5">
{channelGroupings.map(({ group, channels }) => {
const isOpen = !collapsedGroups.has(group.id);
const selectedCount = channels.reduce(
@@ -245,15 +247,52 @@ export default function WorkspaceSearchBar() {
<details
key={group.id}
open={isOpen}
- onToggle={(e) =>
- toggleGroupCollapsed(
- group.id,
- (e.target as HTMLDetailsElement).open,
- )
- }
- className="rounded border border-border bg-card/60"
+ className={cn(
+ "rounded border border-border bg-card/60 min-w-0",
+ isOpen && "w-full",
+ )}
>
- <summary className="cursor-pointer select-none flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1.5 text-sm">
+ <summary
+ onClick={(e) => {
+ // Drive the open state from React rather than the
+ // browser's default toggle action — same rationale
+ // as the outer Filters summary above.
+ e.preventDefault();
+ toggleGroupCollapsed(group.id, !isOpen);
+ }}
+ className="cursor-pointer select-none flex items-center gap-2 px-2.5 py-1.5 text-sm"
+ title={!isOpen ? group.description : undefined}
+ >
+ <ChevronRightIcon
+ aria-hidden="true"
+ className={cn(
+ "size-3.5 shrink-0 text-muted-foreground transition-transform",
+ isOpen && "rotate-90",
+ )}
+ />
+ <span
+ onClick={(e) => {
+ // Isolate checkbox clicks from the summary: no
+ // expand/collapse, just the group bulk-toggle.
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ className="flex items-center"
+ >
+ <Checkbox
+ checked={
+ selectedCount === 0
+ ? false
+ : selectedCount === channels.length
+ ? true
+ : "indeterminate"
+ }
+ onCheckedChange={(value) =>
+ setSelected(channels, value === true)
+ }
+ aria-label={`Select all in ${group.name || "group"}`}
+ />
+ </span>
{group.name && (
<span className="font-medium text-foreground inline-flex items-center gap-1.5">
{group.accent && (
@@ -267,36 +306,39 @@ export default function WorkspaceSearchBar() {
</span>
)}
<span className="text-xs text-muted-foreground">
- ({selectedCount}/{channels.length} selected)
- </span>
- <span className="ml-auto flex items-center gap-2">
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setSelected(channels, true);
- }}
- className="text-xs text-muted-foreground"
- >
- All
- </Button>
- <Button
- type="button"
- variant="link"
- size="sm"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setSelected(channels, false);
- }}
- className="text-xs text-muted-foreground"
- >
- None
- </Button>
+ {selectedCount}/{channels.length}
+ <span className="sr-only"> selected</span>
</span>
+ {isOpen && (
+ <span className="ml-auto flex items-center gap-2">
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setSelected(channels, true);
+ }}
+ className="text-xs text-muted-foreground"
+ >
+ All
+ </Button>
+ <Button
+ type="button"
+ variant="link"
+ size="sm"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setSelected(channels, false);
+ }}
+ className="text-xs text-muted-foreground"
+ >
+ None
+ </Button>
+ </span>
+ )}
</summary>
{group.description && (
<div className="px-3 pt-0 pb-1 text-xs text-muted-foreground">
diff --git a/common/components/ui/checkbox.tsx b/common/components/ui/checkbox.tsx
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
-import { CheckIcon } from "lucide-react"
+import { CheckIcon, MinusIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "../../lib/utils"
@@ -14,7 +14,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
- "peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
+ "peer group size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:data-[state=indeterminate]:bg-primary",
className
)}
{...props}
@@ -23,7 +23,8 @@ function Checkbox({
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
- <CheckIcon className="size-3.5" />
+ <CheckIcon className="size-3.5 group-data-[state=indeterminate]:hidden" />
+ <MinusIcon className="hidden size-3.5 group-data-[state=indeterminate]:block" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## [0.8.1] - 2026-07-22
+- **Channel-group filters are now compact chips.** On a site with many small channel groups the filter panel used to stack every group as its own full-width card, so ten groups meant ten rows before you saw a single channel. Collapsed groups now shrink to labeled chips — a chevron, a tri-state checkbox (checked / mixed / unchecked), the group's accent dot + name, and a `selected/total` count — that wrap several per line. The chip's checkbox toggles the **whole group** on or off in one click *without* expanding it (a mixed group's click selects all), and clicking the name expands the group into the familiar full-width card with per-channel checkboxes and All/None. Groups start collapsed on multi-group sites (your own expand/collapse choices still stick); a collapsed chip shows the group's description as a tooltip. Selection stays flat per-channel — profiles, share links, and saved filters are unchanged. See `common/components/{WorkspaceSearchBar,SearchSessionContext}.tsx`, `common/components/ui/checkbox.tsx`, and `export/e2e/channel-group-chips.spec.ts`.
+
## [0.8.0] - 2026-07-20
- **MCP server: switch which corpus you're reading on the fly — and it sticks across reconnects.** The MCP server was pinned to a single corpus at launch (`--hub` / `--remote` / `--local` or the `TRANSCRIPT_*` env), so re-aiming it at a different site or a local build meant editing the MCP config and restarting. Now, connected to (say) the **archilyzer hub**, you can retarget it from inside a session with three new **read-only** tools: **`list_sources`** shows the active corpus and, in a hub context, its member sites (`siteId · title · url`); **`use_source`** switches the active corpus — to a single hub member (`site:` — which becomes a plain single-site source and so regains **full channel-group + alias** support), a **federated subset** of the hub (`sites:[…]`, unknown tokens reported not dropped), or an arbitrary `remote:` URL / `local:` dir / `hub:` URL; and **`reset_source`** returns to the startup source. The selection is **persisted** to a small state file (under `$XDG_STATE_HOME/yt-dlp-transcript-mcp`, overridable with `TRANSCRIPT_MCP_STATE_DIR`) keyed by the **startup** source, so it survives a reconnect and two differently-configured servers keep independent selections. Everything the sweep/search tools do reads the current active source. Still strictly **read-only** — this only changes *which* already-published static shards are read, the same capability the startup flags already grant this locally-run tool; nothing is written to any corpus. See `mcp/src/{sources,sourceController,source,server,index}.ts`, `mcp/src/sourceController.test.ts`, and `mcp/README.md`.
- **MCP server: run the corpus sweep through Claude Code itself — on plan usage, no API key.** The in-browser "corpus sweep" (fold every matching transcript into a running report) needs a BYO AI key, and a free key conks out fast on a real 30k-video corpus. The `mcp/` server now lets **Claude Code be the sweep engine** instead, via a first-class **`sweep` prompt** (a slash command, `/mcp__<name>__sweep query="k cups" channel="chrissie-mayr"`) plus the tools to drive it. `search_transcripts` gains **paging** — it reports the full `total` and `has_more`, so the whole match set can be enumerated with `offset` (and `include_snippets:false` for a cheap worklist) — and is now **alias-aware**: a plain query that matches a curated search alias also searches the alias regex (e.g. `k cups` → also `cake cup`), with the footer naming which aliases fired; reaching the scan cap is surfaced as **PARTIAL** coverage rather than hidden. A new **`get_transcripts`** tool batch-reads up to 20 videos in one call as bounded, timestamped **excerpt windows** around the matches (alias-correct) — or full transcripts without a query — so a sweep stays token-bounded. The `sweep` prompt walks Claude through search → enumerate → plan `ceil(N/batch)` batches → per-batch windowed read + cross-referenced upsert of cited findings (*title + [mm:ss]*) into a markdown report it maintains with its own Write/Edit tools. The sweep is now **group-aware and multi-channel**: `search_transcripts` scope is additive over one-or-more channels (`channel`/`channels`) and/or channel **groups** (`group`/`groups`, matched by group **id or display name** — `group="other"` ≡ `group="Extended Universe"` — and expanded to the group's channels), with the footer naming the resolved scope and flagging any channel/group token that matched nothing so a typo isn't silently a whole-corpus scan; `get_transcripts` takes matching `channels` lookup hints; and `list_channels` now organizes channels under their groups with a compact `id · name · N channels` cheat-sheet. Crucially the `sweep` prompt is now **pick-first**: invoked with **no** scope it makes Claude list the groups/channels and **ask which to sweep — or to confirm the whole corpus — before enumerating**, rather than silently scanning everything (an explicit `search_transcripts` call with no selector still means "all"). (Hub mode defers per-site group resolution — multi-channel scoping still works there.) The MCP stays strictly **read-only**; only the report file is written, in Claude's own working directory. See `mcp/src/{search,source,server}.ts`, `mcp/src/search.test.ts`, and `mcp/README.md`.
diff --git a/export/e2e/channel-group-chips.spec.ts b/export/e2e/channel-group-chips.spec.ts
@@ -0,0 +1,256 @@
+import { expect, test, type Page, type Route } from "@playwright/test";
+
+// Self-contained fixtures exercising the channel-group filter chips
+// (common/components/WorkspaceSearchBar.tsx): several small groups collapse to
+// wrapping chips with a tri-state group checkbox; expanding breaks a group out
+// to a full-width card with per-channel checkboxes and All/None.
+
+type Chan = { name: string; slug: string; groupId: string };
+
+const CHANNELS: Chan[] = [
+ { name: "Alpha", slug: "alpha", groupId: "g1" },
+ { name: "Beta", slug: "beta", groupId: "g1" },
+ { name: "Gamma", slug: "gamma", groupId: "g2" },
+ { name: "Delta", slug: "delta", groupId: "g3" },
+];
+
+const GROUPS = [
+ {
+ id: "g1",
+ name: "Group One",
+ description: "First fixture group",
+ selectedByDefault: true,
+ order: 1,
+ },
+ {
+ id: "g2",
+ name: "Group Two",
+ description: "Second fixture group",
+ selectedByDefault: true,
+ order: 2,
+ },
+ {
+ id: "g3",
+ name: "Group Three",
+ description: "Third fixture group",
+ selectedByDefault: true,
+ order: 3,
+ },
+];
+
+function summary(c: Chan) {
+ return {
+ slug: `${c.slug}/vid-${c.slug}`,
+ id: `vid-${c.slug}`,
+ channelSlug: c.slug,
+ title: `Video from ${c.name}`,
+ uploadDate: "20250101",
+ date: "2025-01-01",
+ duration: "5:00",
+ channel: c.name,
+ isLivestream: false,
+ ageRestricted: false,
+ isDeleted: false,
+ isUnlisted: false,
+ platform: "youtube" as const,
+ webpageUrl: `https://example.com/vid-${c.slug}`,
+ };
+}
+
+async function fulfillJson(route: Route, body: unknown) {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+}
+
+async function installRoutes(page: Page) {
+ const list = CHANNELS.map(summary);
+ await page.route("**/summaries/manifest.json", async (route) => {
+ await fulfillJson(route, {
+ version: 3,
+ totalCount: list.length,
+ pageSize: 1000,
+ pageCount: 1,
+ generatedAt: new Date().toISOString(),
+ channels: CHANNELS.map((c) => ({
+ name: c.name,
+ count: 1,
+ groupId: c.groupId,
+ })),
+ groups: GROUPS,
+ defaultGroupId: "g1",
+ });
+ });
+ await page.route("**/summaries/page-*.json", async (route) => {
+ await fulfillJson(route, list);
+ });
+ // Transcripts are never fetched by these tests, but stub the routes so
+ // nothing falls through to the dev server.
+ await page.route(/\/transcripts\/[^/]+\/manifest\.json$/, async (route) => {
+ await fulfillJson(route, {
+ version: 1,
+ channelSlug: "unused",
+ pageCount: 0,
+ maxPageBytes: 8388608,
+ generatedAt: new Date().toISOString(),
+ slugToPage: {},
+ });
+ });
+ await page.route(/\/transcripts\/[^/]+\/page-\d+\.json$/, async (route) => {
+ await fulfillJson(route, []);
+ });
+ // No live chat.
+ await page.route("**/subs/manifest.json", async (route) => {
+ await fulfillJson(route, {
+ version: 4,
+ channels: [],
+ totalCount: 0,
+ liveChatTotalCount: 0,
+ generatedAt: new Date().toISOString(),
+ });
+ });
+}
+
+async function waitForHydration(page: Page) {
+ await page.getByTestId("query-builder").waitFor();
+}
+
+// Group cards are <details> nested inside the outer Filters <details>.
+function groupCard(page: Page, name: string) {
+ return page.locator("details details").filter({ hasText: name });
+}
+
+function groupCheckbox(page: Page, name: string) {
+ return page.getByRole("checkbox", { name: `Select all in ${name}` });
+}
+
+// A member-channel checkbox inside its group card. Scoped + exact so the
+// per-result "Select "Video from Alpha" for AI" checkboxes never collide.
+function memberCheckbox(page: Page, group: string, channel: string) {
+ return groupCard(page, group).getByRole("checkbox", {
+ name: channel,
+ exact: true,
+ });
+}
+
+// Expand/collapse by clicking the group's name inside the chip summary —
+// the tri-state checkbox and All/None isolate their own clicks.
+async function clickGroupName(page: Page, name: string) {
+ await groupCard(page, name)
+ .locator("summary")
+ .getByText(name, { exact: true })
+ .click();
+}
+
+test.describe("channel-group filter chips", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ await page.goto("/");
+ await waitForHydration(page);
+ });
+
+ test("groups start collapsed as chips that wrap onto one row", async ({
+ page,
+ }) => {
+ // All three chips render with their selected counts.
+ await expect(groupCard(page, "Group One")).toContainText("2/2");
+ await expect(groupCard(page, "Group Two")).toContainText("1/1");
+ await expect(groupCard(page, "Group Three")).toContainText("1/1");
+
+ // Collapsed by default on a multi-group site: member checkboxes hidden.
+ await expect(memberCheckbox(page, "Group One", "Alpha")).toBeHidden();
+ await expect(memberCheckbox(page, "Group Two", "Gamma")).toBeHidden();
+
+ // Chips shrink-to-fit and pack onto a shared row.
+ const one = await groupCard(page, "Group One")
+ .locator("summary")
+ .boundingBox();
+ const two = await groupCard(page, "Group Two")
+ .locator("summary")
+ .boundingBox();
+ expect(one).not.toBeNull();
+ expect(two).not.toBeNull();
+ expect(one!.y).toBe(two!.y);
+ });
+
+ test("chip checkbox toggles the whole group without expanding", async ({
+ page,
+ }) => {
+ const box = groupCheckbox(page, "Group One");
+ await expect(box).toHaveAttribute("aria-checked", "true");
+
+ // Fully selected → click deselects the whole group.
+ await box.click();
+ await expect(groupCard(page, "Group One")).toContainText("0/2");
+ await expect(box).toHaveAttribute("aria-checked", "false");
+ // ...and the chip did not expand.
+ await expect(groupCard(page, "Group One")).not.toHaveAttribute("open");
+ await expect(memberCheckbox(page, "Group One", "Alpha")).toBeHidden();
+
+ // Unselected → click selects the whole group.
+ await box.click();
+ await expect(groupCard(page, "Group One")).toContainText("2/2");
+ await expect(box).toHaveAttribute("aria-checked", "true");
+ await expect(groupCard(page, "Group One")).not.toHaveAttribute("open");
+ });
+
+ test("partial selection shows indeterminate; clicking selects all", async ({
+ page,
+ }) => {
+ // Expand Group One and deselect one member.
+ await clickGroupName(page, "Group One");
+ await memberCheckbox(page, "Group One", "Alpha").click();
+ await expect(groupCard(page, "Group One")).toContainText("1/2");
+
+ // Collapse — the chip checkbox reads mixed.
+ await clickGroupName(page, "Group One");
+ await expect(memberCheckbox(page, "Group One", "Alpha")).toBeHidden();
+ const box = groupCheckbox(page, "Group One");
+ await expect(box).toHaveAttribute("aria-checked", "mixed");
+
+ // Radix cycles indeterminate → checked, i.e. "select all".
+ await box.click();
+ await expect(groupCard(page, "Group One")).toContainText("2/2");
+ await expect(box).toHaveAttribute("aria-checked", "true");
+ });
+
+ test("expanding shows member checkboxes and All/None at full width", async ({
+ page,
+ }) => {
+ await clickGroupName(page, "Group One");
+
+ const card = groupCard(page, "Group One");
+ await expect(card).toHaveAttribute("open", "");
+ await expect(memberCheckbox(page, "Group One", "Alpha")).toBeVisible();
+ await expect(memberCheckbox(page, "Group One", "Beta")).toBeVisible();
+ await expect(card.getByRole("button", { name: "All" })).toBeVisible();
+ await expect(card.getByRole("button", { name: "None" })).toBeVisible();
+
+ // The expanded card breaks out to (roughly) the container's full width.
+ const containerBox = await card.locator("xpath=..").boundingBox();
+ const cardBox = await card.boundingBox();
+ expect(containerBox).not.toBeNull();
+ expect(cardBox).not.toBeNull();
+ expect(cardBox!.width).toBeGreaterThanOrEqual(containerBox!.width * 0.9);
+
+ // Other groups stay collapsed chips.
+ await expect(memberCheckbox(page, "Group Two", "Gamma")).toBeHidden();
+ });
+
+ test("expanded/collapsed state persists across a reload", async ({
+ page,
+ }) => {
+ await clickGroupName(page, "Group Two");
+ await expect(groupCard(page, "Group Two")).toHaveAttribute("open", "");
+
+ await page.reload();
+ await waitForHydration(page);
+
+ await expect(groupCard(page, "Group Two")).toHaveAttribute("open", "");
+ await expect(memberCheckbox(page, "Group Two", "Gamma")).toBeVisible();
+ await expect(groupCard(page, "Group One")).not.toHaveAttribute("open");
+ await expect(groupCard(page, "Group Three")).not.toHaveAttribute("open");
+ });
+});