commit d009376e0169c908958531a7d0017f226d1ed258
parent 04eff9b768dec55db6fbcc8c46acee6b4b2c4b96
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 13:10:41 -0400
tags S2.8: draftTags / committedTags in the search session
The chip selection is an ordinary filter and behaves like one: a draft set
the panel edits, a committed set the results are filtered by, promoted on
Search. passesFilter gains the same OR the MCP and the record evaluator
apply, and filterKey gains the sorted selection so re-clicking the same two
chips in the other order does not re-run the pipeline.
Posts are excluded while a tag filter is on, in both the scope build and the
matched-post fold. A post carries no curated tags, so letting the posts
corpus through unfiltered would have made "streams where Eva collabs" quietly
also mean "and every tweet".
FilterSnapshot carries `tg`, omitted when empty so a profile saved on an
untagged site is byte-identical to one saved before tags existed.
The URL: `tg` is written live from the committed selection by its own effect
(post-hydration only — writing before reading would erase the selection the
link arrived with), read back first and independently of the share-v1 and
legacy branches, and appended to a copied share link by hand. That is three
small places instead of teaching the share schema and the strip-on-commit
helper about a key that belongs to neither, and it is what makes the param
round-trip through a reload on its own.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Diffstat:
2 files changed, 108 insertions(+), 3 deletions(-)
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -149,6 +149,11 @@ export type ResultGroup = {
platform: Platform;
uploadDate: string;
hits: LayerHit[];
+ // The operator's curated tag ids on this record (lib/curatedTags.ts), when it
+ // carries any — so a card can show what it was tagged as. Absent on posts, on
+ // untagged videos, and on every record from a site built before corpus spec
+ // 4. Labels come from the site's /tags.json; the id is the fallback.
+ curatedTags?: string[];
// Provenance accent of the source origin (hub mode only); undefined
// single-site, so no marker renders.
accent?: string;
@@ -365,6 +370,12 @@ function useSearchSessionState() {
// Inclusive upload-date bounds ("YYYYMMDD"); "" => unbounded on that end.
const [committedDateFrom, setCommittedDateFrom] = useState("");
const [committedDateTo, setCommittedDateTo] = useState("");
+ // Curated tag ids selected in the chip row, ORed: a video carrying ANY of
+ // them passes. An EMPTY set is no filter at all — the row starts empty, and
+ // on a site that publishes no /tags.json it is empty forever.
+ const [committedTags, setCommittedTags] = useState<ReadonlySet<string>>(
+ () => new Set(),
+ );
const committedChannelsKey = useMemo(
() => Array.from(committedExcludedChannels).sort().join(" "),
[committedExcludedChannels],
@@ -396,6 +407,9 @@ function useSearchSessionState() {
);
const [draftDateFrom, setDraftDateFrom] = useState("");
const [draftDateTo, setDraftDateTo] = useState("");
+ const [draftTags, setDraftTags] = useState<ReadonlySet<string>>(
+ () => new Set(),
+ );
const [hydrated, setHydrated] = useState(false);
const [profiles, setProfiles] = useState<Record<string, FilterSnapshot>>({});
@@ -703,6 +717,14 @@ function useSearchSessionState() {
if (t.isLivestream ? committedNol : committedNov) return false;
if (t.ageRestricted ? committedNar : committedNaa) return false;
if (!committedStates.has(summaryState(t))) return false;
+ // tg — curated tags, ORed across the selection. Mirrors
+ // lib/search/evalTree.ts:passesFilters, which is the same rule applied by
+ // the MCP and the record-level evaluator; when one moves the other must.
+ if (committedTags.size > 0) {
+ const on = t.curatedTags;
+ if (!on || on.length === 0) return false;
+ if (!on.some((id) => committedTags.has(id))) return false;
+ }
return true;
};
}, [
@@ -715,6 +737,7 @@ function useSearchSessionState() {
committedNaa,
committedNar,
committedStates,
+ committedTags,
]);
const committedStatesKey = useMemo(
@@ -722,7 +745,14 @@ function useSearchSessionState() {
[committedStates],
);
- const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNop ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedStatesKey}|${committedDateFrom}|${committedDateTo}`;
+ // Sorted, so a selection built in a different click order is the same key and
+ // does not re-run the pipeline.
+ const committedTagsKey = useMemo(
+ () => Array.from(committedTags).sort().join(" "),
+ [committedTags],
+ );
+
+ const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNop ? 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
@@ -732,7 +762,12 @@ function useSearchSessionState() {
if (!transcripts) return [];
const out: string[] = [];
for (const t of transcripts) if (passesFilter(t)) out.push(t.slug);
- if (!committedNop) for (const slug of postScopeSlugs) out.push(slug);
+ // A post carries no curated tags, so a tag filter drops the whole posts
+ // corpus rather than letting it through un-filtered — the same answer
+ // passesFilter gives an untagged video.
+ if (!committedNop && 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]);
@@ -822,6 +857,9 @@ function useSearchSessionState() {
platform: t.platform,
uploadDate: t.uploadDate,
hits: [],
+ ...(t.curatedTags && t.curatedTags.length > 0
+ ? { curatedTags: t.curatedTags }
+ : {}),
accent: accentOf(splitId(t.slug).origin),
});
}
@@ -846,6 +884,9 @@ function useSearchSessionState() {
platform: t.platform,
uploadDate: t.uploadDate,
hits: hitsBySlug.get(t.slug) ?? [],
+ ...(t.curatedTags && t.curatedTags.length > 0
+ ? { curatedTags: t.curatedTags }
+ : {}),
accent: accentOf(splitId(t.slug).origin),
});
}
@@ -860,6 +901,8 @@ function useSearchSessionState() {
if (!post) continue;
if (committedDateFrom && post.uploadDate < committedDateFrom) continue;
if (committedDateTo && post.uploadDate > committedDateTo) continue;
+ // See globalScopeSlugs: a tag filter excludes posts, which carry none.
+ if (committedTags.size > 0) continue;
// A post is either still up or deleted at the source — the two states of
// the six that can apply to it — so it participates in the same
// availability filter videos use.
@@ -897,6 +940,7 @@ function useSearchSessionState() {
committedDateFrom,
committedDateTo,
committedStates,
+ committedTags,
]);
const leafStates = treeProgress?.leafStates ?? new Map<string, LeafState>();
@@ -943,7 +987,8 @@ function useSearchSessionState() {
draftNar !== committedNar ||
!sameSet(draftStates, committedStates) ||
draftDateFrom !== committedDateFrom ||
- draftDateTo !== committedDateTo;
+ draftDateTo !== committedDateTo ||
+ !sameSet(draftTags, committedTags);
const committedSnapshot = useMemo<FilterSnapshot>(() => {
const deltas = buildSnapshotDeltas(
@@ -959,6 +1004,9 @@ function useSearchSessionState() {
writeSnapshotStates(snap, committedStates);
if (committedDateFrom) snap.dateFrom = committedDateFrom;
if (committedDateTo) snap.dateTo = committedDateTo;
+ // Omitted when empty, so a profile saved on an untagged site stays
+ // byte-identical to one saved before tags existed.
+ if (committedTags.size > 0) snap.tg = Array.from(committedTags).sort();
snap.query = stringifyRoot(committedRoot);
return snap;
}, [
@@ -972,6 +1020,7 @@ function useSearchSessionState() {
committedStates,
committedDateFrom,
committedDateTo,
+ committedTags,
committedRoot,
]);
@@ -991,6 +1040,7 @@ function useSearchSessionState() {
writeSnapshotStates(snap, draftStates);
if (draftDateFrom) snap.dateFrom = draftDateFrom;
if (draftDateTo) snap.dateTo = draftDateTo;
+ if (draftTags.size > 0) snap.tg = Array.from(draftTags).sort();
snap.query = stringifyRoot(draftRoot);
return snap;
}, [
@@ -1005,6 +1055,7 @@ function useSearchSessionState() {
draftStates,
draftDateFrom,
draftDateTo,
+ draftTags,
draftRoot,
]);
@@ -1018,6 +1069,9 @@ function useSearchSessionState() {
setCommittedStates(new Set(draftStates));
setCommittedDateFrom(draftDateFrom);
setCommittedDateTo(draftDateTo);
+ setCommittedTags(new Set(draftTags));
+ // `tg` survives this on purpose: it is not a legacy or share-v1 key, and
+ // the effect below rewrites it from the committed selection.
stripAllFilterParamsFromUrl();
}, [
draftExcludedChannels,
@@ -1029,6 +1083,7 @@ function useSearchSessionState() {
draftStates,
draftDateFrom,
draftDateTo,
+ draftTags,
]);
// ─── Hydration ────────────────────────────────────────────────────────────
@@ -1085,6 +1140,13 @@ function useSearchSessionState() {
let initialStates: ReadonlySet<VideoState> = new Set(VIDEO_STATES);
let initialDateFrom = "";
let initialDateTo = "";
+ // `tg` is read from the URL FIRST and independently of the share-v1 /
+ // legacy branch below, because it is not part of either schema: it is the
+ // live representation of the committed tag selection, so its presence on
+ // the URL always wins and its absence falls through to the snapshot.
+ const tagsFromUrl = params.getAll("tg").filter((t) => t !== "");
+ let initialTags: ReadonlySet<string> | null =
+ tagsFromUrl.length > 0 ? new Set(tagsFromUrl) : null;
if (hasShareV1(search)) {
const sel = parseShareV1(search, channelOptions);
@@ -1138,6 +1200,7 @@ function useSearchSessionState() {
initialStates = snapshotStates(snapshot);
initialDateFrom = snapshot?.dateFrom ?? "";
initialDateTo = snapshot?.dateTo ?? "";
+ initialTags ??= new Set(snapshot?.tg ?? []);
if (!resolvedRoot && snapshot?.query) {
resolvedRoot = parseRoot(snapshot.query);
}
@@ -1158,6 +1221,7 @@ function useSearchSessionState() {
setDraftStates(new Set(initialStates));
setDraftDateFrom(initialDateFrom);
setDraftDateTo(initialDateTo);
+ setDraftTags(new Set(initialTags ?? []));
setCommittedExcludedChannels(initialExcluded);
setCommittedNov(initialNov);
setCommittedNol(initialNol);
@@ -1166,6 +1230,7 @@ function useSearchSessionState() {
setCommittedStates(new Set(initialStates));
setCommittedDateFrom(initialDateFrom);
setCommittedDateTo(initialDateTo);
+ setCommittedTags(new Set(initialTags ?? []));
// Chart view + shape (after query/filters so the default data source can
// key off whether a query was restored).
@@ -1195,6 +1260,17 @@ function useSearchSessionState() {
writeViewChartParams(view, view === "chart" ? chartShape : null);
}, [hydrated, view, chartShape]);
+ // `tg` likewise: the URL carries the COMMITTED tag selection at all times, so
+ // a chip survives a reload, a share of the address bar, and a profile load —
+ // without teaching the share-v1 schema or the strip-on-commit helper about a
+ // key that is not theirs. Sorted, so the same selection is always the same
+ // URL. Deliberately post-hydration only: writing before the URL has been
+ // read would erase the selection the link arrived with.
+ useEffect(() => {
+ if (!hydrated) return;
+ writeUrlParams({ tags: committedTagsKey ? committedTagsKey.split(" ") : [] });
+ }, [hydrated, committedTagsKey]);
+
// Persist UI-only collapse state immediately on toggle. Loads + merges +
// saves so concurrent writes to other top-level fields aren't clobbered.
const persistUiCollapse = useCallback(
@@ -1296,6 +1372,7 @@ function useSearchSessionState() {
setDraftStates(snapshotStates(snapshot));
setDraftDateFrom(snapshot?.dateFrom ?? "");
setDraftDateTo(snapshot?.dateTo ?? "");
+ setDraftTags(new Set(snapshot?.tg ?? []));
if (snapshot?.query) {
const parsed = parseRoot(snapshot.query);
if (parsed) setDraftRoot(parsed);
@@ -1320,6 +1397,7 @@ function useSearchSessionState() {
setCommittedStates(snapshotStates(snapshot));
setCommittedDateFrom(snapshot?.dateFrom ?? "");
setCommittedDateTo(snapshot?.dateTo ?? "");
+ setCommittedTags(new Set(snapshot?.tg ?? []));
if (snapshot?.query) {
const parsed = parseRoot(snapshot.query);
if (parsed) setCommittedRoot(parsed);
@@ -1461,6 +1539,18 @@ function useSearchSessionState() {
setDraftExcludedChannels(excluded);
}, [channelOptions, defaultSelectedChannels]);
+ // One click on a chip: select it, or (when it is already selected) clear it.
+ // The chip row is the whole control — there is no separate "clear tags"
+ // affordance, because a selected chip IS the clear affordance.
+ const toggleDraftTag = useCallback((id: string) => {
+ setDraftTags((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }, []);
+
const handleResetAllFilters = useCallback(() => {
setActiveProfileName(null);
writeStorage((s) => {
@@ -1514,6 +1604,11 @@ function useSearchSessionState() {
if (isNodeActive(committedRoot)) {
params.set("qt", stringifyRoot(committedRoot));
}
+ // `tg` rides along under its own name rather than joining the share-v1
+ // schema: buildShareSearchParams starts from an empty URLSearchParams, so
+ // without this a shared link would silently drop the tag selection it was
+ // copied with.
+ for (const id of Array.from(committedTags).sort()) params.append("tg", id);
// Carry the chart view + its shape so a shared link opens straight to the
// same chart (query/filters stay in qt= and the share params above).
if (view === "chart") {
@@ -1857,6 +1952,10 @@ function useSearchSessionState() {
setDraftDateFrom,
draftDateTo,
setDraftDateTo,
+ // ── Curated tags (draft selection + what is actually being filtered on) ──
+ draftTags,
+ toggleDraftTag,
+ committedTags,
// ── Advanced options ──
hitBatchValue,
setHitBatchValue,
diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts
@@ -45,6 +45,10 @@ export type FilterSnapshot = {
dateTo?: string;
mode?: SearchMode;
tracks?: string[];
+ // Curated tag ids selected (lib/curatedTags.ts), ORed. Absent => no tag
+ // filter, which is what every profile saved before tags existed means. NOT
+ // `tracks` above and not the yt-dlp keyword scope — see urlState's `tg`.
+ tg?: string[];
query?: string;
};
@@ -151,6 +155,7 @@ function parseSnapshot(raw: unknown): FilterSnapshot | null {
}
if (r.mode === "transcripts" || r.mode === "subs") snap.mode = r.mode;
if (isStringArray(r.tracks)) snap.tracks = r.tracks.slice();
+ if (isStringArray(r.tg)) snap.tg = r.tg.slice();
if (typeof r.query === "string") snap.query = r.query;
return snap;
}
@@ -266,6 +271,7 @@ export function snapshotsEqual(a: FilterSnapshot, b: FilterSnapshot): boolean {
if ((a.dateTo ?? "") !== (b.dateTo ?? "")) return false;
if ((a.mode ?? "transcripts") !== (b.mode ?? "transcripts")) return false;
if (sortedJson(a.tracks ?? []) !== sortedJson(b.tracks ?? [])) return false;
+ if (sortedJson(a.tg ?? []) !== sortedJson(b.tg ?? [])) return false;
if ((a.query ?? "") !== (b.query ?? "")) return false;
return true;
}