commit 95fe3214b23dc32bfca3570ab9af414fc00238eb
parent a8ea49e65219f865bdec00d98011b612e4a31a00
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 20 Jul 2026 02:43:22 -0400
Ask chat: integrated grounding workspace — pick the AI target, sweep any size, save & resume
Search and /ask shared a shell but still behaved like two tabs: grounding was
all-or-nothing (always the whole result set), the corpus sweep had hard-coded
caps and couldn't survive a rate limit, and a conversation lived in one unnamed
slot that "New chat" wiped. This knits them into one surface.
Grounding targets (A): a video-selection INCLUSION set in SearchSessionContext
(selectedSlugs + toggle/selectOnly/selectAll/clear, groundingMode with an
empty-selection guard, getGrounding/activeGrounding/selectionGrounding all built
through one buildGroundingFor). Persisted to ytdlp-tb:selection and pruned
against settled results. Result cards get a select checkbox and an "Ask" that
grounds in that one video. The new GroundingPalette is the signature control: a
first-class Whole search / Selection segmented toggle, dismissible chips, and an
Ask / Sweep / preset-directive action cluster.
Layout (D): WorkspaceView mounts BOTH panes once — lazily, then latched, hidden
via CSS — so [Search | Chat | Split] never re-runs the search or drops chat
state. Single-pane follows the route; Split is the persisted cross-route state
and drops below lg, degrading to Search/Chat tabs. The route pages keep only
their intro chrome.
Saved chats (C): askChatStorage mirrors the search profiles store (versioned
key, strict parse-or-reset, deep-equal dirty check). SavedChatsRow gives
Save / Save as… / Rename / Delete with a dirty dot; a saved chat carries the
conversation AND any paused-sweep checkpoint.
Pausable sweeps (B): a 429 now raises PausableError, and the shared driveSweep
core checkpoints the report plus the remaining batches instead of aborting —
same for a manual Pause (Stop still aborts and discards). Resume continues from
the next batch after a reload or a model switch, since the checkpoint carries no
key/model binding. Batch size is a real setting, and the "matched more than we
can sweep" note now reads the true engine cap rather than the 100-video display
cap.
PWA notifications (E, stretch): notify on sweep finish/pause, gated on a
registered service worker plus permission plus a hidden tab; a no-op elsewhere.
Verified: 130 unit tests (new selection + askChatStorage suites, incl. a
checkpoint round-trip) and the full export e2e suite at 120/120, with a new
ask-workspace spec covering subset grounding, per-card Ask, the view modes and
their narrow-width degradation, save/reload/resume, and a mocked 429 mid-sweep
that pauses then resumes under a switched model. Existing specs updated for the
Results→Search rename and for the now-co-resident hidden pane (citation asserts
scoped to pane-chat). tsc and eslint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
25 files changed, 2274 insertions(+), 227 deletions(-)
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -5,9 +5,12 @@
// shared SearchSession, so it stays in lockstep with the persistent search bar
// even though the two now live in different slots of the workspace shell.
-import React, { memo, useEffect, useMemo, useRef, useState } from "react";
+import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { MessageSquareIcon } from "lucide-react";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { VirtualRow } from "./VirtualRow";
+import { Checkbox } from "./ui/checkbox";
import type { LayerHit } from "./searchPipeline";
import {
AgeRestrictedBadge,
@@ -60,8 +63,25 @@ export default function SearchResults() {
hitBatchSize,
resultsCopied,
copyResultsContext,
+ selectedSlugs,
+ selectedCount,
+ toggleSelected,
+ selectOnly,
+ selectAllResults,
+ clearSelection,
} = useSearchSession();
+ const router = useRouter();
+ // Per-card "Ask": ground in exactly this video and cross to the chat. The
+ // shared session survives the route change, so the chat picks it up live.
+ const onAsk = useCallback(
+ (slug: string) => {
+ selectOnly(slug);
+ router.push("/ask/");
+ },
+ [selectOnly, router],
+ );
+
return (
<section data-testid="results-section">
<h2 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-2 flex-wrap">
@@ -131,6 +151,35 @@ export default function SearchResults() {
)}
</h2>
+ {view !== "chart" && resultGroups.length > 0 && (
+ <div
+ data-testid="selection-toolbar"
+ className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"
+ >
+ <span>
+ {selectedCount > 0
+ ? `${selectedCount} selected for AI`
+ : "Select videos to ask AI about a subset"}
+ </span>
+ <button
+ type="button"
+ onClick={selectAllResults}
+ className="text-brand transition-colors hover:underline"
+ >
+ Select all {resultGroups.length}
+ </button>
+ {selectedCount > 0 && (
+ <button
+ type="button"
+ onClick={clearSelection}
+ className="transition-colors hover:text-foreground"
+ >
+ Clear
+ </button>
+ )}
+ </div>
+ )}
+
{view === "chart" ? (
<div className="flex flex-col gap-4">
<details
@@ -180,6 +229,9 @@ export default function SearchResults() {
activeVideo={activeVideo}
activeTime={activeTime}
openWithMode={openWithMode}
+ selectedSlugs={selectedSlugs}
+ onToggleSelect={toggleSelected}
+ onAsk={onAsk}
/>
)}
{capped && (
@@ -230,6 +282,9 @@ function VirtualResultList({
activeVideo,
activeTime,
openWithMode,
+ selectedSlugs,
+ onToggleSelect,
+ onAsk,
}: {
resultGroups: ReadonlyArray<ResultGroup>;
leavesById: ReadonlyMap<string, LeafInfo>;
@@ -237,6 +292,9 @@ function VirtualResultList({
activeVideo: string | null;
activeTime: number | null;
openWithMode: (slug: string, hit?: LayerHit) => void;
+ selectedSlugs: ReadonlySet<string>;
+ onToggleSelect: (slug: string) => void;
+ onAsk: (slug: string) => void;
}) {
const flatRows = useMemo(() => buildResultRows(resultGroups), [resultGroups]);
@@ -300,6 +358,9 @@ function VirtualResultList({
activeVideo={activeVideo}
activeTime={activeTime}
openWithMode={openWithMode}
+ selected={selectedSlugs.has(row.group.slug)}
+ onToggleSelect={onToggleSelect}
+ onAsk={onAsk}
/>
)}
</VirtualRow>
@@ -316,6 +377,9 @@ const ResultCard = memo(function ResultCard({
activeVideo,
activeTime,
openWithMode,
+ selected,
+ onToggleSelect,
+ onAsk,
}: {
group: ResultGroup;
leavesById: ReadonlyMap<string, LeafInfo>;
@@ -323,6 +387,9 @@ const ResultCard = memo(function ResultCard({
activeVideo: string | null;
activeTime: number | null;
openWithMode: (slug: string, hit?: LayerHit) => void;
+ selected: boolean;
+ onToggleSelect: (slug: string) => void;
+ onAsk: (slug: string) => void;
}) {
// Bucket hits per contributing leaf so the user sees one section per
// layer rather than an interleaved mishmash.
@@ -348,30 +415,54 @@ const ResultCard = memo(function ResultCard({
: undefined
}
>
- <Button
- type="button"
- variant="ghost"
- onClick={() => openWithMode(group.slug)}
- className="w-full h-auto justify-start text-left items-baseline gap-2 px-4 py-2 rounded-none border-b border-border bg-muted font-normal"
- >
- <span className="font-medium truncate flex-1 min-w-0">
- {group.title}
- </span>
- {group.isLivestream && <LivestreamBadge />}
- {group.ageRestricted && <AgeRestrictedBadge />}
- {(() => {
- const e = vodExpiry(group.platform, group.uploadDate);
- return e?.likelyExpired ? (
- <VodExpiredBadge tooltip={e.tooltip} />
- ) : null;
- })()}
- <span className="text-xs text-muted-foreground shrink-0">
- {group.channel && `${group.channel} · `}
- {group.date}
- {group.hits.length > 0 &&
- ` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`}
- </span>
- </Button>
+ <div className="flex items-stretch border-b border-border bg-muted">
+ {/* Per-card selection: an inclusion checkbox that builds the AI grounding
+ subset. Kept quiet — the boldness is the chat's grounding palette. */}
+ <label
+ className="flex shrink-0 cursor-pointer items-center pl-3 pr-1"
+ title={selected ? "Remove from AI selection" : "Add to AI selection"}
+ onClick={(e) => e.stopPropagation()}
+ >
+ <Checkbox
+ checked={selected}
+ aria-label={`Select "${group.title}" for AI`}
+ onCheckedChange={() => onToggleSelect(group.slug)}
+ />
+ </label>
+ <Button
+ type="button"
+ variant="ghost"
+ data-card-open=""
+ onClick={() => openWithMode(group.slug)}
+ className="min-w-0 flex-1 h-auto justify-start text-left items-baseline gap-2 px-2 py-2 rounded-none bg-transparent font-normal"
+ >
+ <span className="font-medium truncate flex-1 min-w-0">
+ {group.title}
+ </span>
+ {group.isLivestream && <LivestreamBadge />}
+ {group.ageRestricted && <AgeRestrictedBadge />}
+ {(() => {
+ const e = vodExpiry(group.platform, group.uploadDate);
+ return e?.likelyExpired ? (
+ <VodExpiredBadge tooltip={e.tooltip} />
+ ) : null;
+ })()}
+ <span className="text-xs text-muted-foreground shrink-0">
+ {group.channel && `${group.channel} · `}
+ {group.date}
+ {group.hits.length > 0 &&
+ ` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`}
+ </span>
+ </Button>
+ <button
+ type="button"
+ onClick={() => onAsk(group.slug)}
+ title="Ask the AI about this video"
+ className="flex shrink-0 items-center gap-1 border-l border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
+ >
+ <MessageSquareIcon className="size-3.5" /> Ask
+ </button>
+ </div>
<ul className="flex flex-col divide-y divide-border">
{Array.from(buckets.entries()).map(([leafId, hits]) => {
if (hits.length === 0) return null;
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -72,6 +72,7 @@ import type { DisplaySummary, Platform } from "../lib/transcripts";
import { makeId, splitId } from "./originId";
import { sortGroups, type ChannelGroup } from "../lib/channelGroups";
import { buildSearchHandoff, type SearchHandoff } from "../lib/aiHandoff";
+import { pruneSelection, selectGroups } from "../lib/selection";
type Summary = DisplaySummary;
@@ -159,6 +160,41 @@ export const DEFAULT_FLUSH_INTERVAL_MS = 120;
// single-snippet tail).
export const SWEEP_HITS_PER_VIDEO = 4;
+// What the AI is grounded in: the whole committed search, or a hand-picked
+// selection of result videos (the grounding palette's toggle). An empty
+// selection always parks on "search" — see effectiveGroundingMode below.
+export type GroundingMode = "search" | "selection";
+
+// The video-selection set is persisted lightly (just a slug array) under its own
+// key so a hand-picked grounding survives reload without touching the strict
+// export-filter parser.
+const SELECTION_KEY = "ytdlp-tb:selection";
+
+function loadSelection(): string[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = window.localStorage.getItem(SELECTION_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) {
+ return parsed;
+ }
+ } catch {
+ /* malformed → empty */
+ }
+ return [];
+}
+
+function saveSelection(slugs: ReadonlySet<string>): void {
+ if (typeof window === "undefined") return;
+ try {
+ if (slugs.size === 0) window.localStorage.removeItem(SELECTION_KEY);
+ else window.localStorage.setItem(SELECTION_KEY, JSON.stringify([...slugs]));
+ } catch {
+ /* quota — non-fatal */
+ }
+}
+
// Canonical hash of a freshly-constructed empty root — used to disable
// "Reset layers" when the draft is already at the empty default. Module-level
// so we don't reallocate a node every render; `canonicalHash` strips IDs so
@@ -343,6 +379,16 @@ function useSearchSessionState() {
);
const [filtersCollapsed, setFiltersCollapsed] = useState<boolean>(false);
+ // ── Grounding target (shared with /ask) ──
+ // The hand-picked video-selection set (an INCLUSION set at video granularity,
+ // mirroring the channel EXCLUSION set) + which target grounds the chat. Both
+ // survive `/` ⇄ `/ask` (one session) and reload (localStorage). Restored in the
+ // hydration effect below.
+ const [selectedSlugs, setSelectedSlugs] = useState<Set<string>>(
+ () => new Set(),
+ );
+ const [groundingMode, setGroundingMode] = useState<GroundingMode>("search");
+
// Results vs. chart view of the current search. `chartShape` is created
// lazily (never during the server pass) to keep deterministic-id hydration
// quiet — the same reason the QueryBuilder is gated behind `mounted`.
@@ -986,6 +1032,11 @@ function useSearchSessionState() {
}
if (wantsChart) setView("chart");
+ // Restore the hand-picked grounding selection (pruned later against the
+ // settled result set by the self-clean effect).
+ const savedSelection = loadSelection();
+ if (savedSelection.length > 0) setSelectedSlugs(new Set(savedSelection));
+
setHydrated(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelOptions.length === 0, hydrated]);
@@ -1387,6 +1438,60 @@ function useSearchSessionState() {
[openTranscript],
);
+ // ── Video-selection API (the grounding INCLUSION set) ──
+ // Mirrors the channel-selection idiom (a set toggled by the cards), but at
+ // video granularity and shared with the chat as a grounding target.
+ const toggleSelected = useCallback((slug: string) => {
+ setSelectedSlugs((prev) => {
+ const next = new Set(prev);
+ if (next.has(slug)) next.delete(slug);
+ else next.add(slug);
+ return next;
+ });
+ }, []);
+
+ const selectAllResults = useCallback(() => {
+ setSelectedSlugs(new Set(resultGroups.map((g) => g.slug)));
+ }, [resultGroups]);
+
+ // Select exactly one video and ground in it (a per-card "Ask" → selection of
+ // one). Flips the grounding mode so the chat picks it up immediately.
+ const selectOnly = useCallback((slug: string) => {
+ setSelectedSlugs(new Set([slug]));
+ setGroundingMode("selection");
+ }, []);
+
+ const clearSelection = useCallback(() => {
+ setSelectedSlugs((prev) => (prev.size === 0 ? prev : new Set()));
+ }, []);
+
+ const selectedCount = selectedSlugs.size;
+
+ // Persist the selection (post-hydration only, so the one-time restore below
+ // isn't immediately overwritten by the initial empty set).
+ useEffect(() => {
+ if (!hydrated) return;
+ saveSelection(selectedSlugs);
+ }, [hydrated, selectedSlugs]);
+
+ // Self-clean: once the current search settles, drop any selected slugs that are
+ // no longer among the results (a selection carried across a narrower search).
+ const resultSlugSet = useMemo(
+ () => new Set(resultGroups.map((g) => g.slug)),
+ [resultGroups],
+ );
+ useEffect(() => {
+ if (!hydrated || !pipelineDone) return;
+ setSelectedSlugs((prev) => {
+ if (prev.size === 0) return prev;
+ const next = pruneSelection(prev, resultSlugSet);
+ return next.size === prev.size ? prev : next;
+ });
+ // resultSlugSet identity changes as results stream; the pipelineDone guard
+ // means we only prune against a SETTLED set, never a partial one.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [hydrated, pipelineDone, resultSlugSet]);
+
// Map of leafId → leaf (and its query) so result-row rendering can look
// up the original search terms for `<mark>` highlighting.
const leavesById = useMemo(() => {
@@ -1408,57 +1513,97 @@ function useSearchSessionState() {
return m;
}, [committedRoot]);
+ // Shared builder: turn a set of result groups into a SearchHandoff, joining in
+ // each slug's summary metadata (id + canonical URL) and the active leaf queries
+ // as the label. All three groundings below go through this one path so they
+ // stay byte-identical modulo the tiering opts. `searchCapped` is threaded from
+ // the TRUE engine cap so the "matched more than we can sweep" copy is accurate.
+ const buildGroundingFor = useCallback(
+ (
+ groups: ResultGroup[],
+ opts: Parameters<typeof buildSearchHandoff>[3],
+ ): SearchHandoff | null => {
+ if (groups.length === 0) return null;
+ const queries = Array.from(leavesById.values())
+ .map((l) => l.query.trim())
+ .filter(Boolean);
+ const bySlug = new Map(summaries.map((s) => [s.slug, s]));
+ const handoff = buildSearchHandoff(
+ groups,
+ (slug) => {
+ const s = bySlug.get(slug);
+ return s ? { id: s.id, webpageUrl: s.webpageUrl } : undefined;
+ },
+ queries,
+ { ...opts, searchCapped: capped },
+ );
+ return handoff.videos.length ? handoff : null;
+ },
+ [leavesById, summaries, capped],
+ );
+
// The current search, serialized as chat grounding — the shared "active search"
// the /ask chat auto-grounds in (it reads this live from the session, so no
// sessionStorage hand-off is needed now that both views share this provider).
// Null when there's no active query or it matched nothing. Tiered inside
// buildSearchHandoff (whole set, full excerpts for the top few).
const liveGrounding = useMemo<SearchHandoff | null>(() => {
- if (!hasActiveQuery || resultGroups.length === 0) return null;
- const queries = Array.from(leavesById.values())
- .map((l) => l.query.trim())
- .filter(Boolean);
- const bySlug = new Map(summaries.map((s) => [s.slug, s]));
- const handoff = buildSearchHandoff(
- resultGroups,
- (slug) => {
- const s = bySlug.get(slug);
- return s ? { id: s.id, webpageUrl: s.webpageUrl } : undefined;
- },
- queries,
- { searchCapped: capped },
- );
- return handoff.videos.length ? handoff : null;
- }, [hasActiveQuery, resultGroups, leavesById, summaries, capped]);
+ if (!hasActiveQuery) return null;
+ return buildGroundingFor(resultGroups, {});
+ }, [hasActiveQuery, resultGroups, buildGroundingFor]);
+
+ // The selected result videos, in result order.
+ const selectedGroups = useMemo<ResultGroup[]>(
+ () => selectGroups(resultGroups, selectedSlugs),
+ [resultGroups, selectedSlugs],
+ );
+
+ // The hand-picked selection as grounding: every selected video with full
+ // excerpts (sweep caps) — a curated subset is small, so it carries real
+ // evidence per video rather than liveGrounding's tiered single-snippet tail.
+ const selectionGrounding = useMemo<SearchHandoff | null>(() => {
+ if (selectedGroups.length === 0) return null;
+ return buildGroundingFor(selectedGroups, {
+ maxVideos: selectedGroups.length,
+ fullExcerptVideos: selectedGroups.length,
+ maxHitsPerVideo: SWEEP_HITS_PER_VIDEO,
+ });
+ }, [selectedGroups, buildGroundingFor]);
// The FULL matched set as chat grounding — every discovered video with a few
// hit excerpts each, caps lifted so nothing is dropped or thinned (unlike
// liveGrounding's tiered 100-video/top-12 payload). Lazy (a function, not a
// memo) because the full set can be large and is only needed when the user
// launches a whole-corpus "sweep", not on every keystroke. Bounded only by the
- // engine's hit cap (~400–500 videos); `truncated` flags when that cap bit.
+ // engine's hit cap; `capped` flags when that cap bit.
const getFullGrounding = useCallback((): SearchHandoff | null => {
- if (!hasActiveQuery || resultGroups.length === 0) return null;
- const queries = Array.from(leavesById.values())
- .map((l) => l.query.trim())
- .filter(Boolean);
- const bySlug = new Map(summaries.map((s) => [s.slug, s]));
- const handoff = buildSearchHandoff(
- resultGroups,
- (slug) => {
- const s = bySlug.get(slug);
- return s ? { id: s.id, webpageUrl: s.webpageUrl } : undefined;
- },
- queries,
- {
- maxVideos: resultGroups.length,
- fullExcerptVideos: resultGroups.length,
- maxHitsPerVideo: SWEEP_HITS_PER_VIDEO,
- searchCapped: capped,
- },
- );
- return handoff.videos.length ? handoff : null;
- }, [hasActiveQuery, resultGroups, leavesById, summaries, capped]);
+ if (!hasActiveQuery) return null;
+ return buildGroundingFor(resultGroups, {
+ maxVideos: resultGroups.length,
+ fullExcerptVideos: resultGroups.length,
+ maxHitsPerVideo: SWEEP_HITS_PER_VIDEO,
+ });
+ }, [hasActiveQuery, resultGroups, buildGroundingFor]);
+
+ // An empty selection always parks the mode on whole-search, so nothing changes
+ // for users who never select a video.
+ const effectiveGroundingMode: GroundingMode =
+ groundingMode === "selection" && selectedSlugs.size > 0
+ ? "selection"
+ : "search";
+
+ // The grounding for the ACTIVE mode, bounded for per-turn use (the pinned
+ // panel / normal chat turns): selection → the curated set; else the live search.
+ const activeGrounding =
+ effectiveGroundingMode === "selection" ? selectionGrounding : liveGrounding;
+
+ // The grounding for an explicit target — used by the whole-corpus sweep, which
+ // wants FULL excerpts over the whole target (not the tiered per-turn payload).
+ const getGrounding = useCallback(
+ (target: GroundingMode): SearchHandoff | null =>
+ target === "selection" ? selectionGrounding : getFullGrounding(),
+ [selectionGrounding, getFullGrounding],
+ );
const [resultsCopied, setResultsCopied] = useState(false);
const resultsCopiedResetRef = useRef<number | null>(null);
@@ -1602,9 +1747,25 @@ function useSearchSessionState() {
activeTime,
openWithMode,
summaries,
+ // ── Grounding target (selection) ──
+ selectedSlugs,
+ selectedCount,
+ toggleSelected,
+ selectAllResults,
+ selectOnly,
+ clearSelection,
+ groundingMode,
+ setGroundingMode,
+ // The mode after the empty-selection guard (parks on "search" when nothing
+ // is selected).
+ effectiveGroundingMode,
// ── Results → AI ──
// The current search as chat grounding (read live by /ask; no hand-off).
liveGrounding,
+ // The grounding for the ACTIVE mode, bounded for per-turn chat use.
+ activeGrounding,
+ // The grounding for an explicit target (full excerpts) — for the sweep.
+ getGrounding,
// The FULL matched set (lazy) — for the /ask whole-corpus sweep.
getFullGrounding,
copyResultsContext,
diff --git a/common/lib/selection.test.ts b/common/lib/selection.test.ts
@@ -0,0 +1,48 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { pruneSelection, selectGroups } from "./selection";
+import { buildSearchHandoff, type HandoffGroup } from "./aiHandoff";
+
+test("pruneSelection drops slugs no longer present", () => {
+ const next = pruneSelection(new Set(["a", "b", "c"]), new Set(["b", "c", "d"]));
+ assert.deepEqual([...next].sort(), ["b", "c"]);
+});
+
+test("pruneSelection keeps everything when all still present", () => {
+ const next = pruneSelection(new Set(["a", "b"]), new Set(["a", "b", "x"]));
+ assert.deepEqual([...next].sort(), ["a", "b"]);
+});
+
+test("pruneSelection empties when nothing matches", () => {
+ assert.equal(pruneSelection(new Set(["a"]), new Set(["z"])).size, 0);
+});
+
+test("selectGroups filters to the selection, preserving result order", () => {
+ const groups = [{ slug: "a" }, { slug: "b" }, { slug: "c" }];
+ assert.deepEqual(
+ selectGroups(groups, new Set(["c", "a"])).map((g) => g.slug),
+ ["a", "c"],
+ );
+ assert.deepEqual(selectGroups(groups, new Set<string>()), []);
+});
+
+test("getGrounding('selection') = selectGroups routed through buildSearchHandoff", () => {
+ const groups: HandoffGroup[] = [
+ { slug: "a", title: "A", channel: "Ch", uploadDate: "20240101", hits: [{ start: 5, text: "alpha line" }] as never },
+ { slug: "b", title: "B", channel: "Ch", uploadDate: "20240102", hits: [{ start: 9, text: "beta line" }] as never },
+ { slug: "c", title: "C", channel: "Ch", uploadDate: "20240103", hits: [{ start: 1, text: "gamma line" }] as never },
+ ];
+ const selected = new Set(["a", "c"]);
+ const chosen = selectGroups(groups, selected);
+ // Mirrors the session's selectionGrounding: the selected groups, full excerpts.
+ const handoff = buildSearchHandoff(chosen, () => undefined, ["q"], {
+ maxVideos: chosen.length,
+ fullExcerptVideos: chosen.length,
+ maxHitsPerVideo: 4,
+ });
+ // Only the selected videos, in order, each carrying its excerpt.
+ assert.deepEqual(handoff.videos.map((v) => v.key), ["a", "c"]);
+ assert.equal(handoff.videos[0].snippets[0].text, "alpha line");
+ assert.equal(handoff.videos[1].snippets[0].text, "gamma line");
+ assert.equal(handoff.totalVideos, 2);
+});
diff --git a/common/lib/selection.ts b/common/lib/selection.ts
@@ -0,0 +1,25 @@
+// Pure helpers for the video-SELECTION grounding target (the AI-grounding
+// inclusion set), split out of SearchSessionContext so they're unit-testable
+// without pulling in the whole client component.
+
+// Drop selected slugs that are no longer among the current (settled) result set,
+// so a selection carried across a new/narrower search self-cleans.
+export function pruneSelection<T>(
+ selected: ReadonlySet<T>,
+ present: ReadonlySet<T>,
+): Set<T> {
+ const next = new Set<T>();
+ for (const slug of selected) if (present.has(slug)) next.add(slug);
+ return next;
+}
+
+// Filter result groups to the selection, preserving result order. Generic over
+// any object carrying a `slug` so the search-session's ResultGroup fits without
+// a dependency here.
+export function selectGroups<G extends { slug: string }>(
+ groups: ReadonlyArray<G>,
+ selected: ReadonlySet<string>,
+): G[] {
+ if (selected.size === 0) return [];
+ return groups.filter((g) => selected.has(g.slug));
+}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,14 @@
# Changelog
## [Unreleased]
+- **"Ask AI" — an integrated grounding workspace: pick your AI target, sweep any size, save & resume chats.** Search and the chat used to behave like two tabs, grounding was all-or-nothing, a sweep had hard-coded caps and couldn't be paused, and a conversation lived in a single unnamed slot that "New chat" wiped. Now they're one surface:
+ - **Grounding palette (the signature control).** A first-class `Whole search ⇄ Selection` toggle in the chat always states *what the AI is looking at*, alongside an action cluster — **Ask**, **Sweep**, and preset directives (*Summary*, *Contradictions*, *Timeline*) that pre-fill the composer/sweep box. "Selection" grounds in a **hand-picked subset** of results shown as dismissible chips; an empty selection parks on Whole search, so nothing changes for people who never select.
+ - **Per-result selection + one-click Ask.** Every result card gets a select checkbox (an inclusion set mirroring the channel exclusion set) and an **Ask** button that grounds the chat in *that one video* and crosses to it. The selection survives `/` ⇄ `/ask` and reload, and self-cleans against a new search.
+ - **`[ Search | Chat | Split ]` view modes.** A persisted control fullscreens either pane or shows both side by side (`lg+`); below `lg` it degrades to Search ⇄ Chat tabs. Both panes stay mounted, so switching never re-runs the search or drops chat state.
+ - **Uncapped + pausable sweeps.** The sweep batch size is now a real setting, and a sweep sweeps the **whole** target. On a rate-limit (HTTP 429) or a manual **Pause**, the run *checkpoints* the report + the remaining batches instead of aborting, and surfaces **Paused: <reason> — [Resume] [Stop]**. **Resume** continues from the next batch — even after a reload or a **model switch** (the checkpoint carries no key/model binding). The "matched more than we can sweep" note is now driven by the true engine cap, not the >100-video display cap.
+ - **Saved chats library.** A **Saved chats** row (mirroring the search "profiles" row) with Save / Save as… / Rename / Delete and a dirty dot; a saved chat is a named copy of the working conversation **plus any paused-sweep checkpoint**, so a research session — including a mid-sweep pause — can be saved and resumed later.
+ - **PWA notifications (opt-in sites).** On a PWA-shipping site, a long sweep fires a system notification when it finishes or pauses while you're away; a graceful no-op everywhere else.
+ See `common/components/{SearchSessionContext,SearchResults,WorkspaceSearchBar}.tsx` + `common/lib/selection.ts`, `export/app/(workspace)/{WorkspaceView,WorkspaceNav,SiteWorkspace}.tsx`, `export/app/ask/{useAskChat,GroundingPalette,SavedChatsRow,PinnedResultsPanel,ProviderSettings,AskChat,askChatStorage,askNotify}.ts(x)`, `export/app/lib/nativeTools/shared.ts` (`PausableError`), and `export/e2e/{ask-workspace,ask-chat,workspace-shell}.spec.ts`.
- **"Ask AI" chat — sweep a whole search into one report, in batches.** A single question can only reason over ~15 videos, and a big multi-channel search (e.g. *k-cup* → hundreds of matches) leaves the long tail unread. The pinned-results panel now has a **whole-corpus report** action: it reads the **entire** matched set in batches, folding each batch's findings — claims, contradictions with earlier claims, and their cited sources — into a persistent Report and **discarding each batch's raw excerpts** as it goes, so tokens stay bounded no matter how large the result set. Type what to focus on in the composer (e.g. *"major contradictions"*, or leave it blank for *key claims & contradictions*) and click **Build report from all N results**; a determinate progress strip counts the batches while the Report panel's document visibly grows below. When a report already exists the action splits into **Start new report** (replaces it) and **Add to report** (folds these in) so a replacement is never implicit. The model may still drill into a thin line via *fetch_context* mid-sweep, and a single **Stop** cancels the whole run while keeping the partial report. Tool-capable providers only (like Report mode); on the Scripted transport the control is disabled with a hint. See `common/components/SearchSessionContext.tsx` (`getFullGrounding`), `export/app/lib/{askConversation,searchAgent}.ts` (`chunk`/`accumulationSystemPrompt`/`buildAccumulationContent`, `runReportChunk`), `export/app/ask/{useAskChat,PinnedResultsPanel,ReportPanel,AskChat}.tsx`, and `export/e2e/ask-chat.spec.ts`.
- **"Ask AI" chat — citations now open the transcript, and the answer reads your aliases in context.** Clicking an inline `[1]`, `[2]`… citation (or any timestamp in the source list beneath an answer) now **opens the transcript/video modal seeked to that line** — the same modal a search-result hit opens — instead of just scrolling to the source. It opens as a sibling overlay on the same `/ask` route (via `replaceState`, no navigation), so dismissing it (✕ / Escape) drops you back into the chat exactly where you were. On the federated hub chat (no player) citations keep the old scroll-to-source behaviour. Separately, the answer prompt now gets a **focused alias block naming only the terms a search actually used** (e.g. a search whose alias is *Cake Cups* → *k-cups*), so the model reads mis-transcribed spellings in the excerpts as the same term — the gather phase still sees the full glossary so it can discover terms it hasn't searched yet. See `common/components/PlayerProvider.tsx` (`usePlayerOptional`), `export/app/ask/{MessageBubble,AskChat}.tsx`, `export/app/lib/{askRetrieval,searchAgent,askConversation}.ts`, and `export/e2e/ask-chat.spec.ts`.
- **"Ask AI" chat — long answers no longer silently truncate, plus a debug export.** Long answers (especially reports on the free Gemini tier) used to get cut off mid-sentence with no indication, because the answer was hard-capped at 2048 output tokens and the app ignored the provider's "why did it stop" signal. Now: the answer budget defaults to **8192** and there's a **Max answer length** setting to push it higher; when a model *does* hit its output limit the answer shows a clear **"⚠ Cut off at the model's output limit"** notice (with a nudge to raise the limit or use Report mode); and a Gemini response that comes back empty because it was safety-blocked now says so instead of rendering a blank bubble. For troubleshooting, provider settings gain **Download / Copy debug JSON** — a redacted snapshot of the conversation (messages, phases, finish reasons, report, settings) plus the most recent raw API calls (request, response, finish reason, token usage); **your API key is never included**. See `export/app/lib/{askProvider,searchAgent,askDebug}.ts`, `export/app/lib/nativeTools/shared.ts`, and `export/app/ask/{useAskChat,ProviderSettings,MessageBubble}.tsx`.
diff --git a/export/app/(workspace)/SiteWorkspace.tsx b/export/app/(workspace)/SiteWorkspace.tsx
@@ -15,7 +15,7 @@ import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal
import { SingleSiteDataProvider } from "yt-dlp-transcript-common/components/SearchDataContext";
import { SearchSessionProvider } from "yt-dlp-transcript-common/components/SearchSessionContext";
import WorkspaceSearchBar from "yt-dlp-transcript-common/components/WorkspaceSearchBar";
-import WorkspaceNav from "./WorkspaceNav";
+import WorkspaceView from "./WorkspaceView";
export default function SiteWorkspace({
children,
@@ -27,9 +27,11 @@ export default function SiteWorkspace({
<SingleSiteDataProvider>
<SearchSessionProvider>
<div className="flex flex-col gap-6">
- <WorkspaceNav />
<WorkspaceSearchBar />
- {children}
+ {/* Both panes (results + chat) mount once inside WorkspaceView; the
+ [Search|Chat|Split] control governs which show. The routed
+ `children` is each route's intro chrome, slotted above its pane. */}
+ <WorkspaceView>{children}</WorkspaceView>
</div>
</SearchSessionProvider>
</SingleSiteDataProvider>
diff --git a/export/app/(workspace)/WorkspaceNav.tsx b/export/app/(workspace)/WorkspaceNav.tsx
@@ -1,46 +1,76 @@
"use client";
-// The Results ⇄ Chat switch for the shared workspace. Both destinations sit
-// under the same layout, so navigating between them keeps the search bar (and
-// its committed search) mounted — this is just real routing between the two
-// views of that one session. Trailing slashes match the static export's
-// trailingSlash:true.
+// The workspace view-mode control: [ Search | Chat | Split ]. The two focused
+// single-pane views come first, the combined view last. Search and Chat are real
+// Links (client-side routing between `/` and `/ask`, so the URL + the persistent
+// search bar stay in lockstep); Split is a client toggle that shows both panes
+// side by side. Below `lg` Split isn't offered and the control degrades to the
+// two-state Search ⇄ Chat tabs — fullscreen-either is the only sensible form on a
+// phone. Trailing slashes match the static export's trailingSlash:true.
import Link from "next/link";
-import { usePathname } from "next/navigation";
+import { ColumnsIcon, MessageSquareIcon, SearchIcon } from "lucide-react";
+import type { ViewMode } from "./WorkspaceView";
-const ITEMS = [
- { href: "/", label: "Results" },
- { href: "/ask/", label: "Chat" },
-] as const;
+export default function WorkspaceNav({
+ onAsk,
+ view,
+ wide,
+ onPickSingle,
+ onPickSplit,
+}: {
+ // Whether the current route is /ask (drives which single-pane link is active).
+ onAsk: boolean;
+ // The current (possibly split) view.
+ view: ViewMode;
+ // ≥ lg — Split is only offered here.
+ wide: boolean;
+ // Picking a single-pane view (navigates + clears any split).
+ onPickSingle: (v: "search" | "chat") => void;
+ // Picking the side-by-side view (stays on the current route).
+ onPickSplit: () => void;
+}) {
+ const split = view === "split";
+ const base =
+ "inline-flex items-center gap-1.5 px-3 py-1.5 font-medium transition-colors";
+ const active = "bg-primary text-primary-foreground";
+ const idle =
+ "text-muted-foreground hover:bg-accent hover:text-accent-foreground";
-export default function WorkspaceNav() {
- const pathname = usePathname();
- const onAsk = pathname?.startsWith("/ask") ?? false;
return (
<div
role="group"
- aria-label="Search or chat"
+ aria-label="Search, chat, or split view"
data-testid="workspace-nav"
className="inline-flex self-start overflow-hidden rounded-md border border-border bg-muted text-sm"
>
- {ITEMS.map((item) => {
- const active = item.href === "/ask/" ? onAsk : !onAsk;
- return (
- <Link
- key={item.href}
- href={item.href}
- aria-current={active ? "page" : undefined}
- className={`px-3 py-1.5 font-medium transition-colors ${
- active
- ? "bg-primary text-primary-foreground"
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
- }`}
- >
- {item.label}
- </Link>
- );
- })}
+ <Link
+ href="/"
+ aria-current={!onAsk && !split ? "page" : undefined}
+ onClick={() => onPickSingle("search")}
+ className={`${base} ${!split && !onAsk ? active : idle}`}
+ >
+ <SearchIcon className="size-3.5" /> Search
+ </Link>
+ <Link
+ href="/ask/"
+ aria-current={onAsk && !split ? "page" : undefined}
+ onClick={() => onPickSingle("chat")}
+ className={`${base} ${!split && onAsk ? active : idle}`}
+ >
+ <MessageSquareIcon className="size-3.5" /> Chat
+ </Link>
+ {wide && (
+ <button
+ type="button"
+ aria-pressed={split}
+ data-testid="view-split"
+ onClick={onPickSplit}
+ className={`${base} border-l border-border ${split ? active : idle}`}
+ >
+ <ColumnsIcon className="size-3.5" /> Split
+ </button>
+ )}
</div>
);
}
diff --git a/export/app/(workspace)/WorkspaceView.tsx b/export/app/(workspace)/WorkspaceView.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+// The workspace body: it mounts BOTH panes — the search/results rail and the
+// chat/report pane — once, and a [ Search | Chat | Split ] control governs which
+// are shown. Because both stay mounted (hidden via CSS when off-screen), toggling
+// views never re-runs the search or drops chat state; they read the one shared
+// SearchSession the shell provides.
+//
+// • single-pane view follows the ROUTE (`/` → search, `/ask` → chat), so a
+// direct load or a Back/Forward lands on the right pane, and the two
+// single-pane buttons are real navigation Links;
+// • Split is the one cross-route state (persisted): show both, side by side on
+// `lg+`. Below `lg` Split isn't offered and the control is Search ⇄ Chat tabs.
+//
+// The routed `children` is each route's own intro chrome (the transcript-count
+// header on `/`, the "Ask a question" header on `/ask`); it's slotted above the
+// pane it belongs to.
+
+import { useEffect, useState, type ReactNode } from "react";
+import { usePathname } from "next/navigation";
+import SearchResults from "yt-dlp-transcript-common/components/SearchResults";
+import AskChat from "../ask/AskChat";
+import WorkspaceNav from "./WorkspaceNav";
+
+export type ViewMode = "search" | "chat" | "split";
+
+const SPLIT_KEY = "ytdlp-tb:workspace-split";
+
+export default function WorkspaceView({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+ const onAsk = pathname?.startsWith("/ask") ?? false;
+
+ // The only persisted view state: whether the user opted into Split. The single-
+ // pane choice always follows the route, so a direct /ask load shows the chat.
+ const [splitOn, setSplitOn] = useState(false);
+ const [wide, setWide] = useState(false);
+
+ useEffect(() => {
+ /* eslint-disable react-hooks/set-state-in-effect -- one-time restore from
+ localStorage after hydration; there is no localStorage during SSR, so this
+ can't be a lazy useState initializer. */
+ try {
+ setSplitOn(localStorage.getItem(SPLIT_KEY) === "1");
+ } catch {
+ /* storage unavailable */
+ }
+ /* eslint-enable react-hooks/set-state-in-effect */
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.matchMedia) return;
+ const mq = window.matchMedia("(min-width: 1024px)");
+ const sync = () => setWide(mq.matches);
+ sync();
+ mq.addEventListener("change", sync);
+ return () => mq.removeEventListener("change", sync);
+ }, []);
+
+ const setSplit = (on: boolean) => {
+ setSplitOn(on);
+ try {
+ localStorage.setItem(SPLIT_KEY, on ? "1" : "0");
+ } catch {
+ /* ignore */
+ }
+ };
+
+ // Split only takes effect on a wide viewport; otherwise the view follows the
+ // route (the two-state tab degradation).
+ const view: ViewMode =
+ splitOn && wide ? "split" : onAsk ? "chat" : "search";
+ const split = view === "split";
+ const showSearch = split || view === "search";
+ const showChat = split || view === "chat";
+
+ // Mount each pane lazily and LATCH it mounted once revealed. This keeps a
+ // single-pane route light to hydrate (a fresh `/` doesn't mount the whole chat,
+ // and vice-versa) — which matters because a heavy first paint widens the
+ // pre-hydration window where a nav click can be lost. Once a pane has been
+ // shown it stays mounted (hidden via CSS), so switching back never drops its
+ // state or re-runs its work. Latched with React's "adjust state during render"
+ // pattern (a one-way false→true flip), so no effect / cascading render.
+ const [searchMounted, setSearchMounted] = useState(showSearch);
+ const [chatMounted, setChatMounted] = useState(showChat);
+ if (showSearch && !searchMounted) setSearchMounted(true);
+ if (showChat && !chatMounted) setChatMounted(true);
+
+ return (
+ <div className="flex flex-col gap-6">
+ <WorkspaceNav
+ onAsk={onAsk}
+ view={view}
+ wide={wide}
+ onPickSingle={() => setSplit(false)}
+ onPickSplit={() => setSplit(true)}
+ />
+
+ <div
+ className={
+ split ? "grid gap-6 lg:grid-cols-2 lg:items-start" : "block"
+ }
+ >
+ <section
+ data-testid="pane-search"
+ className={`flex flex-col gap-6 ${showSearch ? "" : "hidden"} ${
+ split ? "min-w-0" : ""
+ }`}
+ >
+ {!onAsk && children}
+ {searchMounted && <SearchResults />}
+ </section>
+
+ <section
+ data-testid="pane-chat"
+ className={`flex flex-col gap-6 ${showChat ? "" : "hidden"} ${
+ split ? "min-w-0" : "mx-auto w-full max-w-3xl"
+ }`}
+ >
+ {onAsk && children}
+ {chatMounted && <AskChat />}
+ </section>
+ </div>
+ </div>
+ );
+}
diff --git a/export/app/(workspace)/ask/page.tsx b/export/app/(workspace)/ask/page.tsx
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
import Link from "next/link";
import { currentSite } from "../../lib/site";
import { instanceMode } from "../../lib/mode";
-import AskChat from "../../ask/AskChat";
import AskHub from "../../ask/AskHub";
export const metadata: Metadata = { title: "Ask AI" };
@@ -42,7 +41,11 @@ export default function AskPage() {
</p>
</header>
- {isHub ? <AskHub /> : <AskChat />}
+ {/* Single-site: the chat pane (AskChat) is mounted by the workspace shell
+ (WorkspaceView), so this route renders only its intro header — the shell
+ slots it above the chat. Hub mode bypasses the shell, so AskHub renders
+ here with its own MultiSiteDataProvider wiring. */}
+ {isHub && <AskHub />}
</div>
);
}
diff --git a/export/app/(workspace)/page.tsx b/export/app/(workspace)/page.tsx
@@ -1,5 +1,4 @@
import { countTranscripts } from "yt-dlp-transcript-common/lib/transcripts";
-import SearchResults from "yt-dlp-transcript-common/components/SearchResults";
import { currentSite } from "../lib/site";
import { instanceMode } from "../lib/mode";
import NewSinceLastVisit from "../components/NewSinceLastVisit";
@@ -15,10 +14,10 @@ export default async function Home() {
const settings = currentSite();
const count = await countTranscripts();
- // The corpus data source, SearchSession, and the persistent search bar all
- // live in the workspace shell (layout). This page renders only the "results"
- // half of the workspace: the transcript count header and the results view,
- // both reading the shared session.
+ // The corpus data source, SearchSession, the persistent search bar, AND the
+ // results pane all live in the workspace shell (layout → WorkspaceView). This
+ // page renders only the results pane's INTRO chrome — the transcript-count
+ // header — which the shell slots above the results.
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
@@ -31,7 +30,6 @@ export default async function Home() {
)}
</div>
<NewSinceLastVisit siteId={settings.siteId} count={count} />
- <SearchResults />
</div>
);
}
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -8,6 +8,8 @@ import { ProviderSettings } from "./ProviderSettings";
import { ContextPanel } from "./ContextPanel";
import { ReportPanel } from "./ReportPanel";
import { PinnedResultsPanel } from "./PinnedResultsPanel";
+import { GroundingPalette } from "./GroundingPalette";
+import { SavedChatsRow } from "./SavedChatsRow";
import { MessageBubble } from "./MessageBubble";
import { Composer } from "./Composer";
@@ -106,9 +108,23 @@ export default function AskChat() {
persistKey={s.persistKey}
maxAnswerTokens={s.maxAnswerTokens}
setMaxAnswerTokens={s.setMaxAnswerTokens}
+ sweepBatch={s.sweepBatch}
+ setSweepBatch={s.setSweepBatch}
buildDebugJson={s.buildDebugJson}
/>
+ <SavedChatsRow
+ names={Object.keys(s.savedChats).sort()}
+ activeName={s.activeChatName}
+ dirty={s.chatDirty}
+ busy={busy}
+ onLoad={s.loadChat}
+ onSave={s.saveChat}
+ onSaveAs={s.saveChatAs}
+ onRename={s.renameChat}
+ onDelete={s.deleteChat}
+ />
+
{corpusError && (
<p className="text-sm text-warning">
Couldn't load the transcript index ({corpusError}). This chat needs
@@ -144,8 +160,31 @@ export default function AskChat() {
)}
{s.pinned && (
+ <GroundingPalette
+ pinned={s.pinned}
+ effectiveMode={s.effectiveGroundingMode}
+ setMode={s.setGroundingMode}
+ selectedCount={s.selectedCount}
+ onDeselect={s.toggleSelected}
+ onClearSelection={s.clearSelection}
+ canSweep={s.canSweep}
+ sweeping={s.sweeping}
+ hasReport={s.report.trim() !== ""}
+ busy={busy}
+ hasKey={!!apiKey.trim()}
+ onAsk={() => {
+ if (input.trim()) s.send();
+ else composerRef.current?.focus();
+ }}
+ onSweep={(mode) => s.sweep({ directive: input, mode })}
+ onPreset={fillSuggestion}
+ />
+ )}
+
+ {s.pinned && (
<PinnedResultsPanel
pinned={s.pinned}
+ groundingMode={s.effectiveGroundingMode}
strictGrounding={s.strictGrounding}
busy={busy}
expanding={s.expanding}
@@ -164,9 +203,44 @@ export default function AskChat() {
defaultDirective={DEFAULT_SWEEP_DIRECTIVE}
onSweep={(mode) => s.sweep({ directive: input, mode })}
onStopSweep={s.stop}
+ onPauseSweep={s.pauseSweep}
/>
)}
+ {/* A paused sweep — resumable even after a reload or a model switch. Shows
+ whenever a checkpoint exists and no sweep is currently running. */}
+ {s.sweepPaused && s.sweepCheckpoint && (
+ <div
+ data-testid="sweep-paused"
+ className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-warning/50 bg-warning-soft/40 px-4 py-2.5 text-xs text-foreground"
+ >
+ <span>
+ Paused: {s.sweepCheckpoint.reason} — {s.sweepCheckpoint.done} of{" "}
+ {s.sweepCheckpoint.total} batches done,{" "}
+ {s.sweepCheckpoint.remainingSlugs.length} result
+ {s.sweepCheckpoint.remainingSlugs.length === 1 ? "" : "s"} left.
+ </span>
+ <span className="flex items-center gap-1.5">
+ <button
+ type="button"
+ onClick={s.resumeSweep}
+ disabled={busy || !apiKey.trim()}
+ className="inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1 font-medium text-primary-foreground transition-colors hover:bg-brand-strong disabled:opacity-50"
+ >
+ Resume
+ </button>
+ <button
+ type="button"
+ onClick={s.discardCheckpoint}
+ disabled={busy}
+ className="rounded-md border border-border px-2 py-1 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
+ >
+ Stop
+ </button>
+ </span>
+ </div>
+ )}
+
{/* Detached with a live search available → offer to ground in it again. */}
{s.detached && s.hasLiveSearch && (
<div className="flex items-center justify-between gap-2 rounded-lg border border-border bg-card/40 px-4 py-2.5 text-xs text-muted-foreground">
diff --git a/export/app/ask/GroundingPalette.tsx b/export/app/ask/GroundingPalette.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+// The grounding palette — the connective tissue between the search results and
+// the chat. It always states WHAT the AI is looking at and turns that target
+// into an action. This is the one deliberately-bold control in the workspace:
+//
+// • a first-class segmented MODE TOGGLE (Whole search ⇄ Selection), and
+// • a cluster of ACTION buttons operating on the active target — Ask, Sweep,
+// and a couple of preset directives that pre-fill the composer/sweep box.
+//
+// Everything else stays quiet (semantic theme tokens, lucide icons); the palette
+// carries the emphasis.
+
+import {
+ GlobeIcon,
+ ListChecksIcon,
+ SendIcon,
+ TelescopeIcon,
+ XIcon,
+} from "lucide-react";
+import type { SearchHandoff } from "yt-dlp-transcript-common/lib/aiHandoff";
+import type { GroundingMode } from "yt-dlp-transcript-common/components/SearchSessionContext";
+
+// Preset directives — one click pre-fills the composer (which is also the sweep
+// directive), so a common report focus is a keystroke away.
+const PRESETS: { label: string; directive: string }[] = [
+ { label: "Summary", directive: "a concise summary of the key points" },
+ { label: "Contradictions", directive: "the major contradictions between these" },
+ { label: "Timeline", directive: "a chronological timeline of what happened" },
+];
+
+export function GroundingPalette({
+ pinned,
+ effectiveMode,
+ setMode,
+ selectedCount,
+ onDeselect,
+ onClearSelection,
+ canSweep,
+ sweeping,
+ hasReport,
+ busy,
+ hasKey,
+ onAsk,
+ onSweep,
+ onPreset,
+}: {
+ // The active grounding (for the selection chips + counts). Never null here —
+ // AskChat only renders the palette when there's a grounding to act on.
+ pinned: SearchHandoff;
+ // The mode after the empty-selection guard (what's actually in effect).
+ effectiveMode: GroundingMode;
+ setMode: (m: GroundingMode) => void;
+ selectedCount: number;
+ onDeselect: (key: string) => void;
+ onClearSelection: () => void;
+ canSweep: boolean;
+ sweeping: boolean;
+ hasReport: boolean;
+ busy: boolean;
+ hasKey: boolean;
+ // Submit the composer as a normal grounded question.
+ onAsk: () => void;
+ // Start (or extend) a whole-target sweep.
+ onSweep: (mode: "fresh" | "extend") => void;
+ // Pre-fill the composer / sweep directive.
+ onPreset: (directive: string) => void;
+}) {
+ const hasSelection = selectedCount > 0;
+ const n = pinned.videos.length;
+
+ return (
+ <div
+ data-testid="grounding-palette"
+ className="flex flex-col gap-3 rounded-lg border border-brand/50 bg-brand-soft/40 p-3"
+ >
+ {/* ── Mode toggle: the signature segmented switch ── */}
+ <div className="flex flex-wrap items-center gap-2">
+ <div
+ role="tablist"
+ aria-label="What the AI is grounded in"
+ data-testid="grounding-mode"
+ className="inline-flex overflow-hidden rounded-lg border border-brand/60 bg-card p-0.5 text-xs font-medium shadow-sm"
+ >
+ <button
+ type="button"
+ role="tab"
+ aria-selected={effectiveMode === "search"}
+ onClick={() => setMode("search")}
+ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 transition-colors ${
+ effectiveMode === "search"
+ ? "bg-brand text-brand-ink"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ <GlobeIcon className="size-3.5" /> Whole search
+ </button>
+ <button
+ type="button"
+ role="tab"
+ aria-selected={effectiveMode === "selection"}
+ aria-disabled={!hasSelection}
+ onClick={() => hasSelection && setMode("selection")}
+ title={
+ hasSelection
+ ? "Ground in the videos you selected"
+ : "Select videos in the results to enable this"
+ }
+ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 transition-colors ${
+ effectiveMode === "selection"
+ ? "bg-brand text-brand-ink"
+ : hasSelection
+ ? "text-muted-foreground hover:text-foreground"
+ : "cursor-not-allowed text-muted-foreground/40"
+ }`}
+ >
+ <ListChecksIcon className="size-3.5" /> Selection
+ {hasSelection ? (
+ <span
+ className={`rounded-full px-1.5 text-[10px] tabular-nums ${
+ effectiveMode === "selection"
+ ? "bg-brand-ink/20"
+ : "bg-brand/15 text-foreground"
+ }`}
+ >
+ {selectedCount}
+ </span>
+ ) : null}
+ </button>
+ </div>
+ <span className="text-xs text-muted-foreground">
+ {effectiveMode === "selection"
+ ? `Grounded in ${n} selected video${n === 1 ? "" : "s"}`
+ : "Grounded in the whole search"}
+ </span>
+ </div>
+
+ {/* ── Selection chips (dismissible → deselect) ── */}
+ {effectiveMode === "selection" && (
+ <div className="flex flex-wrap items-center gap-1.5" data-testid="selection-chips">
+ {pinned.videos.map((v) => (
+ <span
+ key={v.key}
+ className="inline-flex max-w-[16rem] items-center gap-1 rounded-full border border-border bg-card px-2 py-0.5 text-xs text-foreground"
+ >
+ <span className="truncate">{v.title}</span>
+ <button
+ type="button"
+ onClick={() => onDeselect(v.key)}
+ aria-label={`Remove ${v.title} from the selection`}
+ className="shrink-0 text-muted-foreground transition-colors hover:text-destructive"
+ >
+ <XIcon className="size-3" />
+ </button>
+ </span>
+ ))}
+ <button
+ type="button"
+ onClick={onClearSelection}
+ className="text-xs text-muted-foreground transition-colors hover:text-foreground"
+ >
+ Clear all
+ </button>
+ </div>
+ )}
+
+ {/* ── Action cluster: Ask · Sweep · presets ── */}
+ <div className="flex flex-wrap items-center gap-2">
+ <button
+ type="button"
+ onClick={onAsk}
+ disabled={busy || !hasKey}
+ aria-label="Ask about this grounding"
+ className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-brand-strong disabled:opacity-50"
+ >
+ <SendIcon className="size-3.5" /> Ask
+ </button>
+ <button
+ type="button"
+ onClick={() => onSweep(hasReport ? "extend" : "fresh")}
+ disabled={!canSweep || busy || sweeping || !hasKey}
+ title={
+ canSweep
+ ? "Read the whole target in batches into one running report"
+ : "Needs a tool-capable provider (switch off Scripted)"
+ }
+ className="inline-flex items-center gap-1.5 rounded-md border border-brand/60 bg-card px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-brand disabled:opacity-50"
+ >
+ <TelescopeIcon className="size-3.5" /> Sweep
+ </button>
+ <span className="mx-0.5 h-4 w-px bg-border" aria-hidden="true" />
+ {PRESETS.map((p) => (
+ <button
+ key={p.label}
+ type="button"
+ onClick={() => onPreset(p.directive)}
+ disabled={busy}
+ className="rounded-full border border-border bg-card/60 px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:border-brand hover:text-foreground disabled:opacity-50"
+ >
+ {p.label}
+ </button>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/export/app/ask/PinnedResultsPanel.tsx b/export/app/ask/PinnedResultsPanel.tsx
@@ -4,6 +4,7 @@ import { useState } from "react";
import {
ChevronDownIcon,
Loader2Icon,
+ PauseIcon,
PinIcon,
PlusIcon,
SquareIcon,
@@ -11,6 +12,7 @@ import {
XIcon,
} from "lucide-react";
import type { SearchHandoff } from "yt-dlp-transcript-common/lib/aiHandoff";
+import type { GroundingMode } from "yt-dlp-transcript-common/components/SearchSessionContext";
// Shows the search results handed off from the search page as the chat's pinned
// grounding: what they are, whether the assistant may look beyond them (the
@@ -18,6 +20,7 @@ import type { SearchHandoff } from "yt-dlp-transcript-common/lib/aiHandoff";
// to detach them.
export function PinnedResultsPanel({
pinned,
+ groundingMode,
strictGrounding,
busy,
expanding,
@@ -37,8 +40,11 @@ export function PinnedResultsPanel({
defaultDirective,
onSweep,
onStopSweep,
+ onPauseSweep,
}: {
pinned: SearchHandoff;
+ // Whole search vs. hand-picked selection — drives the header copy.
+ groundingMode: GroundingMode;
strictGrounding: boolean;
busy: boolean;
expanding: Record<string, boolean>;
@@ -64,6 +70,8 @@ export function PinnedResultsPanel({
defaultDirective: string;
onSweep: (mode: "fresh" | "extend") => void;
onStopSweep: () => void;
+ // Courtesy pause: checkpoint + stop at the next batch boundary (resumable).
+ onPauseSweep: () => void;
}) {
const [showList, setShowList] = useState(false);
const n = pinned.videos.length;
@@ -81,7 +89,9 @@ export function PinnedResultsPanel({
<PinIcon className="mt-0.5 size-4 shrink-0 text-brand" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">
- Grounded in {n} result{n === 1 ? "" : "s"} from your current search
+ {groundingMode === "selection"
+ ? `Grounded in ${n} selected video${n === 1 ? "" : "s"}`
+ : `Grounded in ${n} result${n === 1 ? "" : "s"} from your current search`}
</span>
<span className="truncate font-mono text-xs text-muted-foreground">
{pinned.label}
@@ -209,14 +219,25 @@ export function PinnedResultsPanel({
<span className="text-sm font-medium text-foreground">
Sweeping {sweepN} result{sweepN === 1 ? "" : "s"}…
</span>
- <button
- type="button"
- onClick={onStopSweep}
- aria-label="Stop the sweep"
- className="ml-auto inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
- >
- <SquareIcon className="size-3" /> Stop
- </button>
+ <div className="ml-auto flex items-center gap-1.5">
+ <button
+ type="button"
+ onClick={onPauseSweep}
+ aria-label="Pause the sweep"
+ title="Pause after the current batch — you can resume later"
+ className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
+ >
+ <PauseIcon className="size-3" /> Pause
+ </button>
+ <button
+ type="button"
+ onClick={onStopSweep}
+ aria-label="Stop the sweep"
+ className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
+ >
+ <SquareIcon className="size-3" /> Stop
+ </button>
+ </div>
</div>
<div
className="h-1.5 w-full overflow-hidden rounded-full bg-border"
diff --git a/export/app/ask/ProviderSettings.tsx b/export/app/ask/ProviderSettings.tsx
@@ -26,6 +26,8 @@ type Props = {
persistKey: (p: Provider, key: string, mdl: string, rememberOn: boolean) => void;
maxAnswerTokens: number;
setMaxAnswerTokens: (n: number) => void;
+ sweepBatch: number;
+ setSweepBatch: (n: number) => void;
buildDebugJson: () => string;
};
@@ -46,6 +48,8 @@ export function ProviderSettings(props: Props) {
persistKey,
maxAnswerTokens,
setMaxAnswerTokens,
+ sweepBatch,
+ setSweepBatch,
buildDebugJson,
} = props;
const info = PROVIDERS[provider];
@@ -197,6 +201,26 @@ export function ProviderSettings(props: Props) {
</span>
</label>
+ {/* Sweep batch size: how many result videos each corpus-sweep batch folds
+ into the report. Smaller → more, lighter batches (kinder to rate limits
+ + easier to pause/resume); bigger → fewer, heavier passes. */}
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Sweep batch size (videos per batch)
+ <input
+ type="number"
+ min={1}
+ max={100}
+ step={1}
+ value={sweepBatch}
+ onChange={(e) => setSweepBatch(Number(e.target.value))}
+ className="w-32 rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground"
+ />
+ <span className="text-muted-foreground/70">
+ How many videos a whole-corpus sweep reads per batch. Lower it if a
+ sweep keeps hitting your provider’s rate limit.
+ </span>
+ </label>
+
{/* Debug export: a redacted snapshot for troubleshooting. */}
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<span>Debug</span>
diff --git a/export/app/ask/SavedChatsRow.tsx b/export/app/ask/SavedChatsRow.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+// The saved-chats library row — the /ask analogue of the search "profiles" row
+// (WorkspaceSearchBar's ProfilesRow): a select of saved chats + text-link
+// Save / Save as… / Rename / Delete actions and a warning dirty dot. The live
+// "working" chat autosaves on its own; these actions manage NAMED copies, so a
+// research session can be saved and resumed later (even a paused sweep). Uses the
+// browser prompt/confirm, exactly like ProfilesRow.
+
+export function SavedChatsRow({
+ names,
+ activeName,
+ dirty,
+ busy,
+ onLoad,
+ onSave,
+ onSaveAs,
+ onRename,
+ onDelete,
+}: {
+ names: string[];
+ activeName: string | null;
+ dirty: boolean;
+ busy: boolean;
+ onLoad: (name: string) => void;
+ onSave: () => void;
+ onSaveAs: () => void;
+ onRename: () => void;
+ onDelete: () => void;
+}) {
+ // Nothing saved and nothing loaded → keep the header quiet (Save as… still
+ // reachable via the composer area's New chat flow once there's content).
+ const hasAny = names.length > 0 || activeName != null;
+ if (!hasAny) {
+ return (
+ <div
+ className="flex flex-wrap items-center gap-x-3 gap-y-1"
+ data-testid="saved-chats-row"
+ >
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Saved chats
+ </span>
+ <button
+ type="button"
+ onClick={onSaveAs}
+ disabled={busy}
+ className="text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
+ >
+ Save as…
+ </button>
+ </div>
+ );
+ }
+
+ return (
+ <div
+ className="flex flex-wrap items-center gap-x-3 gap-y-1"
+ data-testid="saved-chats-row"
+ >
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Saved chats
+ </span>
+ <select
+ value={activeName ?? ""}
+ onChange={(e) => e.target.value && onLoad(e.target.value)}
+ data-testid="saved-chat-select"
+ className="rounded border border-border bg-card px-2 py-0.5 text-sm text-foreground"
+ >
+ <option value="">(unsaved)</option>
+ {names.map((n) => (
+ <option key={n} value={n}>
+ {n}
+ </option>
+ ))}
+ </select>
+ {dirty && (
+ <span
+ role="status"
+ aria-label="chat has unsaved changes"
+ data-testid="chat-dirty-dot"
+ className="text-base leading-none text-warning"
+ >
+ •
+ </span>
+ )}
+ <button
+ type="button"
+ onClick={onSave}
+ disabled={busy || !activeName || !dirty}
+ className="text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
+ >
+ Save
+ </button>
+ <button
+ type="button"
+ onClick={onSaveAs}
+ disabled={busy}
+ className="text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
+ >
+ Save as…
+ </button>
+ <button
+ type="button"
+ onClick={onRename}
+ disabled={busy || !activeName}
+ className="text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
+ >
+ Rename
+ </button>
+ <button
+ type="button"
+ onClick={onDelete}
+ disabled={busy || !activeName}
+ className="text-xs text-destructive transition-colors hover:opacity-80 disabled:opacity-50"
+ >
+ Delete
+ </button>
+ </div>
+ );
+}
diff --git a/export/app/ask/askChatStorage.test.ts b/export/app/ask/askChatStorage.test.ts
@@ -0,0 +1,154 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+// Minimal in-memory localStorage so the store's window-guarded functions run in
+// node exactly as they do in the browser.
+class MemStorage {
+ private map = new Map<string, string>();
+ getItem(k: string): string | null {
+ return this.map.has(k) ? (this.map.get(k) as string) : null;
+ }
+ setItem(k: string, v: string): void {
+ this.map.set(k, String(v));
+ }
+ removeItem(k: string): void {
+ this.map.delete(k);
+ }
+}
+(globalThis as { window?: unknown }).window = { localStorage: new MemStorage() };
+
+const KEY = "ytdlp-tb:ai:chats";
+function rawStore(): Storage {
+ return (globalThis as unknown as { window: { localStorage: Storage } }).window
+ .localStorage;
+}
+
+import {
+ emptySavedChats,
+ loadSavedChats,
+ saveSavedChats,
+ savedChatsEqual,
+ type SavedChat,
+ type SavedChatsState,
+} from "./askChatStorage";
+import type { UiMessage } from "../lib/askConversation";
+
+function msg(role: "user" | "assistant", content: string): UiMessage {
+ return { role, content };
+}
+
+function baseChat(over: Partial<SavedChat> = {}): SavedChat {
+ return {
+ messages: [msg("user", "hi"), msg("assistant", "hello")],
+ contextOverride: null,
+ ...over,
+ };
+}
+
+test("loadSavedChats on empty storage returns the empty state", () => {
+ rawStore().removeItem(KEY);
+ assert.deepEqual(loadSavedChats(), emptySavedChats());
+});
+
+test("save → load round-trips items + active pointer", () => {
+ rawStore().removeItem(KEY);
+ const state: SavedChatsState = {
+ v: 1,
+ items: { research: baseChat({ report: "# Findings\n" }) },
+ activeName: "research",
+ };
+ saveSavedChats(state);
+ const back = loadSavedChats();
+ assert.equal(back.activeName, "research");
+ assert.equal(back.items.research.report, "# Findings\n");
+ assert.equal(back.items.research.messages.length, 2);
+});
+
+test("malformed JSON parses-or-resets (returns empty + clears the key)", () => {
+ rawStore().setItem(KEY, "{ not json");
+ assert.deepEqual(loadSavedChats(), emptySavedChats());
+ assert.equal(rawStore().getItem(KEY), null);
+});
+
+test("a wrong version resets", () => {
+ rawStore().setItem(KEY, JSON.stringify({ v: 99, items: {}, activeName: null }));
+ assert.deepEqual(loadSavedChats(), emptySavedChats());
+});
+
+test("an active pointer to a missing chat is dropped on load", () => {
+ rawStore().setItem(
+ KEY,
+ JSON.stringify({ v: 1, items: { a: baseChat() }, activeName: "ghost" }),
+ );
+ assert.equal(loadSavedChats().activeName, null);
+});
+
+test("a sweep checkpoint round-trips (serialize → resume yields the remaining batches)", () => {
+ rawStore().removeItem(KEY);
+ const chat = baseChat({
+ report: "# Report so far\n",
+ checkpoint: {
+ directive: "major contradictions",
+ remainingSlugs: ["ch/v3", "ch/v4", "ch/v5"],
+ done: 2,
+ total: 5,
+ reason: "usage limit",
+ },
+ });
+ saveSavedChats({ v: 1, items: { paused: chat }, activeName: "paused" });
+ const cp = loadSavedChats().items.paused.checkpoint;
+ assert.ok(cp);
+ assert.deepEqual(cp.remainingSlugs, ["ch/v3", "ch/v4", "ch/v5"]);
+ assert.equal(cp.done, 2);
+ assert.equal(cp.total, 5);
+ assert.equal(cp.reason, "usage limit");
+});
+
+test("savedChatsEqual: equal on the same restorable content", () => {
+ assert.ok(savedChatsEqual(baseChat(), baseChat()));
+});
+
+test("savedChatsEqual: dirty when messages / report / checkpoint differ", () => {
+ assert.ok(!savedChatsEqual(baseChat(), baseChat({ messages: [msg("user", "x")] })));
+ assert.ok(!savedChatsEqual(baseChat(), baseChat({ report: "changed" })));
+ assert.ok(
+ !savedChatsEqual(
+ baseChat(),
+ baseChat({ checkpoint: { directive: "d", remainingSlugs: ["a"], done: 0, total: 1, reason: "paused" } }),
+ ),
+ );
+});
+
+test("savedChatsEqual: ignores info-only fields (model, savedAt)", () => {
+ assert.ok(
+ savedChatsEqual(
+ baseChat({ model: "claude-opus-4-8", savedAt: 111 }),
+ baseChat({ model: "gemini-2.5", savedAt: 999 }),
+ ),
+ );
+});
+
+test("rename + delete map ops persist through save/load", () => {
+ rawStore().removeItem(KEY);
+ // Start with two saved chats.
+ const s: SavedChatsState = {
+ v: 1,
+ items: { a: baseChat(), b: baseChat({ report: "b-report" }) },
+ activeName: "a",
+ };
+ saveSavedChats(s);
+ // Rename a → alpha (rebuild the map, move the active pointer).
+ const renamed: SavedChatsState = { v: 1, items: {}, activeName: "alpha" };
+ for (const [k, v] of Object.entries(loadSavedChats().items)) {
+ renamed.items[k === "a" ? "alpha" : k] = v;
+ }
+ saveSavedChats(renamed);
+ let back = loadSavedChats();
+ assert.deepEqual(Object.keys(back.items).sort(), ["alpha", "b"]);
+ assert.equal(back.activeName, "alpha");
+ // Delete b.
+ delete back.items.b;
+ saveSavedChats(back);
+ back = loadSavedChats();
+ assert.deepEqual(Object.keys(back.items), ["alpha"]);
+});
diff --git a/export/app/ask/askChatStorage.ts b/export/app/ask/askChatStorage.ts
@@ -0,0 +1,181 @@
+// localStorage layer for the /ask SAVED CHATS library: a set of named chats +
+// an active-name pointer, mirroring the search "profiles" store
+// (common/components/exportFilterStorage.ts) — strict parse-or-reset, quota-safe
+// writes, and a deep-equal dirty helper. A SavedChat is a snapshot of the working
+// conversation (messages + report + grounding flags) PLUS an optional sweep
+// checkpoint, so a paused sweep is just a saved chat in a paused state and can be
+// resumed after a reload or a model switch.
+//
+// The single live "working" conversation still lives under its own K_CONVO key in
+// useAskChat; a saved chat is a named copy, and loading one replaces the working
+// conversation.
+
+import type { UiMessage } from "../lib/askConversation";
+
+const KEY = "ytdlp-tb:ai:chats";
+const VERSION = 1;
+
+// A sweep that paused mid-run (rate-limit, model warning, or a manual Pause). Holds
+// everything needed to RESUME from the next batch under any provider/model: the
+// remaining result slugs, the directive, and the running report is carried by the
+// SavedChat.report field. `reason` is a short human string for the paused banner.
+export type SweepCheckpoint = {
+ // The report directive the sweep was folding toward.
+ directive: string;
+ // Result slugs not yet folded into the report (the remaining batch queue,
+ // flattened — the resumer re-chunks them by the current batch size).
+ remainingSlugs: string[];
+ // Batches already completed / total, for the "resumed at i of n" copy.
+ done: number;
+ total: number;
+ // Why it paused (e.g. "usage limit", "safety block", "paused"), for the banner.
+ reason: string;
+};
+
+// One saved chat = the persisted working conversation shape, plus a name, the
+// last-used model as INFO (never binding — resume under any model), and an
+// optional paused-sweep checkpoint.
+export type SavedChat = {
+ messages: UiMessage[];
+ contextOverride: string | null;
+ strictGrounding?: boolean;
+ detached?: boolean;
+ enrichments?: Record<string, { clock: string; seconds: number; text: string }[]>;
+ report?: string;
+ reportMode?: boolean;
+ // Sweep checkpoint (present only for a paused sweep).
+ checkpoint?: SweepCheckpoint | null;
+ // Info only — which provider/model produced this (NOT applied on load).
+ model?: string;
+ provider?: string;
+ // Epoch ms of the last save, passed in by the caller (scripts can't call
+ // Date.now()); used only for display ordering.
+ savedAt?: number;
+};
+
+export type SavedChatsState = {
+ v: 1;
+ items: Record<string, SavedChat>;
+ activeName: string | null;
+};
+
+export function emptySavedChats(): SavedChatsState {
+ return { v: VERSION, items: {}, activeName: null };
+}
+
+function isStringArray(v: unknown): v is string[] {
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
+}
+
+function parseCheckpoint(raw: unknown): SweepCheckpoint | null {
+ if (!raw || typeof raw !== "object") return null;
+ const r = raw as Record<string, unknown>;
+ if (typeof r.directive !== "string") return null;
+ if (!isStringArray(r.remainingSlugs)) return null;
+ const done = typeof r.done === "number" ? r.done : 0;
+ const total = typeof r.total === "number" ? r.total : 0;
+ const reason = typeof r.reason === "string" ? r.reason : "paused";
+ return { directive: r.directive, remainingSlugs: r.remainingSlugs.slice(), done, total, reason };
+}
+
+function parseChat(raw: unknown): SavedChat | null {
+ if (!raw || typeof raw !== "object") return null;
+ const r = raw as Record<string, unknown>;
+ // messages is the one required field; the rest are optional flags.
+ if (!Array.isArray(r.messages)) return null;
+ const chat: SavedChat = {
+ messages: r.messages as UiMessage[],
+ contextOverride:
+ typeof r.contextOverride === "string" ? r.contextOverride : null,
+ };
+ if (typeof r.strictGrounding === "boolean") chat.strictGrounding = r.strictGrounding;
+ if (typeof r.detached === "boolean") chat.detached = r.detached;
+ if (r.enrichments && typeof r.enrichments === "object" && !Array.isArray(r.enrichments)) {
+ chat.enrichments = r.enrichments as SavedChat["enrichments"];
+ }
+ if (typeof r.report === "string") chat.report = r.report;
+ if (typeof r.reportMode === "boolean") chat.reportMode = r.reportMode;
+ if (r.checkpoint !== undefined) chat.checkpoint = parseCheckpoint(r.checkpoint);
+ if (typeof r.model === "string") chat.model = r.model;
+ if (typeof r.provider === "string") chat.provider = r.provider;
+ if (typeof r.savedAt === "number") chat.savedAt = r.savedAt;
+ return chat;
+}
+
+function parseState(raw: string): SavedChatsState | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (!parsed || typeof parsed !== "object") return null;
+ const r = parsed as Record<string, unknown>;
+ if (r.v !== VERSION) return null;
+ const itemsRaw = r.items;
+ if (!itemsRaw || typeof itemsRaw !== "object" || Array.isArray(itemsRaw)) {
+ return null;
+ }
+ const items: Record<string, SavedChat> = {};
+ for (const [name, chatRaw] of Object.entries(itemsRaw as Record<string, unknown>)) {
+ if (!name) continue;
+ const chat = parseChat(chatRaw);
+ if (!chat) return null;
+ items[name] = chat;
+ }
+ const active =
+ typeof r.activeName === "string" && r.activeName in items
+ ? r.activeName
+ : null;
+ return { v: VERSION, items, activeName: active };
+}
+
+export function loadSavedChats(): SavedChatsState {
+ if (typeof window === "undefined") return emptySavedChats();
+ let raw: string | null = null;
+ try {
+ raw = window.localStorage.getItem(KEY);
+ } catch {
+ return emptySavedChats();
+ }
+ if (raw === null) return emptySavedChats();
+ const parsed = parseState(raw);
+ if (!parsed) {
+ // Reset on a malformed payload rather than trapping the UI in a broken state.
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ /* ignore */
+ }
+ return emptySavedChats();
+ }
+ return parsed;
+}
+
+export function saveSavedChats(next: SavedChatsState): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(KEY, JSON.stringify(next));
+ } catch {
+ /* quota / serialization errors are non-fatal */
+ }
+}
+
+// Deep-equal-ish dirty check between a saved chat and the current working state.
+// Compares the fields that a save round-trips (messages + report + grounding
+// flags + checkpoint) via canonical JSON, ignoring info-only fields (model,
+// savedAt) that don't change what a load restores.
+export function savedChatsEqual(a: SavedChat, b: SavedChat): boolean {
+ const norm = (c: SavedChat) =>
+ JSON.stringify({
+ messages: c.messages,
+ contextOverride: c.contextOverride ?? null,
+ strictGrounding: c.strictGrounding ?? true,
+ detached: c.detached ?? false,
+ enrichments: c.enrichments ?? {},
+ report: c.report ?? "",
+ reportMode: c.reportMode ?? false,
+ checkpoint: c.checkpoint ?? null,
+ });
+ return norm(a) === norm(b);
+}
diff --git a/export/app/ask/askNotify.ts b/export/app/ask/askNotify.ts
@@ -0,0 +1,43 @@
+// Optional PWA notifications for the whole-corpus sweep — a long run can finish
+// or pause (e.g. on a usage limit) while the user has stepped away, so a system
+// notification is a welcome nudge. Strictly best-effort and a graceful no-op
+// everywhere it doesn't apply:
+// • no Notification API → no-op;
+// • permission not granted → no-op (firing never prompts);
+// • the tab is focused → no-op (don't nag someone who's watching);
+// • not an installed/opt-in PWA → no permission is ever requested (the
+// request is gated on a registered service worker, which only PWA-shipping
+// sites have — see export/app/lib/mode.ts shipsPwa()).
+
+function supported(): boolean {
+ return typeof window !== "undefined" && "Notification" in window;
+}
+
+// Fire a sweep notification IF permission is granted and the tab is hidden (the
+// user has stepped away). Never prompts; never throws.
+export function notifySweep(title: string, body: string): void {
+ if (!supported() || Notification.permission !== "granted") return;
+ // Only nudge when the user isn't already looking at the tab.
+ if (typeof document !== "undefined" && document.visibilityState === "visible") {
+ return;
+ }
+ try {
+ new Notification(title, { body });
+ } catch {
+ /* some engines require notifications go through the SW registration; ignore */
+ }
+}
+
+// Ask for notification permission once, at the start of a sweep (a user gesture),
+// but ONLY on a PWA-shipping site — proxied by a registered service worker, which
+// a dumb (non-PWA) site never has. No-op if already decided or unsupported.
+export async function ensureSweepNotifyPermission(): Promise<void> {
+ if (!supported() || Notification.permission !== "default") return;
+ try {
+ const reg = await navigator.serviceWorker?.getRegistration?.();
+ if (!reg) return; // not an installed/opt-in PWA — don't prompt
+ await Notification.requestPermission();
+ } catch {
+ /* permission flow unavailable — no-op */
+ }
+}
diff --git a/export/app/ask/useAskChat.ts b/export/app/ask/useAskChat.ts
@@ -20,6 +20,7 @@ import {
type AgentMode,
} from "../lib/searchAgent";
import type { RetrievedVideo } from "../lib/askRetrieval";
+import { PausableError } from "../lib/nativeTools/shared";
import { buildDebugExport } from "../lib/askDebug";
import {
buildApiMessages,
@@ -30,6 +31,15 @@ import {
type SearchStep,
type UiMessage,
} from "../lib/askConversation";
+import {
+ loadSavedChats,
+ saveSavedChats,
+ savedChatsEqual,
+ type SavedChat,
+ type SavedChatsState,
+ type SweepCheckpoint,
+} from "./askChatStorage";
+import { ensureSweepNotifyPermission, notifySweep } from "./askNotify";
const K_PROVIDER = "ytdlp-tb:ai:provider";
const K_REMEMBER = "ytdlp-tb:ai:remember";
@@ -53,18 +63,6 @@ const SWEEP_CHUNK_SIZE = 10;
// Directive fallback when the composer is empty at sweep time.
export const DEFAULT_SWEEP_DIRECTIVE = "key claims & contradictions";
-// The effective batch size — SWEEP_CHUNK_SIZE, or a localStorage override (used
-// by e2e to force multiple batches over the small fixture set; also a legitimate
-// power-user tuning). Clamped to [1, 100].
-function sweepChunkSize(): number {
- try {
- const raw = Number(localStorage.getItem(K_SWEEPCHUNK));
- if (Number.isFinite(raw) && raw >= 1) return Math.min(100, Math.floor(raw));
- } catch {
- /* ignore */
- }
- return SWEEP_CHUNK_SIZE;
-}
// How many recent provider calls to retain for the debug export (ring buffer).
const DEBUG_TRACE_CAP = 8;
@@ -85,6 +83,9 @@ type PersistedConvo = {
// so a long research session's report survives reloads.
report?: string;
reportMode?: boolean;
+ // A paused sweep's checkpoint (remaining batches + directive), so a paused sweep
+ // survives a reload and can be resumed — even under a different model.
+ checkpoint?: SweepCheckpoint | null;
};
const NON_TERMINAL_PHASES = new Set(["gathering", "answering", "streaming"]);
@@ -135,9 +136,21 @@ export function useAskChat() {
const corpusError = summariesState.error?.message ?? null;
// The live "active search" this workspace shares — the chat auto-grounds in it.
- // getFullGrounding lazily builds the WHOLE matched set (caps lifted) for the
- // corpus sweep.
- const { liveGrounding, getFullGrounding } = useSearchSession();
+ // `activeGrounding` is the grounding for the ACTIVE mode (whole search ⇄
+ // selection); `getGrounding(target)` lazily builds a FULL-excerpt handoff over
+ // a target for the corpus sweep. The selection API + mode drive the palette.
+ const {
+ activeGrounding,
+ getGrounding,
+ capped,
+ groundingMode,
+ setGroundingMode,
+ effectiveGroundingMode,
+ selectedSlugs,
+ selectedCount,
+ toggleSelected,
+ clearSelection,
+ } = useSearchSession();
const [messages, setMessages] = useState<UiMessage[]>([]);
// A human-pruned context that seeds the conversation: prepended to the replayed
@@ -170,6 +183,23 @@ export function useAskChat() {
total: 0,
sections: 0,
});
+ // User-settable sweep batch size (how many result videos each batch folds into
+ // the report). Restored from the K_SWEEPCHUNK key on mount (which e2e/power-users
+ // also set directly). Bigger → fewer, heavier batches; smaller → more, lighter.
+ const [sweepBatch, setSweepBatchState] = useState(SWEEP_CHUNK_SIZE);
+ const sweepBatchRef = useRef(sweepBatch);
+ sweepBatchRef.current = sweepBatch;
+ // Set while a manual Pause has been requested; the sweep loop checkpoints and
+ // stops cleanly at the next batch boundary (vs. Stop, which aborts + discards).
+ const sweepPauseRef = useRef(false);
+ // A paused sweep's checkpoint (remaining batches + directive + reason). Non-null
+ // ⇒ the sweep paused and can be resumed. Persisted with the working convo AND
+ // any saved chat, so a pause survives a reload or a model switch.
+ const [sweepCheckpoint, setSweepCheckpoint] = useState<SweepCheckpoint | null>(null);
+ // Saved chats library: named conversation copies + which one the working chat
+ // derives from (for the dirty dot). The live "working" chat stays in K_CONVO.
+ const [savedChats, setSavedChats] = useState<Record<string, SavedChat>>({});
+ const [activeChatName, setActiveChatName] = useState<string | null>(null);
// Max output tokens for the answer phase (user-tunable; default 8192). Raising
// it lets long reports finish instead of truncating at the model's output cap.
const [maxAnswerTokens, setMaxAnswerTokensState] = useState(DEFAULT_ANSWER_TOKENS);
@@ -177,20 +207,21 @@ export function useAskChat() {
// for the debug export. Runtime-only (not persisted); trimmed to DEBUG_TRACE_CAP.
const debugTraceRef = useRef<DebugCall[]>([]);
- // The effective pinned grounding = the live search, overlaid with any per-video
- // "Load context" enrichments, unless the user detached.
+ // The effective pinned grounding = the ACTIVE-mode grounding (whole search or
+ // the hand-picked selection), overlaid with any per-video "Load context"
+ // enrichments, unless the user detached.
const pinned = useMemo<SearchHandoff | null>(() => {
- if (detached || !liveGrounding) return null;
- if (Object.keys(enrichments).length === 0) return liveGrounding;
+ if (detached || !activeGrounding) return null;
+ if (Object.keys(enrichments).length === 0) return activeGrounding;
return {
- ...liveGrounding,
- videos: liveGrounding.videos.map((v) =>
+ ...activeGrounding,
+ videos: activeGrounding.videos.map((v) =>
enrichments[v.key]
? { ...v, snippets: mergeSnippets(v.snippets, enrichments[v.key], 30) }
: v,
),
};
- }, [detached, liveGrounding, enrichments]);
+ }, [detached, activeGrounding, enrichments]);
// Per-video key → true while its "Load context" fetch is in flight.
const [expanding, setExpanding] = useState<Record<string, boolean>>({});
// Per-video key → true when its last "Load context" fetch failed (so the panel
@@ -257,6 +288,10 @@ export function useAskChat() {
if (Number.isFinite(savedMax) && savedMax >= MAX_ANSWER_MIN) {
setMaxAnswerTokensState(Math.min(MAX_ANSWER_MAX, savedMax));
}
+ const savedBatch = Number(localStorage.getItem(K_SWEEPCHUNK));
+ if (Number.isFinite(savedBatch) && savedBatch >= 1) {
+ setSweepBatchState(Math.min(100, Math.floor(savedBatch)));
+ }
loadProviderCreds(p, rememberSaved);
const rawConvo = localStorage.getItem(K_CONVO);
if (rawConvo) {
@@ -278,7 +313,14 @@ export function useAskChat() {
if (typeof parsed.reportMode === "boolean") {
setReportModeState(parsed.reportMode);
}
+ if (parsed.checkpoint && typeof parsed.checkpoint === "object") {
+ setSweepCheckpoint(parsed.checkpoint);
+ }
}
+ // Saved chats library (named copies + active pointer).
+ const chats = loadSavedChats();
+ setSavedChats(chats.items);
+ setActiveChatName(chats.activeName);
} catch {
/* storage unavailable / corrupt */
}
@@ -299,7 +341,8 @@ export function useAskChat() {
!detached &&
!hasEnrichments &&
!report &&
- !reportMode
+ !reportMode &&
+ !sweepCheckpoint
) {
localStorage.removeItem(K_CONVO);
setStorageWarning(false);
@@ -319,6 +362,7 @@ export function useAskChat() {
enrichments,
report,
reportMode,
+ checkpoint: sweepCheckpoint,
})
) {
setStorageWarning(msgs.length < messages.length);
@@ -350,6 +394,7 @@ export function useAskChat() {
enrichments,
report,
reportMode,
+ sweepCheckpoint,
]);
function loadProviderCreds(p: Provider, rememberOn: boolean) {
@@ -425,6 +470,16 @@ export function useAskChat() {
}
}
+ function setSweepBatch(n: number) {
+ const clamped = Math.max(1, Math.min(100, Math.floor(n) || SWEEP_CHUNK_SIZE));
+ setSweepBatchState(clamped);
+ try {
+ localStorage.setItem(K_SWEEPCHUNK, String(clamped));
+ } catch {
+ /* ignore */
+ }
+ }
+
// Assemble a redacted debug snapshot (chat state + recent raw provider calls)
// for offline debugging, and hand it back as a pretty JSON string. The API key
// is never included.
@@ -701,108 +756,124 @@ export function useAskChat() {
});
}, [input, busy, apiKey, summariesReady, provider, model, remember, runTurn]);
- // Whole-corpus "sweep": read the ENTIRE matched set in batches, folding each
- // batch's findings into the running report and discarding its raw excerpts, so
- // a large multi-channel result set can be reasoned over without blowing the
- // context. `mode` "fresh" starts a new report (clears the current one); "extend"
- // folds into the existing report. Native-only (report mode); one AbortController
- // for the whole sweep, so a single Stop cancels it and keeps the partial report.
- const runAccumulationReport = useCallback(
- async ({
- directive,
- mode,
- }: {
+ // The shared batch loop behind both a fresh/extend sweep AND a resume. It folds
+ // `remaining` videos into `startReport` batch by batch, updating the sweep
+ // message + progress strip. Three exits:
+ // • completed → the "Built a report…" summary, checkpoint cleared;
+ // • STOP → abort (the ✕): keep the partial report, DISCARD the
+ // checkpoint (a deliberate give-up);
+ // • PAUSE → a manual Pause OR a rate-limit/model warning (PausableError):
+ // checkpoint the remaining batches + directive so the run can
+ // resume later — even after a reload or a model switch.
+ const driveSweep = useCallback(
+ async (args: {
+ remaining: RetrievedVideo[];
+ startReport: string;
directive: string;
- mode: "fresh" | "extend";
+ // Batches already folded before this run (0 for a fresh sweep; the paused
+ // count for a resume) — keeps "Batch i of n" continuous across a pause.
+ doneOffset: number;
+ assistantIndex: number;
+ resultLabel: string;
}) => {
- if (
- busy ||
- sendingRef.current ||
- !apiKey.trim() ||
- !summariesReady ||
- !reportModeAvailable
- ) {
- return;
- }
- const full = getFullGrounding();
- if (!full || full.videos.length === 0) return;
-
- sendingRef.current = true;
- persistKey(provider, apiKey, model, remember);
-
- const videos = full.videos as RetrievedVideo[];
- const batches = chunk(videos, sweepChunkSize());
- const total = batches.length;
- const cleanDirective = directive.trim() || DEFAULT_SWEEP_DIRECTIVE;
+ const { remaining, startReport, directive, doneOffset, assistantIndex, resultLabel } =
+ args;
+ const batches = chunk(remaining, sweepBatchRef.current);
+ const total = doneOffset + batches.length;
const mdl = model.trim() || PROVIDERS[provider].defaultModel;
setBusy(true);
setSweeping(true);
- setSweepProgress({ done: 0, total, sections: 0 });
- // Turn the report mode on so the ReportPanel is visible and subsequent
- // chat turns keep maintaining the report the sweep just built.
setReportModeState(true);
+ sweepPauseRef.current = false;
const ac = new AbortController();
abortRef.current = ac;
- // A sweep "assistant" message tracks the sweep in the transcript; it
- // resolves to a summary (or a stopped/error line) when the sweep ends.
- const assistantIndex = messagesRef.current.length;
- setMessages((prev) => [
- ...prev,
- {
- role: "assistant",
- content: `Sweeping ${videos.length} result${videos.length === 1 ? "" : "s"}…`,
- phase: "gathering",
- searchSteps: [],
- },
- ]);
-
- let report = mode === "fresh" ? "" : reportRef.current;
- if (mode === "fresh") setReport("");
+ let report = startReport;
let sections = 0;
- let done = 0;
+ let done = doneOffset;
+ setSweepProgress({ done, total, sections });
+
+ // Checkpoint the batches from `i` onward (flattened to slugs) so a resume can
+ // rebuild the grounding for exactly what's left, then surface a paused line.
+ const pauseAt = (i: number, reason: string) => {
+ const remainingSlugs = batches.slice(i).flat().map((v) => v.key);
+ setSweepCheckpoint({ directive, remainingSlugs, done, total, reason });
+ notifySweep(
+ "Sweep paused",
+ `${reason} — ${remainingSlugs.length} result${remainingSlugs.length === 1 ? "" : "s"} left. Resume when it clears.`,
+ );
+ patchAt(assistantIndex, (m) => ({
+ ...m,
+ phase: "stopped",
+ content:
+ `Paused (${reason}) at ${done} of ${total} batch${total === 1 ? "" : "es"} — ` +
+ `resume when ready. The partial report is saved.`,
+ }));
+ };
try {
for (let i = 0; i < batches.length; i++) {
- if (ac.signal.aborted) {
- throw new DOMException("Aborted", "AbortError");
+ if (ac.signal.aborted) throw new DOMException("Aborted", "AbortError");
+ // A manual Pause requested between batches → checkpoint + stop cleanly.
+ if (sweepPauseRef.current) {
+ pauseAt(i, "paused");
+ return;
}
setReportUpdating(true);
- const r = await runReportChunk({
- provider,
- apiKey: apiKey.trim(),
- model: mdl,
- directive: cleanDirective,
- videos: batches[i],
- index: i + 1,
- count: total,
- report,
- aliases,
- signal: ac.signal,
- onEvent: (e) => {
- if (e.type === "report_update") {
- sections += 1;
- setSweepProgress((p) => ({ ...p, sections }));
- }
- },
- onDebug: pushDebug,
- });
+ let r;
+ try {
+ r = await runReportChunk({
+ provider,
+ apiKey: apiKey.trim(),
+ model: mdl,
+ directive,
+ videos: batches[i],
+ index: done + 1,
+ count: total,
+ report,
+ aliases,
+ signal: ac.signal,
+ onEvent: (e) => {
+ if (e.type === "report_update") {
+ sections += 1;
+ setSweepProgress((p) => ({ ...p, sections }));
+ }
+ },
+ onDebug: pushDebug,
+ });
+ } catch (e) {
+ // A rate-limit / quota (429, surfaced as PausableError) is resumable —
+ // pause + checkpoint rather than lose the whole run.
+ if (e instanceof PausableError) {
+ pauseAt(i, e.reason);
+ return;
+ }
+ throw e;
+ }
report = r.report;
setReport(report);
- done = i + 1;
+ done += 1;
setSweepProgress({ done, total, sections });
}
+ // Completed cleanly → no checkpoint to resume.
+ setSweepCheckpoint(null);
+ notifySweep(
+ "Sweep finished",
+ `${resultLabel} folded into one report.`,
+ );
patchAt(assistantIndex, (m) => ({
...m,
phase: "done",
content:
- `Built a report from ${videos.length} result${videos.length === 1 ? "" : "s"} ` +
+ `Built a report from ${resultLabel} ` +
`across ${total} batch${total === 1 ? "" : "es"}. See the Report panel.`,
}));
} catch (e) {
const err = e as Error;
if (err.name === "AbortError") {
+ // Stop is a deliberate give-up → discard the checkpoint, keep the report.
+ setSweepCheckpoint(null);
patchAt(assistantIndex, (m) => ({
...m,
phase: "stopped",
@@ -820,24 +891,162 @@ export function useAskChat() {
setBusy(false);
setSweeping(false);
setReportUpdating(false);
+ sweepPauseRef.current = false;
sendingRef.current = false;
abortRef.current = null;
}
},
+ [provider, apiKey, model, aliases, pushDebug],
+ );
+
+ // Whole-corpus "sweep": read the active target (whole search or the hand-picked
+ // selection) in batches, folding each batch's findings into the running report
+ // and discarding its raw excerpts. `mode` "fresh" starts a new report; "extend"
+ // folds into the existing one. Native-only (report mode).
+ const runAccumulationReport = useCallback(
+ async ({
+ directive,
+ mode,
+ }: {
+ directive: string;
+ mode: "fresh" | "extend";
+ }) => {
+ if (
+ busy ||
+ sendingRef.current ||
+ !apiKey.trim() ||
+ !summariesReady ||
+ !reportModeAvailable
+ ) {
+ return;
+ }
+ const full = getGrounding(effectiveGroundingMode);
+ if (!full || full.videos.length === 0) return;
+
+ sendingRef.current = true;
+ persistKey(provider, apiKey, model, remember);
+ // Best-effort: on a PWA site, offer to notify when this (possibly long) run
+ // finishes or pauses. No-op on non-PWA sites / if already decided.
+ void ensureSweepNotifyPermission();
+
+ const videos = full.videos as RetrievedVideo[];
+ const cleanDirective = directive.trim() || DEFAULT_SWEEP_DIRECTIVE;
+ const resultLabel = `${videos.length} result${videos.length === 1 ? "" : "s"}`;
+
+ // A sweep "assistant" message tracks the run in the transcript.
+ const assistantIndex = messagesRef.current.length;
+ setMessages((prev) => [
+ ...prev,
+ {
+ role: "assistant",
+ content: `Sweeping ${videos.length} result${videos.length === 1 ? "" : "s"}…`,
+ phase: "gathering",
+ searchSteps: [],
+ },
+ ]);
+
+ const startReport = mode === "fresh" ? "" : reportRef.current;
+ if (mode === "fresh") setReport("");
+
+ await driveSweep({
+ remaining: videos,
+ startReport,
+ directive: cleanDirective,
+ doneOffset: 0,
+ assistantIndex,
+ resultLabel,
+ });
+ },
[
busy,
apiKey,
summariesReady,
reportModeAvailable,
- getFullGrounding,
+ getGrounding,
+ effectiveGroundingMode,
provider,
model,
remember,
- aliases,
- pushDebug,
+ driveSweep,
],
);
+ // Resume a paused sweep from its checkpoint. Rebuilds the grounding for the
+ // remaining slugs from the CURRENT active target (so it works after a reload,
+ // and under any provider/model — the checkpoint carries no key), then continues
+ // folding into the saved report from where it paused.
+ const resumeSweep = useCallback(async () => {
+ const cp = sweepCheckpoint;
+ if (
+ !cp ||
+ busy ||
+ sendingRef.current ||
+ !apiKey.trim() ||
+ !summariesReady ||
+ !reportModeAvailable
+ ) {
+ return;
+ }
+ const full =
+ getGrounding(effectiveGroundingMode) ?? getGrounding("search");
+ const bySlug = new Map((full?.videos ?? []).map((v) => [v.key, v]));
+ const remaining = cp.remainingSlugs
+ .map((s) => bySlug.get(s))
+ .filter((v): v is RetrievedVideo => Boolean(v));
+ if (remaining.length === 0) {
+ // The grounding changed out from under the checkpoint — nothing to resume.
+ setSweepCheckpoint(null);
+ return;
+ }
+
+ sendingRef.current = true;
+ persistKey(provider, apiKey, model, remember);
+ const resultLabel = `${remaining.length} remaining result${remaining.length === 1 ? "" : "s"}`;
+ const assistantIndex = messagesRef.current.length;
+ setMessages((prev) => [
+ ...prev,
+ {
+ role: "assistant",
+ content: `Resuming the sweep — ${remaining.length} result${remaining.length === 1 ? "" : "s"} left…`,
+ phase: "gathering",
+ searchSteps: [],
+ },
+ ]);
+
+ await driveSweep({
+ remaining,
+ startReport: reportRef.current,
+ directive: cp.directive,
+ doneOffset: cp.done,
+ assistantIndex,
+ resultLabel,
+ });
+ }, [
+ sweepCheckpoint,
+ busy,
+ apiKey,
+ summariesReady,
+ reportModeAvailable,
+ getGrounding,
+ effectiveGroundingMode,
+ provider,
+ model,
+ remember,
+ driveSweep,
+ ]);
+
+ // Request a courtesy pause: the sweep loop checkpoints + stops at the next batch
+ // boundary (the in-flight batch finishes first, so nothing is wasted).
+ const pauseSweep = useCallback(() => {
+ if (!sweeping) return;
+ sweepPauseRef.current = true;
+ }, [sweeping]);
+
+ // Discard a paused sweep's checkpoint (give up on resuming; the report stays).
+ const discardCheckpoint = useCallback(() => {
+ setSweepCheckpoint(null);
+ }, []);
+
// Re-run a failed assistant turn. Reuses the grounding captured before the
// answer stream failed (no re-search); falls back to a full turn otherwise.
const retry = useCallback(
@@ -902,6 +1111,20 @@ export function useAskChat() {
setStrictGroundingState(true);
setReport("");
setReportModeState(false);
+ setSweepCheckpoint(null);
+ // A new chat is a blank working conversation not derived from any saved chat —
+ // clear the active pointer in state AND in the persisted store, so a reload
+ // doesn't reattach to the previously-active saved chat.
+ setActiveChatName(null);
+ try {
+ const cur = loadSavedChats();
+ if (cur.activeName !== null) {
+ cur.activeName = null;
+ saveSavedChats(cur);
+ }
+ } catch {
+ /* ignore */
+ }
};
// Detach from the live search → ask a free-form question (the AI searches on
@@ -914,8 +1137,9 @@ export function useAskChat() {
if (busy) return;
setDetached(false);
};
- // Whether there's a live search available to ground in (regardless of detach).
- const hasLiveSearch = !!liveGrounding;
+ // Whether there's a grounding available to ground in (regardless of detach) —
+ // the whole search or a hand-picked selection.
+ const hasLiveSearch = !!activeGrounding;
const setStrictGrounding = (on: boolean) => setStrictGroundingState(on);
@@ -994,6 +1218,136 @@ export function useAskChat() {
setContextOverride(null);
};
+ // ── Saved chats library ──
+ // The current working conversation as a SavedChat snapshot (what a save writes).
+ const workingChat = useMemo<SavedChat>(
+ () => ({
+ messages,
+ contextOverride,
+ strictGrounding,
+ detached,
+ enrichments,
+ report,
+ reportMode,
+ checkpoint: sweepCheckpoint,
+ provider,
+ model: model.trim() || PROVIDERS[provider].defaultModel,
+ }),
+ [
+ messages,
+ contextOverride,
+ strictGrounding,
+ detached,
+ enrichments,
+ report,
+ reportMode,
+ sweepCheckpoint,
+ provider,
+ model,
+ ],
+ );
+
+ // Dirty when the working chat diverges from the saved copy it derives from
+ // (drives the warning dot next to the Save action).
+ const chatDirty =
+ activeChatName != null &&
+ savedChats[activeChatName] != null &&
+ !savedChatsEqual(savedChats[activeChatName], workingChat);
+
+ // One funnel for every mutation of the saved-chats store (mirrors the search
+ // profiles' writeStorage): load current → mutate → persist → reflect in state.
+ const writeChats = useCallback(
+ (mut: (s: SavedChatsState) => void) => {
+ const current: SavedChatsState = {
+ v: 1,
+ items: { ...savedChats },
+ activeName: activeChatName,
+ };
+ mut(current);
+ saveSavedChats(current);
+ setSavedChats(current.items);
+ setActiveChatName(current.activeName);
+ },
+ [savedChats, activeChatName],
+ );
+
+ const saveChat = useCallback(() => {
+ if (!activeChatName) return;
+ const snap: SavedChat = { ...workingChat, savedAt: Date.now() };
+ writeChats((s) => {
+ s.items[activeChatName] = snap;
+ });
+ }, [activeChatName, workingChat, writeChats]);
+
+ const saveChatAs = useCallback(() => {
+ if (typeof window === "undefined") return;
+ const raw = window.prompt("Save this chat as:");
+ if (raw == null) return;
+ const name = raw.trim();
+ if (!name) return;
+ if (Object.prototype.hasOwnProperty.call(savedChats, name)) {
+ window.alert(`A chat named "${name}" already exists.`);
+ return;
+ }
+ const snap: SavedChat = { ...workingChat, savedAt: Date.now() };
+ writeChats((s) => {
+ s.items[name] = snap;
+ s.activeName = name;
+ });
+ }, [savedChats, workingChat, writeChats]);
+
+ const renameChat = useCallback(() => {
+ if (typeof window === "undefined" || !activeChatName) return;
+ const raw = window.prompt("Rename chat:", activeChatName);
+ if (raw == null) return;
+ const next = raw.trim();
+ if (!next || next === activeChatName) return;
+ if (Object.prototype.hasOwnProperty.call(savedChats, next)) {
+ window.alert(`A chat named "${next}" already exists.`);
+ return;
+ }
+ writeChats((s) => {
+ const copy: Record<string, SavedChat> = {};
+ for (const [k, v] of Object.entries(s.items)) {
+ copy[k === activeChatName ? next : k] = v;
+ }
+ s.items = copy;
+ s.activeName = next;
+ });
+ }, [activeChatName, savedChats, writeChats]);
+
+ const deleteChat = useCallback(() => {
+ if (typeof window === "undefined" || !activeChatName) return;
+ if (!window.confirm(`Delete chat "${activeChatName}"?`)) return;
+ writeChats((s) => {
+ delete s.items[activeChatName];
+ s.activeName = null;
+ });
+ }, [activeChatName, writeChats]);
+
+ // Load a saved chat as the working conversation (replaces it). Resumes under the
+ // CURRENT provider/model — the saved model is info only — so a paused sweep can
+ // be resumed after a model switch.
+ const loadChat = useCallback(
+ (name: string) => {
+ if (busy) return;
+ const chat = savedChats[name];
+ if (!chat) return;
+ setMessages((chat.messages ?? []).map(normalizeRestoredMessage));
+ setContextOverride(chat.contextOverride ?? null);
+ setStrictGroundingState(chat.strictGrounding ?? true);
+ setDetached(chat.detached ?? false);
+ setEnrichments(chat.enrichments ?? {});
+ setReport(chat.report ?? "");
+ setReportModeState(chat.reportMode ?? false);
+ setSweepCheckpoint(chat.checkpoint ?? null);
+ writeChats((s) => {
+ s.activeName = name;
+ });
+ },
+ [busy, savedChats, writeChats],
+ );
+
return {
// provider settings
provider,
@@ -1021,12 +1375,32 @@ export function useAskChat() {
sweep: runAccumulationReport,
sweeping,
sweepProgress,
+ // configurable batch size
+ sweepBatch,
+ setSweepBatch,
+ // pausable sweep: a checkpoint (non-null ⇒ paused/resumable) + controls.
+ sweepCheckpoint,
+ sweepPaused: sweepCheckpoint != null && !sweeping,
+ resumeSweep,
+ pauseSweep,
+ discardCheckpoint,
// Native-tool-capable → the sweep can run (report mode is available).
canSweep: reportModeAvailable,
- // The full matched-set size (before any cap) + whether the search itself was
- // capped, so the panel can say "Build from all N" vs "Sweep the first N".
- totalGroundingVideos: liveGrounding?.totalVideos ?? 0,
- sweepCapped: liveGrounding?.truncated ?? false,
+ // The active target's size (before any cap) + whether the sweep can't cover
+ // everything. Selection is a curated subset (never "capped"); only a whole-
+ // search sweep is bounded by the engine's true hit cap (`capped`), NOT by
+ // liveGrounding.truncated (which trips merely at >100 videos even though the
+ // sweep covers every discovered video).
+ totalGroundingVideos: activeGrounding?.totalVideos ?? 0,
+ sweepCapped: effectiveGroundingMode === "search" ? capped : false,
+ // ── Grounding palette (mode toggle + selection) ──
+ groundingMode,
+ setGroundingMode,
+ effectiveGroundingMode,
+ selectedCount,
+ selectedSlugs,
+ toggleSelected,
+ clearSelection,
// answer length + debug export
maxAnswerTokens,
setMaxAnswerTokens,
@@ -1046,6 +1420,15 @@ export function useAskChat() {
retry,
editUserMessage,
storageWarning,
+ // saved chats library
+ savedChats,
+ activeChatName,
+ chatDirty,
+ saveChat,
+ saveChatAs,
+ renameChat,
+ deleteChat,
+ loadChat,
// context editing
contextText,
contextOverride,
diff --git a/export/app/lib/nativeTools/shared.ts b/export/app/lib/nativeTools/shared.ts
@@ -134,6 +134,20 @@ export type ParsedToolCall = {
// and retries the turn with the scripted transport.
export class ToolsUnavailableError extends Error {}
+// Thrown when a request fails in a way that is transient and RESUMABLE rather
+// than fatal — a rate-limit / quota hit (HTTP 429). Mid-sweep the driver catches
+// this and PAUSES (checkpoints the report + remaining batches) instead of
+// aborting, so the run can resume once the limit resets. `reason` is a short
+// human string for the paused banner.
+export class PausableError extends Error {
+ reason: string;
+ constructor(message: string, reason: string) {
+ super(message);
+ this.name = "PausableError";
+ this.reason = reason;
+ }
+}
+
export function abortError(): DOMException {
return new DOMException("Aborted", "AbortError");
}
@@ -174,6 +188,11 @@ export async function postJson(
if (res.status === 400 || res.status === 404) {
throw new ToolsUnavailableError(msg);
}
+ // Rate-limit / quota → resumable. Let the sweep driver pause + checkpoint
+ // rather than surface a hard error and lose the run.
+ if (res.status === 429) {
+ throw new PausableError(msg, "usage limit");
+ }
throw new Error(msg);
}
const json = await res.json();
diff --git a/export/e2e/ask-chat.spec.ts b/export/e2e/ask-chat.spec.ts
@@ -677,8 +677,13 @@ test.describe("ask chat", () => {
await expect(page.getByText(/Expanded answer/)).toBeVisible();
// A gather search ran, and the grounded fixture video is among the citations.
expect(gatherCalls).toBeGreaterThan(0);
+ // Scope to the chat pane: the results rail (mounted but hidden in Chat view)
+ // also renders this title, so an unscoped .first() would match the hidden one.
await expect(
- page.getByText("Transcript only", { exact: false }).first(),
+ page
+ .getByTestId("pane-chat")
+ .getByText("Transcript only", { exact: false })
+ .first(),
).toBeVisible();
});
diff --git a/export/e2e/ask-workspace.spec.ts b/export/e2e/ask-workspace.spec.ts
@@ -0,0 +1,318 @@
+import { expect, test, type Page } from "@playwright/test";
+import { installRoutes } from "./helpers";
+
+// The integrated grounding workspace: pick the AI target (whole search ⇄ a
+// hand-picked selection), the [Search|Chat|Split] view modes, saved chats, and a
+// pausable/resumable sweep. Retrieval runs client-side over the mocked fixtures;
+// the AI provider is mocked here (native tool-calling), reusing the same SSE/
+// tool_use shapes as ask-chat.spec.
+
+type SLeaf = { k: "l"; q: string; s: "transcripts" | "chat" | "metadata" };
+type SGroup = { k: "g"; o: "AND" | "OR"; c: (SLeaf | SGroup)[] };
+const qt = (root: SGroup) => encodeURIComponent(JSON.stringify(root));
+
+// transcripts:"alpha" matches all three fixtures.
+const ALL_TREE: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [{ k: "l", q: "alpha", s: "transcripts" }],
+};
+
+const CORS = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-headers": "*",
+ "access-control-allow-methods": "*",
+};
+
+function sse(text: string): string {
+ return (
+ `data: ${JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text } })}\n\n` +
+ `data: ${JSON.stringify({ type: "message_stop" })}\n\n`
+ );
+}
+function toolUse(name: string, input: Record<string, unknown>) {
+ return JSON.stringify({
+ content: [{ type: "tool_use", id: "tu_1", name, input }],
+ stop_reason: "tool_use",
+ });
+}
+
+// A normal chat mock: native gather searches once then finishes; answers stream
+// Markdown, echoing which grounded titles reached the answer request so a test can
+// assert WHAT the turn was grounded in.
+async function mockChat(page: Page, answerBodies: string[] = []) {
+ await page.route("https://api.anthropic.com/**", async (route) => {
+ if (route.request().method() === "OPTIONS") {
+ await route.fulfill({ status: 204, headers: CORS });
+ return;
+ }
+ const body = route.request().postDataJSON() as {
+ system?: string;
+ tools?: unknown[];
+ messages?: { role: string; content: unknown }[];
+ };
+ const system = body.system ?? "";
+ if (system.includes("Markdown")) {
+ answerBodies.push(JSON.stringify(body.messages ?? []));
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "text/event-stream" },
+ body: sse("Grounded answer [1]"),
+ });
+ return;
+ }
+ if (Array.isArray(body.tools)) {
+ const msgs = body.messages ?? [];
+ const lastUser = [...msgs].reverse().find((m) => m.role === "user");
+ const isToolResult = Array.isArray(lastUser?.content);
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "application/json" },
+ body: isToolResult ? toolUse("finish", {}) : toolUse("finish", {}),
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "text/event-stream" },
+ body: sse("DONE"),
+ });
+ });
+}
+
+async function keyIn(page: Page, mode: "Scripted" | "Native tools" = "Native tools") {
+ const key = page.locator('input[placeholder^="sk-ant"]');
+ // Clear first: a remembered key may already be restored (e.g. after a reload),
+ // and a bare fill() would append to it.
+ await key.clear();
+ await key.fill("sk-ant-test");
+ await expect(key).toHaveValue("sk-ant-test");
+ await page.getByRole("button", { name: mode }).click();
+}
+
+// Run a real search on `/`, wait for the cards, cross to the chat.
+async function searchThenChat(page: Page, cards: number) {
+ await page.goto(`/?qt=${qt(ALL_TREE)}`);
+ await expect(page.locator("[data-card-header]")).toHaveCount(cards, {
+ timeout: 15_000,
+ });
+ await page.getByTestId("workspace-nav").getByRole("link", { name: "Chat" }).click();
+}
+
+test.describe("grounding workspace", () => {
+ test("selecting a subset grounds the chat in exactly those videos", async ({
+ page,
+ }) => {
+ await installRoutes(page);
+ const answerBodies: string[] = [];
+ await mockChat(page, answerBodies);
+ await page.goto(`/?qt=${qt(ALL_TREE)}`);
+ await expect(page.locator("[data-card-header]")).toHaveCount(3, { timeout: 15_000 });
+
+ // Select 2 of the 3 results via the per-card checkboxes.
+ await page.getByRole("checkbox", { name: /Transcript only/ }).check();
+ await page.getByRole("checkbox", { name: /Large live chat/ }).check();
+ await expect(page.getByTestId("selection-toolbar")).toContainText("2 selected");
+
+ // Cross to the chat — it auto-grounds; flip the palette to Selection.
+ await page.getByTestId("workspace-nav").getByRole("link", { name: "Chat" }).click();
+ await keyIn(page);
+ await page
+ .getByTestId("grounding-mode")
+ .getByRole("tab", { name: /Selection/ })
+ .click();
+
+ // The palette + panel report the 2-video selection, shown as chips.
+ await expect(page.getByTestId("grounding-palette")).toContainText(
+ "Grounded in 2 selected videos",
+ );
+ const chips = page.getByTestId("selection-chips");
+ await expect(chips).toContainText("Transcript only");
+ await expect(chips).toContainText("Large live chat");
+ await expect(chips).not.toContainText("Small live chat");
+
+ // A strict ask grounds ONLY in the 2 selected — the answer request carries
+ // their titles but not the deselected "Small live chat".
+ await page.getByPlaceholder(/Ask about the transcripts/).fill("summarize these");
+ await page.getByRole("button", { name: "Ask", exact: true }).click();
+ await expect(page.getByText(/Grounded answer/)).toBeVisible();
+ const last = answerBodies[answerBodies.length - 1];
+ expect(last).toContain("Transcript only");
+ expect(last).toContain("Large live chat");
+ expect(last).not.toContain("Small live chat");
+ });
+
+ test("a per-card Ask grounds in that one video", async ({ page }) => {
+ await installRoutes(page);
+ await mockChat(page);
+ await page.goto(`/?qt=${qt(ALL_TREE)}`);
+ await expect(page.locator("[data-card-header]")).toHaveCount(3, { timeout: 15_000 });
+
+ // Click "Ask" on the Small live chat card → navigates to the chat, grounded
+ // in exactly that one video.
+ await page
+ .locator("[data-card-header]", { hasText: "Small live chat" })
+ .getByRole("button", { name: "Ask" })
+ .click();
+ await expect(page).toHaveURL(/\/ask\/?$/);
+ await keyIn(page);
+ await expect(page.getByTestId("grounding-palette")).toContainText(
+ "Grounded in 1 selected video",
+ );
+ await expect(page.getByTestId("selection-chips")).toContainText("Small live chat");
+ });
+
+ test("the [Search|Chat|Split] control fullscreens either pane and degrades to tabs when narrow", async ({
+ page,
+ }) => {
+ await installRoutes(page);
+ await mockChat(page);
+ await page.goto(`/?qt=${qt(ALL_TREE)}`);
+ await expect(page.locator("[data-card-header]").first()).toBeVisible({ timeout: 15_000 });
+
+ const nav = page.getByTestId("workspace-nav");
+ const searchPane = page.getByTestId("pane-search");
+ const chatPane = page.getByTestId("pane-chat");
+
+ // Search view (route `/`): results visible, chat hidden.
+ await expect(searchPane).toBeVisible();
+ await expect(chatPane).toBeHidden();
+
+ // Split (wide viewport) → BOTH panes visible side by side.
+ await nav.getByTestId("view-split").click();
+ await expect(searchPane).toBeVisible();
+ await expect(chatPane).toBeVisible();
+
+ // Chat → chat pane fullscreen, search hidden.
+ await nav.getByRole("link", { name: "Chat" }).click();
+ await expect(chatPane).toBeVisible();
+ await expect(searchPane).toBeHidden();
+
+ // Narrow viewport → Split isn't offered (degrades to Search ⇄ Chat tabs).
+ await page.setViewportSize({ width: 600, height: 900 });
+ await expect(nav.getByTestId("view-split")).toHaveCount(0);
+ await expect(nav.getByRole("link", { name: "Search" })).toBeVisible();
+ await expect(nav.getByRole("link", { name: "Chat" })).toBeVisible();
+ });
+
+ test("save a chat, New chat, reload, then load it → the conversation is restored", async ({
+ page,
+ }) => {
+ await installRoutes(page);
+ await mockChat(page);
+ await searchThenChat(page, 3);
+ await keyIn(page);
+
+ // Produce an answer, then Save as… (browser prompt → "research").
+ await page.getByPlaceholder(/Ask about the transcripts/).fill("what happened");
+ await page.getByRole("button", { name: "Ask", exact: true }).click();
+ await expect(page.getByText(/Grounded answer/)).toBeVisible();
+
+ page.once("dialog", (d) => d.accept("research"));
+ await page.getByTestId("saved-chats-row").getByRole("button", { name: "Save as…" }).click();
+ await expect(page.getByTestId("saved-chat-select")).toHaveValue("research");
+
+ // New chat clears the working conversation.
+ await page.getByRole("button", { name: "New chat" }).click();
+ await expect(page.getByText(/Grounded answer/)).toHaveCount(0);
+
+ // Reload — the saved chat is still in the library; load it → restored.
+ await page.waitForTimeout(400);
+ await page.reload();
+ await keyIn(page);
+ await page
+ .getByTestId("saved-chat-select")
+ .selectOption("research");
+ await expect(page.getByText(/Grounded answer/)).toBeVisible();
+ await expect(page.getByText("what happened")).toBeVisible();
+ });
+
+ test("a 429 mid-sweep PAUSES (not errors); Resume — even after a model switch — finishes it", async ({
+ page,
+ }) => {
+ await installRoutes(page);
+ // Force batch size 2 so the 3 fixtures span 2 batches.
+ await page.addInitScript(() => localStorage.setItem("ytdlp-tb:ai:sweepchunk", "2"));
+
+ let batch2Attempts = 0;
+ await page.route("https://api.anthropic.com/**", async (route) => {
+ if (route.request().method() === "OPTIONS") {
+ await route.fulfill({ status: 204, headers: CORS });
+ return;
+ }
+ const body = route.request().postDataJSON() as {
+ system?: string;
+ tools?: unknown[];
+ messages?: { role: string; content: unknown }[];
+ };
+ const system = body.system ?? "";
+ const msgs = body.messages ?? [];
+ const lastUser = [...msgs].reverse().find((m) => m.role === "user");
+ const isToolResult = Array.isArray(lastUser?.content);
+ const text = typeof lastUser?.content === "string" ? lastUser.content : "";
+ if (Array.isArray(body.tools) && /running report/.test(system)) {
+ const batch = /Batch (\d+) of/.exec(text)?.[1] ?? "1";
+ // Batch 2's FIRST attempt hits a rate limit (429) → pause. Its later
+ // (resumed) attempt succeeds.
+ if (batch === "2") {
+ batch2Attempts += 1;
+ if (batch2Attempts === 1) {
+ await route.fulfill({
+ status: 429,
+ headers: { ...CORS, "content-type": "application/json" },
+ body: JSON.stringify({ type: "error", error: { message: "rate limited" } }),
+ });
+ return;
+ }
+ }
+ if (isToolResult) {
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "application/json" },
+ body: toolUse("finish", {}),
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "application/json" },
+ body: toolUse("update_report", {
+ section: `Batch ${batch} finding`,
+ content: `Finding recorded in batch ${batch}.`,
+ }),
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "text/event-stream" },
+ body: sse("DONE"),
+ });
+ });
+
+ await searchThenChat(page, 3);
+ await keyIn(page);
+ await expect(page.getByText(/Grounded in 3 results/)).toBeVisible();
+
+ await page.getByRole("button", { name: /Build report from all 3 results/ }).click();
+
+ // Batch 1 folds in; batch 2's 429 pauses the run (not a hard error).
+ await expect(page.getByText("Finding recorded in batch 1.")).toBeVisible({ timeout: 15_000 });
+ const paused = page.getByTestId("sweep-paused");
+ await expect(paused).toBeVisible();
+ await expect(paused).toContainText("usage limit");
+ // The partial report survived.
+ await expect(page.getByText("Finding recorded in batch 2.")).toHaveCount(0);
+
+ // Switch the model (the checkpoint carries no model binding) and Resume.
+ const modelInput = page.locator('input[list="ask-models"]');
+ await modelInput.fill("claude-sonnet-5");
+ await paused.getByRole("button", { name: "Resume" }).click();
+
+ // The resumed batch completes and the report is now whole.
+ await expect(page.getByText("Finding recorded in batch 2.")).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByTestId("sweep-paused")).toHaveCount(0);
+ // Both batches are in the report.
+ await expect(page.getByText("Finding recorded in batch 1.")).toBeVisible();
+ });
+});
diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts
@@ -506,7 +506,11 @@ test.describe("composite search — query tree", () => {
.click();
await expect(page).toHaveURL(/\/ask\//);
await expect(page.getByText(/Grounded in 3 results/)).toBeVisible();
- await expect(page.getByText(/alpha/).first()).toBeVisible();
+ // Scope to the chat pane: the results rail (mounted but hidden in Chat view)
+ // also renders the query term, so an unscoped .first() would hit the hidden one.
+ await expect(
+ page.getByTestId("pane-chat").getByText(/alpha/).first(),
+ ).toBeVisible();
});
test("builder UI: + Add layer adds a second leaf and commits on Search", async ({
diff --git a/export/e2e/search-results-virtualization.spec.ts b/export/e2e/search-results-virtualization.spec.ts
@@ -166,11 +166,11 @@ test.describe("search results virtualization", () => {
`${VIDEO_COUNT} videos`,
);
- // The dense card is the first one rendered; clicking its header (the
- // direct-child button of the card-header div, not one of the nested
- // hit-row buttons) opens the modal for it.
+ // The dense card is the first one rendered; clicking its header title button
+ // (data-card-open, not the select checkbox / Ask action or a nested hit-row
+ // button) opens the modal for it.
await page
- .locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"] > button`)
+ .locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"] [data-card-open]`)
.click();
await page
.getByRole("button", { name: "Mark clip start at current time" })
diff --git a/export/e2e/workspace-shell.spec.ts b/export/e2e/workspace-shell.spec.ts
@@ -49,7 +49,7 @@ test.describe("workspace shell", () => {
).toHaveValue("alpha");
// Navigate back to results — the search is preserved (no ?qt re-seed needed).
- await nav.getByRole("link", { name: "Results" }).click();
+ await nav.getByRole("link", { name: "Search" }).click();
await expect(page).not.toHaveURL(/\/ask/);
await expect(page.locator("[data-card-header]")).toHaveCount(ALPHA_SLUGS.length);
});
@@ -57,10 +57,14 @@ test.describe("workspace shell", () => {
test("the workspace nav marks the active view", async ({ page }) => {
await page.goto("/");
const nav = page.getByTestId("workspace-nav");
- await expect(nav.getByRole("link", { name: "Results" })).toHaveAttribute(
+ await expect(nav.getByRole("link", { name: "Search" })).toHaveAttribute(
"aria-current",
"page",
);
+ // Wait for the results pane to hydrate (browse mode lists every video) before
+ // clicking — a client Link click lost to a pre-hydration window would leave us
+ // stranded on `/`.
+ await expect(page.locator("[data-card-header]").first()).toBeVisible();
await nav.getByRole("link", { name: "Chat" }).click();
await expect(page.getByPlaceholder(/Ask about the transcripts/)).toBeVisible();
await expect(nav.getByRole("link", { name: "Chat" })).toHaveAttribute(