Archilyzer · Source

archilyzer

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

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:
Mcommon/components/SearchResults.tsx | 9---------
Mcommon/components/SearchSessionContext.tsx | 29+++++++++++++----------------
Mcommon/lib/aiHandoff.ts | 11+++++------
Mexport/CHANGELOG.md | 1+
Mexport/app/ask/AskChat.tsx | 22++++++++++++++++++++--
Mexport/app/ask/PinnedResultsPanel.tsx | 6+++---
Mexport/app/ask/useAskChat.ts | 169+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mexport/e2e/ask-chat.spec.ts | 195+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mexport/e2e/query-tree.spec.ts | 8+++++---
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&apos;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();