Archilyzer · Source

archilyzer

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

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:
Mcommon/components/SearchResults.tsx | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcommon/components/SearchSessionContext.tsx | 237++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Acommon/lib/selection.test.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/selection.ts | 25+++++++++++++++++++++++++
Mexport/CHANGELOG.md | 8++++++++
Mexport/app/(workspace)/SiteWorkspace.tsx | 8+++++---
Mexport/app/(workspace)/WorkspaceNav.tsx | 92++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Aexport/app/(workspace)/WorkspaceView.tsx | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/(workspace)/ask/page.tsx | 7+++++--
Mexport/app/(workspace)/page.tsx | 10++++------
Mexport/app/ask/AskChat.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/ask/GroundingPalette.tsx | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/ask/PinnedResultsPanel.tsx | 39++++++++++++++++++++++++++++++---------
Mexport/app/ask/ProviderSettings.tsx | 24++++++++++++++++++++++++
Aexport/app/ask/SavedChatsRow.tsx | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/ask/askChatStorage.test.ts | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/ask/askChatStorage.ts | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/ask/askNotify.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Mexport/app/ask/useAskChat.ts | 593+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mexport/app/lib/nativeTools/shared.ts | 19+++++++++++++++++++
Mexport/e2e/ask-chat.spec.ts | 7++++++-
Aexport/e2e/ask-workspace.spec.ts | 318+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/query-tree.spec.ts | 6+++++-
Mexport/e2e/search-results-virtualization.spec.ts | 8++++----
Mexport/e2e/workspace-shell.spec.ts | 8++++++--
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&apos;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(