commit 89b8136e0ec9de9bfb911c82a111d43c21aecb02
parent ffc06c3ce5907daa1d97cfdebc2cb4bd96dd4b59
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 8 Jul 2026 20:46:32 -0400
Ask chat: hand a search's results to the AI as pinned grounding
Add an "Ask AI about these results" button to the search results that
opens /ask grounded in exactly the videos found, instead of the assistant
deciding its own search. Strict by default (answer only from the pinned
set, skipping gather via precomputedGrounding); a per-chat toggle lets the
assistant also search, seeding runAskTurn with the handed-off results.
- common/lib/aiHandoff.ts: SearchHandoff type + buildSearchHandoff (maps
ResultGroup[] -> RetrievedVideo-shaped videos, capped, truncation flag).
- TranscriptSearch: the button -> sessionStorage handoff + router.push.
- useAskChat: pinned + strictGrounding state, one-shot handoff read on
mount, persisted with the convo; send() branches strict/expand.
- searchAgent: seedVideos accumulator seed for expand; precomputedGrounding
carries truncated.
- PinnedResultsPanel + AskChat: pinned grounding UI, strict/expand toggle,
Clear, grounding-aware empty state.
Tests: 6 unit (aiHandoff), 3 e2e (ask-chat pinned strict/expand/clear),
1 e2e (query-tree handoff navigation). tsc + all suites green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
10 files changed, 690 insertions(+), 35 deletions(-)
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -1,6 +1,7 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { usePlayer } from "./PlayerProvider";
import { VirtualRow } from "./VirtualRow";
@@ -69,6 +70,7 @@ import { ymdToInput, inputToYmd } from "../lib/ymd";
import type { DisplaySummary, Platform } from "../lib/transcripts";
import { makeId, splitId } from "./originId";
import { sortGroups, type ChannelGroup } from "../lib/channelGroups";
+import { AI_HANDOFF_KEY, buildSearchHandoff } from "../lib/aiHandoff";
type Summary = DisplaySummary;
@@ -1348,6 +1350,32 @@ export default function TranscriptSearch() {
return m;
}, [committedRoot]);
+ const router = useRouter();
+ // Hand the current result set to the /ask chat as its grounding: serialize the
+ // matched videos + snippets, stash them for the chat to pick up, and navigate.
+ const askAboutResults = useCallback(() => {
+ 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 },
+ );
+ if (handoff.videos.length === 0) return;
+ try {
+ sessionStorage.setItem(AI_HANDOFF_KEY, JSON.stringify(handoff));
+ } catch {
+ /* storage unavailable — the chat just won't receive the pin */
+ }
+ router.push("/ask/");
+ }, [leavesById, resultGroups, summaries, capped, router]);
+
const [resultsCopied, setResultsCopied] = useState(false);
const resultsCopiedResetRef = useRef<number | null>(null);
const copyResultsContext = useCallback(async () => {
@@ -1848,14 +1876,24 @@ export default function TranscriptSearch() {
</button>
</span>
{hasActiveQuery && resultGroups.length > 0 && (
- <button
- type="button"
- onClick={copyResultsContext}
- title="Copy these results as context to paste into an AI chat"
- className="inline-flex items-center rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-normal text-muted-foreground hover:bg-accent hover:text-accent-foreground"
- >
- {resultsCopied ? "✓ Copied" : "Copy for AI"}
- </button>
+ <span className="inline-flex items-center gap-2">
+ <button
+ type="button"
+ onClick={copyResultsContext}
+ title="Copy these results as context to paste into an AI chat"
+ className="inline-flex items-center rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-normal text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ >
+ {resultsCopied ? "✓ Copied" : "Copy for AI"}
+ </button>
+ <button
+ type="button"
+ onClick={askAboutResults}
+ title="Open the AI chat grounded in these search results"
+ className="inline-flex items-center rounded-md bg-primary px-2.5 py-1 text-xs font-normal text-primary-foreground hover:opacity-90"
+ >
+ Ask AI about these results
+ </button>
+ </span>
)}
</h2>
diff --git a/common/lib/aiHandoff.test.ts b/common/lib/aiHandoff.test.ts
@@ -0,0 +1,82 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildSearchHandoff, type HandoffGroup } from "./aiHandoff";
+import type { LayerHit } from "../components/searchPipeline";
+
+function hit(start: number, text: string): LayerHit {
+ return { leafId: "l1", scope: "transcripts", start, text };
+}
+
+function group(slug: string, hits: LayerHit[]): HandoffGroup {
+ return { slug, title: `Title ${slug}`, channel: "Chan", uploadDate: "20200101", hits };
+}
+
+const refs: Record<string, { id: string; webpageUrl?: string }> = {
+ "chan/aaa": { id: "aaa", webpageUrl: "https://x/aaa" },
+ "chan/bbb": { id: "bbb", webpageUrl: "https://x/bbb" },
+};
+const lookup = (slug: string) => refs[slug];
+
+test("buildSearchHandoff maps groups to videos with ids, urls, and clocks", () => {
+ const h = buildSearchHandoff(
+ [group("chan/aaa", [hit(65, "hello world"), hit(0, "meta")])],
+ lookup,
+ ["graham platner"],
+ );
+ assert.equal(h.label, "graham platner");
+ assert.equal(h.totalVideos, 1);
+ assert.equal(h.truncated, false);
+ assert.equal(h.videos.length, 1);
+ const v = h.videos[0];
+ assert.equal(v.key, "chan/aaa");
+ assert.equal(v.videoId, "aaa");
+ assert.equal(v.url, "https://x/aaa");
+ assert.deepEqual(v.snippets[0], { clock: "1:05", seconds: 65, text: "hello world" });
+ // start 0 → "0:00" clock, whitespace collapsed.
+ assert.equal(v.snippets[1].clock, "0:00");
+});
+
+test("buildSearchHandoff joins multiple queries with AND, falls back when empty", () => {
+ assert.equal(
+ buildSearchHandoff([group("chan/aaa", [hit(1, "a")])], lookup, ["one", " two "]).label,
+ "one AND two",
+ );
+ assert.equal(
+ buildSearchHandoff([group("chan/aaa", [hit(1, "a")])], lookup, []).label,
+ "your search",
+ );
+});
+
+test("buildSearchHandoff caps videos and hits, flagging truncation", () => {
+ const groups = Array.from({ length: 25 }, (_, i) =>
+ group(`chan/${i}`, Array.from({ length: 10 }, (_, j) => hit(j, `h${j}`))),
+ );
+ const h = buildSearchHandoff(groups, lookup, ["q"], {
+ maxVideos: 5,
+ maxHitsPerVideo: 3,
+ });
+ assert.equal(h.videos.length, 5);
+ assert.equal(h.totalVideos, 25);
+ assert.equal(h.videos[0].snippets.length, 3);
+ assert.equal(h.truncated, true); // both video cap and per-video hit overflow
+});
+
+test("buildSearchHandoff marks truncated when the search itself was capped", () => {
+ const h = buildSearchHandoff([group("chan/aaa", [hit(1, "a")])], lookup, ["q"], {
+ searchCapped: true,
+ });
+ assert.equal(h.truncated, true);
+});
+
+test("buildSearchHandoff falls back to slug when no summary ref is found", () => {
+ const h = buildSearchHandoff([group("chan/unknown", [hit(1, "a")])], lookup, ["q"]);
+ assert.equal(h.videos[0].videoId, "chan/unknown");
+ assert.equal(h.videos[0].url, undefined);
+});
+
+test("buildSearchHandoff with no groups yields an empty, untruncated handoff", () => {
+ const h = buildSearchHandoff([], lookup, ["q"]);
+ assert.equal(h.videos.length, 0);
+ assert.equal(h.totalVideos, 0);
+ assert.equal(h.truncated, false);
+});
diff --git a/common/lib/aiHandoff.ts b/common/lib/aiHandoff.ts
@@ -0,0 +1,109 @@
+import type { LayerHit } from "../components/searchPipeline";
+
+// Hand-off channel: a completed transcript search's results, serialized so the
+// /ask chat can pick them up and answer *grounded in exactly those results*
+// instead of running its own search. The search page writes a SearchHandoff to
+// sessionStorage under this key and navigates to /ask; the chat reads it once on
+// mount, pins it, and clears the key.
+//
+// This lives in `common` (shared by both search + chat) so it cannot import the
+// export-only `RetrievedVideo`. HandoffVideo is deliberately shape-compatible
+// with `RetrievedVideo`, so the chat treats `videos` as grounding with no adapter.
+export const AI_HANDOFF_KEY = "ytdlp-tb:ai:handoff";
+
+export type HandoffSnippet = { clock: string; seconds: number; text: string };
+
+export type HandoffVideo = {
+ key: string;
+ videoId: string;
+ title: string;
+ channel: string;
+ siteTitle?: string;
+ uploadDate: string;
+ url?: string;
+ snippets: HandoffSnippet[];
+};
+
+export type SearchHandoff = {
+ // Human-readable summary of the search that produced these (the joined leaf
+ // queries), shown in the pinned panel.
+ label: string;
+ videos: HandoffVideo[];
+ // Total matching videos before capping — so the UI can say "showing first N".
+ totalVideos: number;
+ // Capped videos, per-video hit overflow, or the search itself hit its batch cap.
+ truncated: boolean;
+};
+
+// The minimal view of a search result group the builder needs.
+export type HandoffGroup = {
+ slug: string;
+ title: string;
+ channel: string;
+ uploadDate: string;
+ hits: LayerHit[];
+};
+
+// Per-slug metadata joined in from the summaries (video id + canonical URL, and
+// the source site title in hub mode).
+export type HandoffSummaryRef = {
+ id: string;
+ webpageUrl?: string;
+ siteTitle?: string;
+};
+
+function hms(s: number): string {
+ const n = Math.max(0, Math.floor(s));
+ const h = Math.floor(n / 3600);
+ const m = Math.floor((n % 3600) / 60);
+ const ss = n % 60;
+ const mm = String(m).padStart(2, "0");
+ const sss = String(ss).padStart(2, "0");
+ return h > 0 ? `${h}:${mm}:${sss}` : `${m}:${sss}`;
+}
+
+// Turn a completed search's result groups into a SearchHandoff. Bounded (default
+// 20 videos, 8 snippets each) so the grounding stays a reasonable token size;
+// `truncated` records whether anything was dropped.
+export function buildSearchHandoff(
+ groups: HandoffGroup[],
+ lookup: (slug: string) => HandoffSummaryRef | undefined,
+ queries: string[],
+ opts: {
+ maxVideos?: number;
+ maxHitsPerVideo?: number;
+ searchCapped?: boolean;
+ } = {},
+): SearchHandoff {
+ const maxVideos = opts.maxVideos ?? 20;
+ const maxHits = opts.maxHitsPerVideo ?? 8;
+ const shown = groups.slice(0, maxVideos);
+ let hitOverflow = false;
+
+ const videos: HandoffVideo[] = shown.map((g) => {
+ const ref = lookup(g.slug);
+ const hits = g.hits.slice(0, maxHits);
+ if (g.hits.length > hits.length) hitOverflow = true;
+ return {
+ key: g.slug,
+ videoId: ref?.id ?? g.slug,
+ title: g.title,
+ channel: g.channel,
+ siteTitle: ref?.siteTitle,
+ uploadDate: g.uploadDate,
+ url: ref?.webpageUrl,
+ snippets: hits.map((h) => ({
+ clock: hms(h.start),
+ seconds: h.start,
+ text: h.text.trim().replace(/\s+/g, " "),
+ })),
+ };
+ });
+
+ const cleaned = queries.map((q) => q.trim()).filter(Boolean);
+ const label = cleaned.length ? cleaned.join(" AND ") : "your search";
+ const truncated =
+ groups.length > shown.length || hitOverflow || !!opts.searchCapped;
+
+ return { label, videos, totalVideos: groups.length, truncated };
+}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## [Unreleased]
+- **Hand a search's results straight to the "Ask AI" chat.** The search results header gains an **Ask AI about these results** button (next to *Copy for AI*) that opens the chat grounded in *exactly* the videos you found, instead of the assistant deciding its own search. By default it answers **only** from those results (fast and predictable); a per-chat toggle — *Answer only from these results* — lets the assistant also search the archive, using your results as a starting point. The pinned set is shown with the search that produced it, survives reloads, and can be detached (**Clear**) or replaced with a new hand-off. See `common/lib/aiHandoff.ts`, `common/components/TranscriptSearch.tsx`, `export/app/ask/{useAskChat.ts,PinnedResultsPanel.tsx,AskChat.tsx}`, `export/app/lib/searchAgent.ts`, and `export/e2e/{ask-chat,query-tree}.spec.ts`.
+
## [0.7.5] - 2026-07-07
- **The "Ask AI" chat now uses your curated name aliases when it searches.** Asking about a term with a known alias (e.g. *Graham Platner*, which AI transcription often mangles to "Grand Platina") now applies the alias's regex, so misspelled mentions are found instead of missed. Previously the chat matched aliases one word at a time and a multi-word alias never fired; it now matches the whole search phrase, and each prompt explicitly tells the model to search the full aliased term (not a fragment like "Graham") and why. See `export/app/lib/askRetrieval.ts` (`buildSearchRoot`) and `export/app/lib/askConversation.ts` (`renderAliasGlossary`).
- **The chat conversation is now saved, resumable, and editable.** Your conversation and the excerpts it gathered are kept in your browser, so a reload no longer loses them, and a *New chat* button clears them. If a request fails partway (e.g. a provider 503), the turn shows a **Retry** button that re-runs it **reusing the excerpts already found** — no re-searching. And a new **Context** panel lets you view and prune exactly what gets sent to the model (conversation + excerpts) as editable text, for a leaner, cheaper starting point — mid-conversation or as the seed for a fresh session. See `export/app/ask/{useAskChat.ts,ContextPanel.tsx,MessageBubble.tsx}`, `export/app/lib/{searchAgent,askConversation}.ts`, and `export/e2e/ask-chat.spec.ts`.
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -5,6 +5,7 @@ import { ArrowDownIcon, PlusIcon } from "lucide-react";
import { useAskChat } from "./useAskChat";
import { ProviderSettings } from "./ProviderSettings";
import { ContextPanel } from "./ContextPanel";
+import { PinnedResultsPanel } from "./PinnedResultsPanel";
import { MessageBubble } from "./MessageBubble";
import { Composer } from "./Composer";
@@ -49,6 +50,14 @@ export default function AskChat() {
const channelCount = channels.length;
const suggestions = useMemo(() => {
+ if (s.pinned) {
+ return [
+ "Summarize these results.",
+ "Make a timeline from these results.",
+ "What are the key points across these?",
+ "What's the disagreement between them?",
+ ];
+ }
const base = [
"What are the main topics discussed?",
"Summarize the most recent developments.",
@@ -56,7 +65,7 @@ export default function AskChat() {
];
if (channels[0]) base.unshift(`What does ${channels[0].name} focus on?`);
return base.slice(0, 4);
- }, [channels]);
+ }, [channels, s.pinned]);
return (
<div className="flex flex-col gap-5">
@@ -83,12 +92,14 @@ export default function AskChat() {
</p>
)}
- {(messages.length > 0 || s.contextOverride) && (
+ {(messages.length > 0 || s.contextOverride || s.pinned) && (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground/70">
{s.contextOverride
? "Continuing from an edited context."
- : "Conversation is saved in this browser."}
+ : s.pinned
+ ? "Grounded in your search results."
+ : "Conversation is saved in this browser."}
</span>
<button
type="button"
@@ -101,6 +112,16 @@ export default function AskChat() {
</div>
)}
+ {s.pinned && (
+ <PinnedResultsPanel
+ pinned={s.pinned}
+ strictGrounding={s.strictGrounding}
+ busy={busy}
+ onSetStrict={s.setStrictGrounding}
+ onClear={s.clearPinned}
+ />
+ )}
+
{(messages.length > 0 || s.contextOverride) && (
<ContextPanel
contextText={s.contextText}
@@ -120,12 +141,25 @@ export default function AskChat() {
{messages.length === 0 ? (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
- Ask a question about the transcripts
- {channelCount > 0
- ? ` (${channelCount} channel${channelCount === 1 ? "" : "s"} indexed)`
- : ""}
- . The assistant searches for what it needs — refining as it reads —
- and answers with citations.
+ {s.pinned ? (
+ <>
+ Ask about the {s.pinned.videos.length} result
+ {s.pinned.videos.length === 1 ? "" : "s"} you handed off from
+ search
+ {s.strictGrounding
+ ? " — the assistant answers only from them, with citations."
+ : " — the assistant starts from them and may search for more."}
+ </>
+ ) : (
+ <>
+ Ask a question about the transcripts
+ {channelCount > 0
+ ? ` (${channelCount} channel${channelCount === 1 ? "" : "s"} indexed)`
+ : ""}
+ . The assistant searches for what it needs — refining as it
+ reads — and answers with citations.
+ </>
+ )}
</p>
<div className="flex flex-wrap gap-2">
{suggestions.map((q) => (
diff --git a/export/app/ask/PinnedResultsPanel.tsx b/export/app/ask/PinnedResultsPanel.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useState } from "react";
+import { ChevronDownIcon, PinIcon, XIcon } from "lucide-react";
+import type { SearchHandoff } from "yt-dlp-transcript-common/lib/aiHandoff";
+
+// 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
+// strict/expand toggle), and a way to detach them.
+export function PinnedResultsPanel({
+ pinned,
+ strictGrounding,
+ busy,
+ onSetStrict,
+ onClear,
+}: {
+ pinned: SearchHandoff;
+ strictGrounding: boolean;
+ busy: boolean;
+ onSetStrict: (on: boolean) => void;
+ onClear: () => void;
+}) {
+ const [showList, setShowList] = useState(false);
+ const n = pinned.videos.length;
+
+ return (
+ <div className="flex flex-col gap-3 rounded-lg border border-brand/40 bg-brand-soft/40 px-4 py-3">
+ <div className="flex items-start gap-2">
+ <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 search
+ </span>
+ <span className="truncate font-mono text-xs text-muted-foreground">
+ {pinned.label}
+ {pinned.truncated ? ` · showing first ${n}` : ""}
+ </span>
+ </div>
+ <button
+ type="button"
+ onClick={onClear}
+ disabled={busy}
+ title="Detach these results and let the assistant search normally"
+ 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 disabled:opacity-50"
+ >
+ <XIcon className="size-3.5" /> Clear
+ </button>
+ </div>
+
+ <label className="flex cursor-pointer items-start gap-2 text-xs text-muted-foreground">
+ <input
+ type="checkbox"
+ checked={strictGrounding}
+ onChange={(e) => onSetStrict(e.target.checked)}
+ disabled={busy}
+ className="mt-0.5 accent-brand"
+ />
+ <span>
+ <span className="font-medium text-foreground">
+ Answer only from these results
+ </span>
+ <br />
+ {strictGrounding
+ ? "The assistant won't run its own searches."
+ : "The assistant may also search the archive for more."}
+ </span>
+ </label>
+
+ <div>
+ <button
+ type="button"
+ onClick={() => setShowList((v) => !v)}
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
+ >
+ <ChevronDownIcon
+ className={`size-3.5 transition-transform ${showList ? "rotate-180" : ""}`}
+ />
+ {showList ? "Hide" : "Show"} the {n} video{n === 1 ? "" : "s"}
+ </button>
+ {showList && (
+ <ul className="mt-2 flex flex-col gap-1 border-t border-border pt-2">
+ {pinned.videos.map((v) => (
+ <li key={v.key} className="truncate text-xs text-muted-foreground">
+ <span className="text-foreground">{v.title}</span>
+ {v.channel ? ` — ${v.channel}` : ""}
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/export/app/ask/useAskChat.ts b/export/app/ask/useAskChat.ts
@@ -2,10 +2,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
+import {
+ AI_HANDOFF_KEY,
+ type SearchHandoff,
+} from "yt-dlp-transcript-common/lib/aiHandoff";
import { PROVIDERS, type Provider } from "../lib/askProvider";
import { runAskTurn, type AgentEvent, type AgentMode } from "../lib/searchAgent";
+import type { RetrievedVideo } from "../lib/askRetrieval";
import {
buildApiMessages,
+ buildGroundedContent,
parseContext,
serializeContext,
type SearchStep,
@@ -20,7 +26,14 @@ const K_CONVO = "ytdlp-tb:ai:conversation";
const keyFor = (p: Provider) => `ytdlp-tb:ai:key:${p}`;
const modelFor = (p: Provider) => `ytdlp-tb:ai:model:${p}`;
-type PersistedConvo = { messages: UiMessage[]; contextOverride: string | null };
+type PersistedConvo = {
+ messages: UiMessage[];
+ contextOverride: string | null;
+ // A search's handed-off results, pinned as the chat's grounding.
+ pinned?: SearchHandoff | null;
+ // When pinned: answer only from those results (skip the AI's own search).
+ strictGrounding?: boolean;
+};
// State + turn orchestration for the /ask chat. Owns the conversation (persisted
// across reloads), the retrieval agent turns (gather → answer), a Retry path that
@@ -42,6 +55,12 @@ export function useAskChat() {
// A human-pruned context that seeds the conversation: prepended to the replayed
// history on every turn. Set by applying an edit in the context panel.
const [contextOverride, setContextOverride] = useState<string | null>(null);
+ // A search's results handed off from the search page, pinned as this chat's
+ // grounding. When set, questions answer over these instead of the AI searching.
+ const [pinned, setPinned] = useState<SearchHandoff | null>(null);
+ // Pinned mode only: answer strictly from the pinned results (skip gather) vs.
+ // use them as a starting point the AI may expand with its own searches.
+ const [strictGrounding, setStrictGroundingState] = useState(true);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const abortRef = useRef<AbortController | null>(null);
@@ -52,6 +71,10 @@ export function useAskChat() {
messagesRef.current = messages;
const contextOverrideRef = useRef(contextOverride);
contextOverrideRef.current = contextOverride;
+ const pinnedRef = useRef(pinned);
+ pinnedRef.current = pinned;
+ const strictRef = useRef(strictGrounding);
+ strictRef.current = strictGrounding;
// Restore saved preferences + (if remembered) the provider's key/model, plus a
// persisted conversation.
@@ -72,12 +95,37 @@ export function useAskChat() {
}
setMarkdownOnState(localStorage.getItem(K_MARKDOWN) !== "0");
loadProviderCreds(p, rememberSaved);
- const rawConvo = localStorage.getItem(K_CONVO);
- if (rawConvo) {
- const parsed = JSON.parse(rawConvo) as PersistedConvo;
- if (Array.isArray(parsed.messages)) setMessages(parsed.messages);
- if (typeof parsed.contextOverride === "string") {
- setContextOverride(parsed.contextOverride);
+ // A search hand-off (sessionStorage, one-shot) takes precedence: start a
+ // fresh chat pinned to those results.
+ let handoff: SearchHandoff | null = null;
+ try {
+ const rawHandoff = sessionStorage.getItem(AI_HANDOFF_KEY);
+ if (rawHandoff) {
+ handoff = JSON.parse(rawHandoff) as SearchHandoff;
+ sessionStorage.removeItem(AI_HANDOFF_KEY);
+ }
+ } catch {
+ /* no / corrupt handoff */
+ }
+ if (handoff && Array.isArray(handoff.videos) && handoff.videos.length) {
+ setPinned(handoff);
+ setStrictGroundingState(true);
+ setMessages([]);
+ setContextOverride(null);
+ } else {
+ const rawConvo = localStorage.getItem(K_CONVO);
+ if (rawConvo) {
+ const parsed = JSON.parse(rawConvo) as PersistedConvo;
+ if (Array.isArray(parsed.messages)) setMessages(parsed.messages);
+ if (typeof parsed.contextOverride === "string") {
+ setContextOverride(parsed.contextOverride);
+ }
+ if (parsed.pinned && Array.isArray(parsed.pinned.videos)) {
+ setPinned(parsed.pinned);
+ }
+ if (typeof parsed.strictGrounding === "boolean") {
+ setStrictGroundingState(parsed.strictGrounding);
+ }
}
}
} catch {
@@ -93,10 +141,15 @@ export function useAskChat() {
if (persistTimer.current) clearTimeout(persistTimer.current);
persistTimer.current = setTimeout(() => {
try {
- if (messages.length === 0 && !contextOverride) {
+ if (messages.length === 0 && !contextOverride && !pinned) {
localStorage.removeItem(K_CONVO);
} else {
- const payload: PersistedConvo = { messages, contextOverride };
+ const payload: PersistedConvo = {
+ messages,
+ contextOverride,
+ pinned,
+ strictGrounding,
+ };
localStorage.setItem(K_CONVO, JSON.stringify(payload));
}
} catch {
@@ -106,7 +159,7 @@ export function useAskChat() {
return () => {
if (persistTimer.current) clearTimeout(persistTimer.current);
};
- }, [messages, contextOverride]);
+ }, [messages, contextOverride, pinned, strictGrounding]);
function loadProviderCreds(p: Provider, rememberOn: boolean) {
if (rememberOn) {
@@ -181,7 +234,9 @@ export function useAskChat() {
precomputedGrounding?: {
videos: UiMessage["sources"];
groundedContent: string;
+ truncated?: boolean;
};
+ seedVideos?: RetrievedVideo[];
}) => {
const { question, prior, userIndex, assistantIndex } = args;
setBusy(true);
@@ -251,10 +306,12 @@ export function useAskChat() {
signal: ac.signal,
onEvent,
historyOverride,
+ seedVideos: args.seedVideos,
precomputedGrounding: args.precomputedGrounding?.groundedContent
? {
videos: args.precomputedGrounding.videos ?? [],
groundedContent: args.precomputedGrounding.groundedContent,
+ truncated: args.precomputedGrounding.truncated,
}
: undefined,
});
@@ -301,13 +358,42 @@ export function useAskChat() {
const prior = messagesRef.current;
const userIndex = prior.length;
const assistantIndex = prior.length + 1;
+
+ // Pinned search results ground the turn. Strict → every question answers
+ // only from them (skip gather entirely). Expand → seed the AI's search with
+ // them on the first turn, then let it search freely (the pin stays in history).
+ const pin = pinnedRef.current;
+ let precomputedGrounding:
+ | { videos: RetrievedVideo[]; groundedContent: string; truncated?: boolean }
+ | undefined;
+ let seedVideos: RetrievedVideo[] | undefined;
+ if (pin && pin.videos.length) {
+ const videos = pin.videos as RetrievedVideo[];
+ if (strictRef.current) {
+ precomputedGrounding = {
+ videos,
+ groundedContent: buildGroundedContent(question, videos),
+ truncated: pin.truncated,
+ };
+ } else if (prior.length === 0) {
+ seedVideos = videos;
+ }
+ }
+
setInput("");
setMessages((prev) => [
...prev,
{ role: "user", content: question },
{ role: "assistant", content: "", phase: "gathering", searchSteps: [] },
]);
- await runTurn({ question, prior, userIndex, assistantIndex });
+ await runTurn({
+ question,
+ prior,
+ userIndex,
+ assistantIndex,
+ precomputedGrounding,
+ seedVideos,
+ });
}, [input, busy, apiKey, summariesReady, provider, model, remember, runTurn]);
// Re-run a failed assistant turn. Reuses the grounding captured before the
@@ -349,8 +435,19 @@ export function useAskChat() {
if (busy) return;
setMessages([]);
setContextOverride(null);
+ setPinned(null);
+ setStrictGroundingState(true);
};
+ // Detach the pinned search results → back to a normal AI-decides-search chat.
+ const clearPinned = () => {
+ if (busy) return;
+ setPinned(null);
+ setStrictGroundingState(true);
+ };
+
+ const setStrictGrounding = (on: boolean) => setStrictGroundingState(on);
+
// The effective context that will be sent next turn, serialized for the panel:
// the override (if any) prepended to the replayed conversation.
const contextText = useMemo(() => {
@@ -407,6 +504,11 @@ export function useAskChat() {
contextOverride,
applyContext,
clearContextOverride,
+ // pinned search results
+ pinned,
+ strictGrounding,
+ setStrictGrounding,
+ clearPinned,
};
}
diff --git a/export/app/lib/searchAgent.ts b/export/app/lib/searchAgent.ts
@@ -165,10 +165,20 @@ export type RunAskTurnOptions = {
budget?: number;
signal?: AbortSignal;
onEvent: (e: AgentEvent) => void;
- // Retry path: reuse a previous attempt's grounding and SKIP the whole gather
- // phase — only (re)stream the answer. Set when retrying a turn whose gather
- // succeeded but whose answer stream failed (e.g. a 503 mid-answer).
- precomputedGrounding?: { videos: RetrievedVideo[]; groundedContent: string };
+ // Retry path AND pinned-strict grounding: reuse pre-supplied grounding and SKIP
+ // the whole gather phase — only (re)stream the answer. Set when retrying a turn
+ // whose gather succeeded but whose answer stream failed (e.g. a 503 mid-answer),
+ // or when answering strictly from a search's handed-off results. `truncated`
+ // carries through when the supplied set was capped.
+ precomputedGrounding?: {
+ videos: RetrievedVideo[];
+ groundedContent: string;
+ truncated?: boolean;
+ };
+ // Pinned-expand grounding: seed the gathered-videos set with these before the
+ // model runs its own searches, so the final grounding is the handed-off results
+ // merged with whatever the model finds.
+ seedVideos?: RetrievedVideo[];
// Human-edited context: replaces the history reconstructed from `prior` (used
// when the user has pruned the context in the context panel).
historyOverride?: ChatMessage[];
@@ -214,7 +224,12 @@ export async function runAskTurn(
signal,
onDelta: (chunk) => onEvent({ type: "delta", text: chunk }),
});
- return { answer, videos: pv, groundedContent, truncated: false };
+ return {
+ answer,
+ videos: pv,
+ groundedContent,
+ truncated: opts.precomputedGrounding.truncated ?? false,
+ };
}
const hasPriorGrounding = prior.some(
@@ -222,6 +237,10 @@ export async function runAskTurn(
);
const videos = new Map<string, RetrievedVideo>();
+ // Pinned-expand: start from the handed-off results, then let the model add to
+ // them. Deduped by key; still capped below at MAX_CONTEXT_VIDEOS.
+ const seedVideos = opts.seedVideos ?? [];
+ for (const v of seedVideos) videos.set(v.key, v);
const queries: string[] = [];
let truncated = false;
@@ -285,8 +304,9 @@ export async function runAskTurn(
}
// Safety net: never answer a fresh question with zero grounding just because a
- // model ignored the protocol / declined to search.
- if (queries.length === 0 && !hasPriorGrounding) {
+ // model ignored the protocol / declined to search. Seeded results already count
+ // as grounding, so don't force a search when we started from a handed-off set.
+ if (queries.length === 0 && !hasPriorGrounding && seedVideos.length === 0) {
await runSearch(question);
}
diff --git a/export/e2e/ask-chat.spec.ts b/export/e2e/ask-chat.spec.ts
@@ -231,6 +231,157 @@ test.describe("ask chat", () => {
await expect(page.getByText("point one", { exact: false }).first()).toBeVisible();
});
+ // A search hand-off: the payload the search page writes to sessionStorage.
+ const HANDOFF = {
+ label: "graham platner",
+ videos: [
+ {
+ key: "v1",
+ videoId: "v1",
+ title: "Alpha talk",
+ channel: "Chan",
+ uploadDate: "20200101",
+ url: "https://x/v1",
+ snippets: [{ clock: "0:05", seconds: 5, text: "alpha excerpt one" }],
+ },
+ {
+ key: "v2",
+ videoId: "v2",
+ title: "Beta talk",
+ channel: "Chan",
+ uploadDate: "20200102",
+ snippets: [{ clock: "1:00", seconds: 60, text: "beta excerpt two" }],
+ },
+ ],
+ totalVideos: 2,
+ truncated: false,
+ };
+
+ async function seedHandoff(page: Page) {
+ // Runs before any page script, so the mount effect finds the pin.
+ await page.addInitScript((h) => {
+ sessionStorage.setItem("ytdlp-tb:ai:handoff", JSON.stringify(h));
+ }, HANDOFF);
+ }
+
+ async function keyIn(page: Page) {
+ await page.getByRole("button", { name: "Scripted" }).click();
+ await page.locator('input[placeholder^="sk-ant"]').fill("sk-ant-test");
+ }
+
+ test("pinned strict grounding answers from the handed-off results, no search", async ({
+ page,
+ }) => {
+ await installRoutes(page);
+ let gatherCalls = 0;
+ let answerSawExcerpts = false;
+ 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;
+ messages?: { role: string; content: unknown }[];
+ };
+ const system = body.system ?? "";
+ if (system.includes("Markdown")) {
+ answerSawExcerpts = JSON.stringify(body.messages ?? []).includes(
+ "alpha excerpt one",
+ );
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "text/event-stream" },
+ body: sse("Grounded answer [1]"),
+ });
+ return;
+ }
+ // Any non-answer call would be the gather phase — it must NOT happen.
+ gatherCalls += 1;
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "text/event-stream" },
+ body: sse("SEARCH: alpha"),
+ });
+ });
+ await seedHandoff(page);
+ await page.goto("/ask/");
+ await keyIn(page);
+
+ await expect(page.getByText(/Grounded in 2 results/)).toBeVisible();
+ await ask(page, "summarize these");
+ await expect(page.getByText(/Grounded answer/)).toBeVisible();
+ expect(gatherCalls).toBe(0);
+ expect(answerSawExcerpts).toBe(true);
+ });
+
+ test("expanding the pin lets the assistant also search the archive", async ({
+ page,
+ }) => {
+ await installRoutes(page);
+ let gatherCalls = 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;
+ messages?: { role: string; content: unknown }[];
+ };
+ const system = body.system ?? "";
+ const msgs = body.messages ?? [];
+ const lastUser = [...msgs].reverse().find((m) => m.role === "user");
+ const lastText =
+ typeof lastUser?.content === "string" ? lastUser.content : "";
+ if (system.includes("Markdown")) {
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "text/event-stream" },
+ body: sse("Expanded answer [1]"),
+ });
+ return;
+ }
+ gatherCalls += 1;
+ const text = lastText.includes('Results for "') ? "DONE" : "SEARCH: alpha";
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "text/event-stream" },
+ body: sse(text),
+ });
+ });
+ await seedHandoff(page);
+ await page.goto("/ask/");
+ await keyIn(page);
+
+ await expect(page.getByText(/Grounded in 2 results/)).toBeVisible();
+ // Turn strict OFF → the assistant may search.
+ await page.getByLabel("Answer only from these results").uncheck();
+ await ask(page, "what else is there");
+ await expect(page.getByText(/Expanded answer/)).toBeVisible();
+ // A gather search ran, and the seeded video is among the citations.
+ expect(gatherCalls).toBeGreaterThan(0);
+ await expect(page.getByText("Alpha talk", { exact: false }).first()).toBeVisible();
+ });
+
+ test("clearing the pin returns to a normal AI-decides-search chat", async ({
+ page,
+ }) => {
+ await installRoutes(page);
+ await mockAnthropic(page);
+ await seedHandoff(page);
+ await page.goto("/ask/");
+ await keyIn(page);
+
+ await expect(page.getByText(/Grounded in 2 results/)).toBeVisible();
+ await page.getByRole("button", { name: "Clear" }).click();
+ await expect(page.getByText(/Grounded in 2 results/)).toHaveCount(0);
+ // Back to the normal empty-state copy.
+ await expect(
+ page.getByText(/The assistant searches for what it needs/),
+ ).toBeVisible();
+ });
+
test("editing the context changes what the next turn sends", async ({
page,
}) => {
diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts
@@ -484,6 +484,29 @@ test.describe("composite search — query tree", () => {
).toHaveCount(2, { timeout: 15_000 });
});
+ test("'Ask AI about these results' hands the search off to /ask, pinned", async ({
+ page,
+ }) => {
+ const tree: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [{ k: "l", q: "alpha", s: "transcripts" }],
+ };
+ await page.goto(`/?qt=${qt(tree)}`);
+ await expectResultSlugs(page, [
+ TRANSCRIPT_ONLY_SLUG,
+ CHAT_SMALL_SLUG,
+ CHAT_LARGE_SLUG,
+ ]);
+ await page
+ .getByRole("button", { name: "Ask AI about these results" })
+ .click();
+ // Landed on /ask with the three results pinned as grounding.
+ await expect(page).toHaveURL(/\/ask\//);
+ await expect(page.getByText(/Grounded in 3 results/)).toBeVisible();
+ await expect(page.getByText(/alpha/).first()).toBeVisible();
+ });
+
test("builder UI: + Add layer adds a second leaf and commits on Search", async ({
page,
}) => {