commit 163e4481a7d621df00ae6c2647169dbab3c3a5fa
parent a4a71723b3ebcd3fab8face32fbb144bf4a6d03a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 29 Aug 2026 20:29:05 -0400
ask: the question box is always on screen
Below lg the chat drops its nested max-h-[60vh] scroller — a scroller
inside the document scroller — and the composer sticks to the bottom of
the viewport instead, so the box you type in is never a scroll away.
The grounding palette and the pinned panel fold behind one line that
says what the answers are grounded in, and the provider knobs nobody
sets on the first visit move behind Advanced.
Auto-scroll now reads whichever element is actually scrolling, throttled
through rAF, and refuses to move the page when the pane is the hidden
one on the search route.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
6 files changed, 231 insertions(+), 113 deletions(-)
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowDownIcon, PlusIcon } from "lucide-react";
import { usePlayerOptional } from "yt-dlp-transcript-common/components/PlayerProvider";
+import { useMediaQuery } from "yt-dlp-transcript-common/lib/useMediaQuery";
import { DEFAULT_SWEEP_DIRECTIVE, useAskChat } from "./useAskChat";
import { ProviderSettings } from "./ProviderSettings";
import { ContextPanel } from "./ContextPanel";
@@ -42,6 +43,13 @@ export default function AskChat() {
const composerRef = useRef<HTMLTextAreaElement | null>(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<boolean | null>(null);
+ const rafRef = useRef<number | null>(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).
@@ -50,23 +58,91 @@ export default function AskChat() {
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 el = scrollRef.current;
- if (!el) return;
- const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
+ 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(() => {
- const el = scrollRef.current;
- if (el && stuckRef.current) el.scrollTo({ top: el.scrollHeight });
- }, [messages]);
+ 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 = () => {
- const el = scrollRef.current;
- if (!el) return;
- el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
+ 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);
};
@@ -163,7 +239,28 @@ export default function AskChat() {
</div>
)}
+ {/* 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 && (
+ <details
+ open={groundingOpen ?? nested}
+ onToggle={(e) =>
+ 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. */}
+ <summary className="cursor-pointer px-4 py-2.5 text-xs text-muted-foreground">
+ {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`}
+ </summary>
+ <div className="flex flex-col gap-4 border-t border-border p-3">
<GroundingPalette
pinned={s.pinned}
effectiveMode={s.effectiveGroundingMode}
@@ -183,9 +280,6 @@ export default function AskChat() {
onSweep={(mode) => s.sweep({ directive: input, mode })}
onPreset={fillSuggestion}
/>
- )}
-
- {s.pinned && (
<PinnedResultsPanel
pinned={s.pinned}
groundingMode={s.effectiveGroundingMode}
@@ -210,6 +304,8 @@ export default function AskChat() {
onStopSweep={s.stop}
onPauseSweep={s.pauseSweep}
/>
+ </div>
+ </details>
)}
{/* A paused sweep — resumable even after a reload or a model switch. Shows
@@ -289,8 +385,8 @@ export default function AskChat() {
<div className="relative">
<div
ref={scrollRef}
- onScroll={onScroll}
- className="flex max-h-[60vh] min-h-[8rem] flex-col gap-4 overflow-y-auto"
+ onScroll={nested ? onScroll : undefined}
+ className="flex min-h-[8rem] flex-col gap-4 lg:max-h-[calc(100dvh-16rem)] lg:overflow-y-auto"
>
{messages.length === 0 ? (
<div className="flex flex-col gap-3">
@@ -348,7 +444,10 @@ export default function AskChat() {
<button
type="button"
onClick={jumpToLatest}
- className="absolute bottom-2 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card px-3 py-1 text-xs text-muted-foreground shadow-sm transition-colors hover:text-foreground animate-in fade-in motion-reduce:animate-none"
+ // Below lg the scroller is the document, so an `absolute` pill
+ // inside it would scroll away with the messages: pin it to the
+ // viewport, just above the sticky composer.
+ className="max-lg:fixed max-lg:bottom-28 lg:absolute bottom-2 left-1/2 z-20 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card px-3 py-1 text-xs text-muted-foreground shadow-sm transition-colors hover:text-foreground animate-in fade-in motion-reduce:animate-none"
>
<ArrowDownIcon className="size-3.5" /> Jump to latest
</button>
diff --git a/export/app/ask/Composer.tsx b/export/app/ask/Composer.tsx
@@ -35,7 +35,11 @@ export function Composer(props: Props) {
e.preventDefault();
send();
}}
- className="flex flex-col gap-2"
+ // Below lg the messages ride the document scroll, so the composer is
+ // pinned to the bottom of the viewport — the question box is the point of
+ // the page and should never be a scroll away. `-mx-4 px-4` lets its
+ // background span the padded column it sits in.
+ className="flex flex-col gap-2 max-lg:sticky max-lg:bottom-0 max-lg:z-10 max-lg:-mx-4 max-lg:border-t max-lg:border-border max-lg:bg-background max-lg:px-4 max-lg:pt-2 max-lg:pb-[calc(0.5rem+env(safe-area-inset-bottom,0px))]"
>
<textarea
ref={textareaRef}
@@ -55,7 +59,10 @@ export function Composer(props: Props) {
: "Loading transcripts…"
}
disabled={!summariesReady}
- className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground"
+ // text-base below md: iOS zooms the page on focus for anything under
+ // 16px, and it never zooms back out.
+ onFocus={(e) => e.currentTarget.scrollIntoView({ block: "end" })}
+ className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-base md:text-sm text-foreground"
/>
<div className="flex flex-wrap items-center gap-2">
<button
diff --git a/export/app/ask/MessageBubble.tsx b/export/app/ask/MessageBubble.tsx
@@ -25,7 +25,7 @@ function CopyButton({ text }: { text: string }) {
<button
type="button"
onClick={() => void copy()}
- className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
+ className="inline-flex min-h-9 items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
aria-label="Copy answer"
>
{state === "copied" ? (
diff --git a/export/app/ask/ProviderSettings.tsx b/export/app/ask/ProviderSettings.tsx
@@ -198,107 +198,116 @@ export function ProviderSettings(props: Props) {
</span>
</div>
- {/* Answer length: raise it so long reports don't truncate at the cap. */}
- <label className="flex flex-col gap-1 text-xs text-muted-foreground">
- Max answer length (tokens)
- <input
- type="number"
- min={256}
- max={32768}
- step={512}
- value={maxAnswerTokens}
- onChange={(e) => setMaxAnswerTokens(Number(e.target.value))}
- className="w-32 rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground"
- />
- <span className="text-muted-foreground/70">
- How long an answer can be before the model cuts it off. Gemini,
- OpenAI, and Claude all support ~8k; raise it for big reports.
- </span>
- </label>
-
- {/* Sweep batch size: how many result videos each corpus-sweep batch folds
- into the report. Smaller → more, lighter batches (kinder to rate limits
- + easier to pause/resume); bigger → fewer, heavier passes. */}
- <label className="flex flex-col gap-1 text-xs text-muted-foreground">
- Sweep batch size (videos per batch)
- <input
- type="number"
- min={1}
- max={100}
- step={1}
- value={sweepBatch}
- onChange={(e) => setSweepBatch(Number(e.target.value))}
- className="w-32 rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground"
- />
- <span className="text-muted-foreground/70">
- How many videos a whole-corpus sweep reads per batch. Lower it if a
- sweep keeps hitting your provider’s rate limit.
- </span>
- </label>
+ {/* Everything below is a knob, not a setup step: a first-time reader
+ needs provider, model and key, and nothing else. */}
+ <details className="rounded-md border border-border">
+ <summary className="cursor-pointer px-3 py-2 text-xs font-medium text-foreground">
+ Advanced
+ </summary>
+ <div className="flex flex-col gap-3 border-t border-border px-3 py-3">
+ {/* Answer length: raise it so long reports don't truncate at the cap. */}
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Max answer length (tokens)
+ <input
+ type="number"
+ min={256}
+ max={32768}
+ step={512}
+ value={maxAnswerTokens}
+ onChange={(e) => setMaxAnswerTokens(Number(e.target.value))}
+ className="w-32 rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground"
+ />
+ <span className="text-muted-foreground/70">
+ How long an answer can be before the model cuts it off. Gemini,
+ OpenAI, and Claude all support ~8k; raise it for big reports.
+ </span>
+ </label>
- {/* Requests per minute: paces every AI call so a free-tier key stays
- under its per-minute cap instead of erroring. Defaults are conservative
- per-model guesses (shown as the placeholder); set a higher value for a
- paid key. Auto-lowers itself after a rate-limit (429). */}
- <label className="flex flex-col gap-1 text-xs text-muted-foreground">
- Requests per minute
- <div className="flex items-center gap-2">
+ {/* Sweep batch size: how many result videos each corpus-sweep batch folds
+ into the report. Smaller → more, lighter batches (kinder to rate limits
+ + easier to pause/resume); bigger → fewer, heavier passes. */}
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Sweep batch size (videos per batch)
<input
type="number"
min={1}
- max={100000}
+ max={100}
step={1}
- value={rpmOverride ?? ""}
- placeholder={String(rpmDefault)}
- onChange={(e) => {
- const v = e.target.value.trim();
- setRpm(v === "" ? null : Number(v));
- }}
- aria-label="Requests per minute"
+ value={sweepBatch}
+ onChange={(e) => setSweepBatch(Number(e.target.value))}
className="w-32 rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground"
/>
- {rpmOverride != null && (
+ <span className="text-muted-foreground/70">
+ How many videos a whole-corpus sweep reads per batch. Lower it if a
+ sweep keeps hitting your provider’s rate limit.
+ </span>
+ </label>
+
+ {/* Requests per minute: paces every AI call so a free-tier key stays
+ under its per-minute cap instead of erroring. Defaults are conservative
+ per-model guesses (shown as the placeholder); set a higher value for a
+ paid key. Auto-lowers itself after a rate-limit (429). */}
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Requests per minute
+ <div className="flex items-center gap-2">
+ <input
+ type="number"
+ min={1}
+ max={100000}
+ step={1}
+ value={rpmOverride ?? ""}
+ placeholder={String(rpmDefault)}
+ onChange={(e) => {
+ const v = e.target.value.trim();
+ setRpm(v === "" ? null : Number(v));
+ }}
+ aria-label="Requests per minute"
+ className="w-32 rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground"
+ />
+ {rpmOverride != null && (
+ <button
+ type="button"
+ onClick={() => setRpm(null)}
+ className="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
+ >
+ Reset to default
+ </button>
+ )}
+ </div>
+ <span className="text-muted-foreground/70">
+ Paces requests to ≈{spacingLabel} apart so a free-tier key stays under
+ its per-minute limit. Blank uses the per-model default ({rpmDefault}).
+ It auto-lowers itself after a rate-limit (429).
+ </span>
+ </label>
+
+ {/* Debug export: a redacted snapshot for troubleshooting. */}
+ <div className="flex flex-col gap-1 text-xs text-muted-foreground">
+ <span>Debug</span>
+ <div className="flex flex-wrap items-center gap-2">
+ <button
+ type="button"
+ onClick={downloadDebug}
+ className="rounded-md border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
+ >
+ Download debug JSON
+ </button>
<button
type="button"
- onClick={() => setRpm(null)}
- className="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
+ onClick={() => void copyDebug()}
+ className="rounded-md border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
- Reset to default
+ {debugCopied ? "Copied" : "Copy debug JSON"}
</button>
- )}
+ </div>
+ <span className="text-muted-foreground/70">
+ A redacted snapshot of this chat plus the most recent API calls
+ (finish reasons + payloads) for troubleshooting. Your API key is
+ never included.
+ </span>
</div>
- <span className="text-muted-foreground/70">
- Paces requests to ≈{spacingLabel} apart so a free-tier key stays under
- its per-minute limit. Blank uses the per-model default ({rpmDefault}).
- It auto-lowers itself after a rate-limit (429).
- </span>
- </label>
-
- {/* Debug export: a redacted snapshot for troubleshooting. */}
- <div className="flex flex-col gap-1 text-xs text-muted-foreground">
- <span>Debug</span>
- <div className="flex flex-wrap items-center gap-2">
- <button
- type="button"
- onClick={downloadDebug}
- className="rounded-md border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
- >
- Download debug JSON
- </button>
- <button
- type="button"
- onClick={() => void copyDebug()}
- className="rounded-md border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
- >
- {debugCopied ? "Copied" : "Copy debug JSON"}
- </button>
</div>
- <span className="text-muted-foreground/70">
- A redacted snapshot of this chat plus the most recent API calls
- (finish reasons + payloads) for troubleshooting. Your API key is
- never included.
- </span>
- </div>
+ </details>
<p className="text-xs text-muted-foreground/80">
Your key is stored only{" "}
diff --git a/export/app/ask/SavedChatsRow.tsx b/export/app/ask/SavedChatsRow.tsx
@@ -44,7 +44,7 @@ export function SavedChatsRow({
type="button"
onClick={onSaveAs}
disabled={busy}
- className="text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
+ className="inline-flex min-h-9 items-center text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Save as…
</button>
@@ -64,7 +64,7 @@ export function SavedChatsRow({
value={activeName ?? ""}
onChange={(e) => e.target.value && onLoad(e.target.value)}
data-testid="saved-chat-select"
- className="rounded border border-border bg-card px-2 py-0.5 text-sm text-foreground"
+ className="min-h-9 rounded border border-border bg-card px-2 py-0.5 text-base sm:text-sm text-foreground"
>
<option value="">(unsaved)</option>
{names.map((n) => (
@@ -87,7 +87,7 @@ export function SavedChatsRow({
type="button"
onClick={onSave}
disabled={busy || !activeName || !dirty}
- className="text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
+ className="inline-flex min-h-9 items-center text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Save
</button>
@@ -95,7 +95,7 @@ export function SavedChatsRow({
type="button"
onClick={onSaveAs}
disabled={busy}
- className="text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
+ className="inline-flex min-h-9 items-center text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Save as…
</button>
@@ -103,7 +103,7 @@ export function SavedChatsRow({
type="button"
onClick={onRename}
disabled={busy || !activeName}
- className="text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
+ className="inline-flex min-h-9 items-center text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Rename
</button>
@@ -111,7 +111,7 @@ export function SavedChatsRow({
type="button"
onClick={onDelete}
disabled={busy || !activeName}
- className="text-xs text-destructive transition-colors hover:opacity-80 disabled:opacity-50"
+ className="inline-flex min-h-9 items-center text-xs text-destructive transition-colors hover:opacity-80 disabled:opacity-50"
>
Delete
</button>
diff --git a/export/e2e/ask-chat.spec.ts b/export/e2e/ask-chat.spec.ts
@@ -274,7 +274,10 @@ test.describe("ask chat", () => {
await setup(page);
await ask(page, "tell me about the alpha discussion");
await expect(page.locator("li", { hasText: "point one" }).first()).toBeVisible();
- // The settings panel stays open after keying in; download the debug JSON.
+ // The settings panel stays open after keying in; the debug export lives
+ // behind its "Advanced" disclosure (provider, model and key are the only
+ // top-level settings now).
+ await page.getByText("Advanced", { exact: true }).click();
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "Download debug JSON" }).click(),