Archilyzer · Source

archilyzer

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

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:
Mcommon/components/SearchSessionContext.tsx | 11+++++++++--
Mcommon/components/WorkspaceSearchBar.tsx | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mcommon/components/ui/checkbox.tsx | 7++++---
Mexport/CHANGELOG.md | 3+++
Aexport/e2e/channel-group-chips.spec.ts | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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"); + }); +});