commit 7790cfcdd0bf25535cca7850dce56c3531a79399
parent 0363ab70173abcb1174b7396d63eb6a423b74b94
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 16 Jul 2026 18:27:01 -0400
Ask chat: auto-ground in the live shared search (retire the handoff)
Now that search and /ask share one SearchSession, the chat reads the current
search directly instead of a one-shot sessionStorage hand-off:
- SearchSession exposes `liveGrounding` (the committed results as tiered chat
grounding, null when no active search). useAskChat derives `pinned` from it,
overlaid with per-video "Load context" enrichments, unless the user detached.
- The "Ask AI about these results" button + AI_HANDOFF_KEY are removed; the
Results<->Chat nav is the way across. A Detach control asks free-form
(ignoring the search) and a "Ground in my search" button re-attaches.
- "Load context" expansions are stored as an enrichment overlay keyed by video
slug, so they survive the live search updating around them. Persisted
(detached + enrichments) instead of a frozen pin.
Verified: tsc clean (export + common); no new lint errors; full e2e 106/106
green (ask-chat pinned tests reworked to seed via a real search + a new
query-tree "live search auto-grounds the chat" test).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
9 files changed, 250 insertions(+), 200 deletions(-)
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -60,7 +60,6 @@ export default function SearchResults() {
hitBatchSize,
resultsCopied,
copyResultsContext,
- askAboutResults,
} = useSearchSession();
return (
@@ -128,14 +127,6 @@ export default function SearchResults() {
>
{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/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -22,7 +22,6 @@ import {
useState,
type ReactNode,
} from "react";
-import { useRouter } from "next/navigation";
import { usePlayer } from "./PlayerProvider";
import { useChannelSubsManifests } from "./subsCache";
import { useSearchData } from "./SearchDataContext";
@@ -72,7 +71,7 @@ import {
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";
+import { buildSearchHandoff, type SearchHandoff } from "../lib/aiHandoff";
type Summary = DisplaySummary;
@@ -1403,10 +1402,13 @@ function useSearchSessionState() {
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(() => {
+ // 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);
@@ -1420,14 +1422,8 @@ function useSearchSessionState() {
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]);
+ return handoff.videos.length ? handoff : null;
+ }, [hasActiveQuery, resultGroups, leavesById, summaries, capped]);
const [resultsCopied, setResultsCopied] = useState(false);
const resultsCopiedResetRef = useRef<number | null>(null);
@@ -1571,8 +1567,9 @@ function useSearchSessionState() {
activeTime,
openWithMode,
summaries,
- // ── Results → AI handoff (buttons live in the results header) ──
- askAboutResults,
+ // ── Results → AI ──
+ // The current search as chat grounding (read live by /ask; no hand-off).
+ liveGrounding,
copyResultsContext,
resultsCopied,
};
diff --git a/common/lib/aiHandoff.ts b/common/lib/aiHandoff.ts
@@ -1,15 +1,14 @@
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.
+// Search grounding: a completed transcript search's results, serialized so the
+// /ask chat can answer *grounded in exactly those results* instead of running
+// its own search. The workspace shares one SearchSession across `/` and `/ask`,
+// so the chat reads the live search's SearchHandoff straight from context — no
+// sessionStorage hand-off is involved.
//
// 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 };
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **"Ask AI" now grounds in your *current* search automatically.** With search and chat sharing one workspace, you no longer click "Ask AI about these results" to hand a frozen copy of your results to the chat — the chat reads the **live** search directly. Run a search, switch to Chat, and it's already grounded in exactly those results (with the same *answer only from these / may also search* toggle); change the search and the grounding follows. No active search → the chat searches on its own, as before. A **Detach** control lets you ask a free-form question without the current search grounding it (and a **Ground in my search** button re-attaches). The old "Ask AI about these results" button and its one-shot hand-off are retired. See `common/components/SearchSessionContext.tsx` (`liveGrounding`), `export/app/ask/useAskChat.ts`, `export/app/ask/{AskChat,PinnedResultsPanel}.tsx`, and `common/components/SearchResults.tsx`.
- **Search and "Ask AI" are now one workspace — the search bar stays put when you switch between them.** Previously `/` (search) and `/ask` (chat) were separate pages, and navigating from one to the other threw away your search. They now share a single shell: the search bar and its results live in a persistent layout, with a **Results ⇄ Chat** switch between the two views. Run a search, flip to Chat to ask about it, flip back — your search is exactly where you left it. Under the hood the search state was lifted out of the monolithic search component into a shared `SearchSession` (both views read the same committed search), so it's the one source of truth features build on. Hub mode is unchanged. See `common/components/{SearchSessionContext,WorkspaceSearchBar,SearchResults,TranscriptSearch}.tsx`, `export/app/(workspace)/*`, and `export/e2e/workspace-shell.spec.ts`.
- **Handing a search to "Ask AI" now passes the *whole* result set, not just the first 20.** Previously only the top 20 videos reached the chat, so it literally couldn't see the rest. Now every match is handed off (up to a generous cap) and presented in tiers: the model gets a compact **index of all matching videos** (title, channel, date, hit count) plus **full excerpts for the top ~12** — and it can pull excerpts for any other result on demand via the existing *fetch_context* tool, or you can click **Load context** on any of them. This keeps the payload bounded while letting the assistant reason over the complete set. See `common/lib/aiHandoff.ts` (tiered `buildSearchHandoff`) and `export/app/lib/askConversation.ts` (`buildTieredGrounding`).
- **"Ask AI" chat — regenerate, edit & resend, and per-answer attribution.** You can now **Regenerate** a completed answer (reusing the excerpts it already gathered), **Edit** any earlier question to pull it back into the composer and re-ask from that point, and see **which model** produced each answer — useful when you switch providers mid-conversation. The Context panel now shows a rough **token estimate** (not just a character count) as a cost cue, a hand-typed model name survives switching providers and back within a session, and a failed "Load context" fetch now says so on that result instead of the spinner just quietly stopping. See `export/app/ask/{MessageBubble,AskChat,PinnedResultsPanel,ContextPanel,useAskChat}.tsx` and `export/app/lib/askConversation.ts`.
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -127,11 +127,29 @@ export default function AskChat() {
expanding={s.expanding}
expandError={s.expandError}
onSetStrict={s.setStrictGrounding}
- onClear={s.clearPinned}
+ onClear={s.detach}
onExpandVideo={s.expandPinnedVideo}
/>
)}
+ {/* 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">
+ <span>
+ Answering freely — your current search isn't grounding these
+ answers.
+ </span>
+ <button
+ type="button"
+ onClick={s.reattach}
+ disabled={busy}
+ className="shrink-0 rounded-md border border-border px-2 py-1 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
+ >
+ Ground in my search
+ </button>
+ </div>
+ )}
+
{(messages.length > 0 || s.contextOverride) && (
<ContextPanel
contextText={s.contextText}
@@ -154,7 +172,7 @@ export default function AskChat() {
{s.pinned ? (
<>
Ask about the {s.pinned.videos.length} result
- {s.pinned.videos.length === 1 ? "" : "s"} you handed off from
+ {s.pinned.videos.length === 1 ? "" : "s"} in your current
search
{s.strictGrounding
? " — the assistant answers only from them, with citations."
diff --git a/export/app/ask/PinnedResultsPanel.tsx b/export/app/ask/PinnedResultsPanel.tsx
@@ -42,7 +42,7 @@ 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 search
+ Grounded in {n} result{n === 1 ? "" : "s"} from your current search
</span>
<span className="truncate font-mono text-xs text-muted-foreground">
{pinned.label}
@@ -53,10 +53,10 @@ export function PinnedResultsPanel({
type="button"
onClick={onClear}
disabled={busy}
- title="Detach these results and let the assistant search normally"
+ title="Answer freely, without grounding in the current search"
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
+ <XIcon className="size-3.5" /> Detach
</button>
</div>
diff --git a/export/app/ask/useAskChat.ts b/export/app/ask/useAskChat.ts
@@ -2,10 +2,8 @@
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 { useSearchSession } from "yt-dlp-transcript-common/components/SearchSessionContext";
+import type { SearchHandoff } from "yt-dlp-transcript-common/lib/aiHandoff";
import { fetchTranscript } from "yt-dlp-transcript-common/components/transcriptCache";
import {
cuesToSnippets,
@@ -32,13 +30,19 @@ 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 Snip = { clock: string; seconds: number; text: string };
+
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).
+ // When grounded in the live search: answer only from it (skip the AI's own
+ // search).
strictGrounding?: boolean;
+ // The user detached from the live search to ask a free-form question.
+ detached?: boolean;
+ // Per-video extra transcript pulled in via "Load context", overlaid onto the
+ // live search grounding (keyed by video slug).
+ enrichments?: Record<string, Snip[]>;
};
const NON_TERMINAL_PHASES = new Set(["gathering", "answering", "streaming"]);
@@ -88,16 +92,37 @@ export function useAskChat() {
const { summaries, summariesReady } = summariesState;
const corpusError = summariesState.error?.message ?? null;
+ // The live "active search" this workspace shares — the chat auto-grounds in it.
+ const { liveGrounding } = useSearchSession();
+
const [messages, setMessages] = useState<UiMessage[]>([]);
// 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.
+ // The user detached from the live search to ask a free-form question (the chat
+ // then searches on its own, ignoring the current search).
+ const [detached, setDetached] = useState(false);
+ // Per-video extra transcript pulled in via "Load context", overlaid onto the
+ // live grounding (keyed by video slug) so an expansion survives search updates.
+ const [enrichments, setEnrichments] = useState<Record<string, Snip[]>>({});
+ // Grounded mode: answer strictly from the live search (skip gather) vs. use it
+ // as a starting point the AI may expand with its own searches.
const [strictGrounding, setStrictGroundingState] = useState(true);
+
+ // The effective pinned grounding = the live search, 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;
+ return {
+ ...liveGrounding,
+ videos: liveGrounding.videos.map((v) =>
+ enrichments[v.key]
+ ? { ...v, snippets: mergeSnippets(v.snippets, enrichments[v.key], 30) }
+ : v,
+ ),
+ };
+ }, [detached, liveGrounding, 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
@@ -148,39 +173,21 @@ export function useAskChat() {
}
setMarkdownOnState(localStorage.getItem(K_MARKDOWN) !== "0");
loadProviderCreds(p, rememberSaved);
- // 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);
+ const rawConvo = localStorage.getItem(K_CONVO);
+ if (rawConvo) {
+ const parsed = JSON.parse(rawConvo) as PersistedConvo;
+ if (Array.isArray(parsed.messages)) {
+ setMessages(parsed.messages.map(normalizeRestoredMessage));
}
- } 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.map(normalizeRestoredMessage));
- }
- 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);
- }
+ if (typeof parsed.contextOverride === "string") {
+ setContextOverride(parsed.contextOverride);
+ }
+ if (typeof parsed.strictGrounding === "boolean") {
+ setStrictGroundingState(parsed.strictGrounding);
+ }
+ if (typeof parsed.detached === "boolean") setDetached(parsed.detached);
+ if (parsed.enrichments && typeof parsed.enrichments === "object") {
+ setEnrichments(parsed.enrichments);
}
}
} catch {
@@ -196,7 +203,13 @@ export function useAskChat() {
if (persistTimer.current) clearTimeout(persistTimer.current);
persistTimer.current = setTimeout(() => {
try {
- if (messages.length === 0 && !contextOverride && !pinned) {
+ const hasEnrichments = Object.keys(enrichments).length > 0;
+ if (
+ messages.length === 0 &&
+ !contextOverride &&
+ !detached &&
+ !hasEnrichments
+ ) {
localStorage.removeItem(K_CONVO);
setStorageWarning(false);
return;
@@ -206,7 +219,15 @@ export function useAskChat() {
// silently failing to save anything.
let msgs = messages;
for (;;) {
- if (writeConvo({ messages: msgs, contextOverride, pinned, strictGrounding })) {
+ if (
+ writeConvo({
+ messages: msgs,
+ contextOverride,
+ strictGrounding,
+ detached,
+ enrichments,
+ })
+ ) {
setStorageWarning(msgs.length < messages.length);
break;
}
@@ -228,7 +249,7 @@ export function useAskChat() {
return () => {
if (persistTimer.current) clearTimeout(persistTimer.current);
};
- }, [messages, contextOverride, pinned, strictGrounding]);
+ }, [messages, contextOverride, strictGrounding, detached, enrichments]);
function loadProviderCreds(p: Provider, rememberOn: boolean) {
// Prefer a model the user typed for this provider earlier this session, so a
@@ -557,27 +578,34 @@ export function useAskChat() {
if (busy) return;
setMessages([]);
setContextOverride(null);
- setPinned(null);
+ setDetached(false);
+ setEnrichments({});
setStrictGroundingState(true);
};
- // Detach the pinned search results → back to a normal AI-decides-search chat.
- const clearPinned = () => {
+ // Detach from the live search → ask a free-form question (the AI searches on
+ // its own). Re-attach binds the current search back as grounding.
+ const detach = () => {
if (busy) return;
- setPinned(null);
- setStrictGroundingState(true);
+ setDetached(true);
};
+ const reattach = () => {
+ if (busy) return;
+ setDetached(false);
+ };
+ // Whether there's a live search available to ground in (regardless of detach).
+ const hasLiveSearch = !!liveGrounding;
const setStrictGrounding = (on: boolean) => setStrictGroundingState(on);
- // User-driven "expand this hit": read the surrounding transcript for a pinned
- // video (client-side, IDB-cached) and merge it into that video's snippets, so
- // the assistant reads the fuller context on the next question. Works in strict
- // AND expand mode, on every provider — no LLM call. Persisted with the convo.
+ // User-driven "expand this hit": read the surrounding transcript for a grounded
+ // video (client-side, IDB-cached) and stash it as an enrichment overlaid onto
+ // the live grounding, so the assistant reads the fuller context on the next
+ // question. Works in strict AND expand mode, on every provider — no LLM call.
+ // Persisted with the convo; survives the live search updating around it.
const expandPinnedVideo = useCallback(
async (key: string, aroundSeconds?: number) => {
- const pin = pinnedRef.current;
- const v = pin?.videos.find((x) => x.key === key);
+ const v = pinnedRef.current?.videos.find((x) => x.key === key);
if (!v) return;
const center =
typeof aroundSeconds === "number"
@@ -599,21 +627,13 @@ export function useAskChat() {
maxCues: 60,
}),
);
- setPinned((prev) =>
- prev
- ? {
- ...prev,
- videos: prev.videos.map((x) =>
- x.key === key
- ? { ...x, snippets: mergeSnippets(x.snippets, snips, 30) }
- : x,
- ),
- }
- : prev,
- );
+ setEnrichments((e) => ({
+ ...e,
+ [key]: mergeSnippets(e[key] ?? [], snips, 30),
+ }));
} catch {
- // Transcript unavailable — leave the pin untouched but flag it so the
- // panel can surface "couldn't load" instead of silently stopping.
+ // Transcript unavailable — flag it so the panel can surface "couldn't
+ // load" instead of the spinner silently stopping.
setExpandError((e) => ({ ...e, [key]: true }));
} finally {
setExpanding((e) => {
@@ -694,7 +714,10 @@ export function useAskChat() {
pinned,
strictGrounding,
setStrictGrounding,
- clearPinned,
+ detached,
+ detach,
+ reattach,
+ hasLiveSearch,
expandPinnedVideo,
expanding,
expandError,
diff --git a/export/e2e/ask-chat.spec.ts b/export/e2e/ask-chat.spec.ts
@@ -1,6 +1,70 @@
import { expect, test, type Page } from "@playwright/test";
+import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "./fixtures/data";
import { installRoutes } from "./helpers";
+// ─── Live-search grounding helpers ──────────────────────────────────────────
+// The chat auto-grounds in the workspace's active search (no sessionStorage
+// hand-off). These tests therefore produce grounding by running a REAL search
+// on `/` (via the ?qt= query tree, same encoding as query-tree.spec) over the
+// mocked fixtures, then crossing to the chat via the Results⇄Chat nav so the
+// shared SearchSession stays mounted and the chat picks the results up live.
+type SLeaf = {
+ k: "l";
+ q: string;
+ s: "transcripts" | "chat" | "metadata" | "description" | "tags";
+ n?: 1;
+ h?: 0;
+};
+type SGroup = { k: "g"; o: "AND" | "OR"; n?: 1; c: (SLeaf | SGroup)[] };
+
+function qt(root: SGroup): string {
+ return encodeURIComponent(JSON.stringify(root));
+}
+
+// The video whose fixture transcript has cues at 5s/50s/100s — grounding on it
+// lets "Load context"/fetch_context window past the matched 5s hit into the 50s
+// "beta line".
+const REAL_KEY = `${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`;
+
+// A single-video grounding: transcripts:"alpha" matches all three fixtures, and
+// the metadata leaf (filter-only, h:0 — contributes no hits) narrows to just
+// VIDEO_TRANSCRIPT_ONLY. So the chat grounds in ONE result carrying exactly the
+// one matched excerpt (the 5s "alpha line").
+const ONE_RESULT_TREE: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [
+ { k: "l", q: "alpha", s: "transcripts" },
+ { k: "l", q: "Transcript only", s: "metadata", h: 0 },
+ ],
+};
+
+// A two-video grounding: transcripts:"alpha" AND NOT chat:"message" keeps
+// VIDEO_TRANSCRIPT_ONLY (no chat) and VIDEO_CHAT_LARGE (chat without "message"),
+// dropping VIDEO_CHAT_SMALL. Only the transcripts leaf contributes hits, so each
+// grounded video carries its 5s "alpha line" excerpt.
+const TWO_RESULT_TREE: SGroup = {
+ k: "g",
+ o: "AND",
+ c: [
+ { k: "l", q: "alpha", s: "transcripts" },
+ { k: "l", q: "message", s: "chat", n: 1 },
+ ],
+};
+
+// Run a real search on `/` (via qt=), wait for it to settle, then cross to the
+// chat through the shared workspace nav so it auto-grounds in those results.
+async function searchThenChat(page: Page, tree: SGroup, cardCount: number) {
+ await page.goto(`/?qt=${qt(tree)}`);
+ await expect(page.locator("[data-card-header]")).toHaveCount(cardCount, {
+ timeout: 15_000,
+ });
+ await page
+ .getByTestId("workspace-nav")
+ .getByRole("link", { name: "Chat" })
+ .click();
+}
+
// The /ask agentic chat. Retrieval runs client-side over the mocked transcript
// fixtures (installRoutes); the AI provider is mocked here. We drive the
// provider-agnostic "scripted" mode so a single Anthropic SSE mock covers every
@@ -230,39 +294,6 @@ 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, mode: "Scripted" | "Native tools" = "Scripted") {
// Fill the key FIRST and confirm it sticks: a value that survives proves
// React has hydrated and attached its handlers, so the mode-button click
@@ -275,41 +306,6 @@ test.describe("ask chat", () => {
await page.getByRole("button", { name: mode }).click();
}
- // A hand-off whose video key resolves against the transcript fixtures
- // (installRoutes), so fetch_context / "Load context" can pull real cues. The
- // fixture cues sit at 5s, 50s, 100s; a window around the 5s hit pulls in the
- // 50s "beta line" — the signal that a fetch enriched the grounding.
- const REAL_KEY = "test-channel/vid-transcript-only";
- const FETCH_HANDOFF = {
- label: "alpha",
- videos: [
- {
- key: REAL_KEY,
- videoId: "vid-transcript-only",
- title: "Transcript only",
- channel: "Test Channel",
- uploadDate: "20200101",
- url: "https://x/v1",
- snippets: [
- { clock: "0:05", seconds: 5, text: "transcript-only video — alpha line" },
- ],
- },
- ],
- totalVideos: 1,
- truncated: false,
- };
-
- // Seed once — guarded so a reload doesn't re-seed (which would clobber the
- // enriched pin restored from localStorage).
- async function seedFetchHandoff(page: Page) {
- await page.addInitScript((h) => {
- if (!sessionStorage.getItem("seeded_once")) {
- sessionStorage.setItem("ytdlp-tb:ai:handoff", JSON.stringify(h));
- sessionStorage.setItem("seeded_once", "1");
- }
- }, FETCH_HANDOFF);
- }
-
test("expand mode: the model reads more transcript via fetch_context", async ({
page,
}) => {
@@ -359,8 +355,7 @@ test.describe("ask chat", () => {
body: sse("DONE"),
});
});
- await seedFetchHandoff(page);
- await page.goto("/ask/");
+ await searchThenChat(page, ONE_RESULT_TREE, 1);
await keyIn(page, "Native tools");
await expect(page.getByText(/Grounded in 1 result/)).toBeVisible();
@@ -407,8 +402,18 @@ test.describe("ask chat", () => {
body: sse("SEARCH: alpha"),
});
});
- await seedFetchHandoff(page);
- await page.goto("/ask/");
+ // Run the search and COMMIT it (click Search) so the query persists to
+ // localStorage — the reload below lands on /ask/, whose URL carries no qt=,
+ // and the live grounding must be restored from the persisted search.
+ await page.goto(`/?qt=${qt(ONE_RESULT_TREE)}`);
+ await expect(page.locator("[data-card-header]")).toHaveCount(1, {
+ timeout: 15_000,
+ });
+ await page.getByTestId("search-submit").click();
+ await page
+ .getByTestId("workspace-nav")
+ .getByRole("link", { name: "Chat" })
+ .click();
await keyIn(page);
await expect(page.getByText(/Grounded in 1 result/)).toBeVisible();
@@ -419,11 +424,13 @@ test.describe("ask chat", () => {
// Windowing pulled in the neighbouring cue → the count grows.
await expect(page.getByText(/2 excerpts/)).toBeVisible();
- // Persists across a reload (stored in the conversation). The key is only
- // saved on send, so re-enter it after reloading.
+ // Persists across a reload (enrichment stored in the conversation; the live
+ // search restored from localStorage). The key is only saved on send, so
+ // re-enter it after reloading.
await page.waitForTimeout(600);
await page.reload();
await keyIn(page);
+ await expect(page.getByText(/Grounded in 1 result/)).toBeVisible();
await page.getByRole("button", { name: /Show the 1 video/ }).click();
await expect(page.getByText(/2 excerpts/)).toBeVisible();
@@ -466,8 +473,7 @@ test.describe("ask chat", () => {
body: sse(text),
});
});
- await seedFetchHandoff(page);
- await page.goto("/ask/");
+ await searchThenChat(page, ONE_RESULT_TREE, 1);
await keyIn(page, "Scripted");
await expect(page.getByText(/Grounded in 1 result/)).toBeVisible();
@@ -478,7 +484,7 @@ test.describe("ask chat", () => {
expect(fetchToolEverOffered).toBe(false);
});
- test("pinned strict grounding answers from the handed-off results, no search", async ({
+ test("pinned strict grounding answers from the live search, no search", async ({
page,
}) => {
await installRoutes(page);
@@ -495,8 +501,9 @@ test.describe("ask chat", () => {
};
const system = body.system ?? "";
if (system.includes("Markdown")) {
+ // The real matched excerpts (the 5s "alpha line" cues) reach the answer.
answerSawExcerpts = JSON.stringify(body.messages ?? []).includes(
- "alpha excerpt one",
+ "alpha line",
);
await route.fulfill({
status: 200,
@@ -513,8 +520,7 @@ test.describe("ask chat", () => {
body: sse("SEARCH: alpha"),
});
});
- await seedHandoff(page);
- await page.goto("/ask/");
+ await searchThenChat(page, TWO_RESULT_TREE, 2);
await keyIn(page);
await expect(page.getByText(/Grounded in 2 results/)).toBeVisible();
@@ -559,8 +565,7 @@ test.describe("ask chat", () => {
body: sse(text),
});
});
- await seedHandoff(page);
- await page.goto("/ask/");
+ await searchThenChat(page, TWO_RESULT_TREE, 2);
await keyIn(page);
await expect(page.getByText(/Grounded in 2 results/)).toBeVisible();
@@ -568,27 +573,41 @@ test.describe("ask chat", () => {
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.
+ // A gather search ran, and the grounded fixture video is among the citations.
expect(gatherCalls).toBeGreaterThan(0);
- await expect(page.getByText("Alpha talk", { exact: false }).first()).toBeVisible();
+ await expect(
+ page.getByText("Transcript only", { exact: false }).first(),
+ ).toBeVisible();
});
- test("clearing the pin returns to a normal AI-decides-search chat", async ({
+ test("detaching from the live search returns to a normal AI-decides-search chat", async ({
page,
}) => {
await installRoutes(page);
await mockAnthropic(page);
- await seedHandoff(page);
- await page.goto("/ask/");
+ await searchThenChat(page, TWO_RESULT_TREE, 2);
await keyIn(page);
await expect(page.getByText(/Grounded in 2 results/)).toBeVisible();
- await page.getByRole("button", { name: "Clear" }).click();
+ // Detach → the grounding panel disappears…
+ await page.getByRole("button", { name: "Detach" }).click();
await expect(page.getByText(/Grounded in 2 results/)).toHaveCount(0);
+ // …and, with a live search still available, a re-attach affordance appears.
+ await expect(
+ page.getByRole("button", { name: "Ground in my search" }),
+ ).toBeVisible();
// Back to the normal empty-state copy.
await expect(
page.getByText(/The assistant searches for what it needs/),
).toBeVisible();
+
+ // Asking now runs a normal AI-decided search (the scripted mock searches
+ // "alpha", then answers) — grounding no longer short-circuits the gather.
+ await ask(page, "tell me about the alpha discussion");
+ await expect(page.getByText(/Searched:\s*alpha/)).toBeVisible();
+ await expect(
+ page.locator("li", { hasText: "point one" }).first(),
+ ).toBeVisible();
});
test("editing the context changes what the next turn sends", async ({
diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts
@@ -484,7 +484,7 @@ 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 ({
+ test("the live search auto-grounds the /ask chat via the Results⇄Chat nav", async ({
page,
}) => {
const tree: SGroup = {
@@ -498,10 +498,12 @@ test.describe("composite search — query tree", () => {
CHAT_SMALL_SLUG,
CHAT_LARGE_SLUG,
]);
+ // Cross to the chat via the shared workspace nav (the search stays mounted),
+ // and it auto-grounds in the three live results — no hand-off button.
await page
- .getByRole("button", { name: "Ask AI about these results" })
+ .getByTestId("workspace-nav")
+ .getByRole("link", { name: "Chat" })
.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();