"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowDownIcon, PlusIcon } from "lucide-react"; import { usePlayerOptional } from "yt-dlp-transcript-common/components/PlayerProvider"; import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext"; import { useMediaQuery } from "yt-dlp-transcript-common/lib/useMediaQuery"; import { DEFAULT_SWEEP_DIRECTIVE, useAskChat } from "./useAskChat"; import { ProviderSettings } from "./ProviderSettings"; import { ContextPanel } from "./ContextPanel"; import { ReportPanel } from "./ReportPanel"; import { PinnedResultsPanel } from "./PinnedResultsPanel"; import { GroundingPalette } from "./GroundingPalette"; import { SavedChatsRow } from "./SavedChatsRow"; import { MessageBubble } from "./MessageBubble"; import { Composer } from "./Composer"; import { NO_ARCHIVES_IN_SCOPE, NO_ARCHIVES_ON_HUB } from "./hubScopeCopy"; import { LinkedCopy } from "./LinkedCopy"; export default function AskChat() { const s = useAskChat(); const { messages, busy, channels, corpusError, summariesReady, apiKey, input, setInput, markdownOn, } = s; // Hub only (single-site has no `federation`): nothing to search. The // provider never reads ready over an empty scope, so once the hub's list is // in the composer says why instead of "Loading transcripts…" — the hub has // no archives at all, or this browser switched every one off with the front // page's scope chips. Before the list is in, it is still loading. const { federation } = useSearchData(); const blockedCopy = !federation?.listed ? undefined : federation.sites.length === 0 ? NO_ARCHIVES_ON_HUB : federation.sites.every((f) => f.status === "off") ? NO_ARCHIVES_IN_SCOPE : undefined; // Opening a citation seeks the shared transcript modal to the cited line. It // writes ?v=&t=&vm= via replaceState on this same /ask route (no navigation, // no history push) and doesn't touch `messages`, so the bottom-scroll effect // never fires and the chat scroll position survives — dismissing the modal // returns the reader to exactly where they were. Null in hub /ask (no player). const player = usePlayerOptional(); const onOpenCitation = player ? (slug: string, seconds?: number) => player.openTranscript(slug, seconds, { mode: "transcript" }) : undefined; const scrollRef = useRef(null); const composerRef = useRef(null); const stuckRef = useRef(true); const [showJump, setShowJump] = useState(false); // From lg the message list is its own scroller, sized so the composer stays // on screen beside it. Below lg there is no room for a scroller inside a // scroller: the messages ride the DOCUMENT scroll and the composer sticks to // the bottom of the viewport instead. const nested = useMediaQuery("(min-width: 1024px)"); const [groundingOpen, setGroundingOpen] = useState(null); const rafRef = useRef(null); // Fill the composer from a suggestion chip and focus it, so the reader can // tweak and press Enter (chips used to fill the box silently, with no focus). const fillSuggestion = (q: string) => { setInput(q); composerRef.current?.focus(); }; // Whichever element is actually doing the scrolling. Both branches speak the // same three numbers, so the stick-to-bottom logic below is written once. const metrics = () => { if (nested) { const el = scrollRef.current; return el ? { top: el.scrollTop, height: el.scrollHeight, client: el.clientHeight, } : null; } const doc = document.scrollingElement; return doc ? { top: doc.scrollTop, height: doc.scrollHeight, client: doc.clientHeight, } : null; }; // Smart auto-scroll: only stick to the bottom when the reader is already near // it, so streaming text doesn't yank the view while they scroll back to read. const onScroll = () => { const m = metrics(); if (!m) return; const nearBottom = m.height - m.top - m.client < 60; stuckRef.current = nearBottom; setShowJump(!nearBottom && messages.length > 0); }; // Below lg the scroll listener has to be on the window, because the document // is what scrolls. useEffect(() => { if (nested) return; window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); // onScroll closes over `messages` only to decide whether to offer the jump // pill; re-binding on every token would be worse than a slightly stale count. // eslint-disable-next-line react-hooks/exhaustive-deps }, [nested, messages.length]); useEffect(() => { if (!stuckRef.current) return; if (nested) { const el = scrollRef.current; if (el) el.scrollTo({ top: el.scrollHeight }); return; } // Nothing to be at the bottom of yet — and scrolling the document on a // fresh /ask would drop the reader at the footer. if (messages.length === 0) return; // The chat pane is CSS-hidden (not unmounted) while the reader is on `/`, // and a sweep keeps appending messages there. Scrolling the DOCUMENT then // would yank the SEARCH page out from under them — so only do it when this // pane is actually laid out. if (!scrollRef.current?.getClientRects().length) return; // One rAF per frame, not one scroll per streamed token. if (rafRef.current !== null) return; rafRef.current = requestAnimationFrame(() => { rafRef.current = null; const doc = document.scrollingElement; if (doc) window.scrollTo({ top: doc.scrollHeight }); }); }, [messages, nested]); useEffect( () => () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }, [], ); const jumpToLatest = () => { if (nested) { const el = scrollRef.current; if (!el) return; el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); } else { const doc = document.scrollingElement; if (!doc) return; window.scrollTo({ top: doc.scrollHeight, behavior: "smooth" }); } stuckRef.current = true; setShowJump(false); }; 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.", "Where is a specific person or case talked about?", ]; if (channels[0]) base.unshift(`What does ${channels[0].name} focus on?`); return base.slice(0, 4); }, [channels, s.pinned]); return (
{corpusError && (

Couldn't load the transcript index ({corpusError}). This chat needs the site's summaries manifest.

)} {s.storageWarning && (

This conversation grew too large to save fully — older turns won't be restored if you reload. Start a new chat to reset.

)} {(messages.length > 0 || s.contextOverride || s.pinned) && (
{s.contextOverride ? "Continuing from an edited context." : s.pinned ? "Grounded in your search results." : "Conversation is saved in this browser."}
)} {/* One line that says what the answers are grounded in, with the palette and the pinned-results panel behind a chevron. Expanded from lg, where there is room for both; collapsed on a phone, where they were two screens of chrome above the question box. */} {s.pinned && (
setGroundingOpen((e.currentTarget as HTMLDetailsElement).open) } className="rounded-lg border border-border bg-card/40" > {/* Deliberately NOT phrased "Grounded in N results": that exact sentence is the PinnedResultsPanel's, and several specs match it unscoped — a second copy in this summary is a strict-mode violation, not a passing test. */} {s.selectedCount > 0 ? `Grounding: ${s.selectedCount} selected video${s.selectedCount === 1 ? "" : "s"}` : `Grounding: ${s.pinned.videos.length} result${s.pinned.videos.length === 1 ? "" : "s"} · whole search`}
{ if (input.trim()) s.send(); else composerRef.current?.focus(); }} onSweep={(mode) => s.sweep({ directive: input, mode })} onPreset={fillSuggestion} /> s.sweep({ directive: input, mode })} onStopSweep={s.stop} onPauseSweep={s.pauseSweep} />
)} {/* A paused sweep — resumable even after a reload or a model switch. Shows whenever a checkpoint exists and no sweep is currently running. */} {s.sweepPaused && s.sweepCheckpoint && (
Paused: {s.sweepCheckpoint.reason} — {s.sweepCheckpoint.done} of{" "} {s.sweepCheckpoint.total} batches done,{" "} {s.sweepCheckpoint.remainingSlugs.length} result {s.sweepCheckpoint.remainingSlugs.length === 1 ? "" : "s"} left.
)} {/* Detached with a live search available → offer to ground in it again. */} {s.detached && s.hasLiveSearch && (
Answering freely — your current search isn't grounding these answers.
)} {(messages.length > 0 || s.contextOverride) && ( )}
{messages.length === 0 ? (

{s.pinned ? ( <> Ask about the {s.pinned.videos.length} result {s.pinned.videos.length === 1 ? "" : "s"} in your current 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. )}

{suggestions.map((q) => ( ))}
) : ( messages.map((m, i) => ( s.retry(i) : undefined} onEdit={ m.role === "user" && !busy ? () => s.editUserMessage(i) : undefined } onOpenCitation={onOpenCitation} /> )) )}
{showJump && ( )}
: undefined} hasKey={!!apiKey.trim()} markdownOn={markdownOn} setMarkdownOn={s.setMarkdownOn} textareaRef={composerRef} />
); }