commit 5d8ca107a1680e413f16c5215a7d60d961778698
parent a906f1341598f10d3ce8915f7e36660b33013b00
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 16 Jul 2026 22:01:09 -0400
Ask chat: clickable [n] citations + composer/copy niceties
- Inline [n] citation markers in an answer now link to their source: a
smooth in-page scroll + brief highlight, no new tab / no navigation.
Markdown gains an optional `linkComponent` (backward-compatible; the
changelog renderer is unchanged); MessageBubble linkifies [n] (skipping
code spans, only 1..sourceCount) and anchors each source <li>.
- Suggestion chips now focus the composer when picked (were filling it
silently), via a textarea ref threaded through Composer.
- The answer Copy button reports "Copy failed" when the clipboard is
unavailable (insecure origin) instead of silently no-op'ing.
Verified: tsc clean (export + common); no new lint errors; full e2e 109/109
(+1 citation-click test).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
6 files changed, 124 insertions(+), 14 deletions(-)
diff --git a/common/components/Markdown.tsx b/common/components/Markdown.tsx
@@ -8,6 +8,7 @@
// Pure/presentational and safe in a client tree. markdown-to-jsx escapes raw
// HTML by default, so streaming model output can't inject markup.
+import type { AnchorHTMLAttributes, ComponentType } from "react";
import MarkdownToJsx from "markdown-to-jsx";
const OVERRIDES = {
@@ -69,17 +70,25 @@ const OVERRIDES = {
};
// Render a Markdown string. `forceBlock` keeps single-line input block-level so
-// spacing is consistent regardless of content shape.
+// spacing is consistent regardless of content shape. `linkComponent` optionally
+// overrides how `<a>` renders (e.g. to make in-page citation anchors scroll
+// instead of opening a new tab); when omitted, links use the default external
+// styling above.
export function Markdown({
children,
className,
+ linkComponent,
}: {
children: string;
className?: string;
+ linkComponent?: ComponentType<AnchorHTMLAttributes<HTMLAnchorElement>>;
}) {
+ const overrides = linkComponent
+ ? { ...OVERRIDES, a: { component: linkComponent } }
+ : OVERRIDES;
return (
<div className={className}>
- <MarkdownToJsx options={{ overrides: OVERRIDES, forceBlock: true }}>
+ <MarkdownToJsx options={{ overrides, forceBlock: true }}>
{children}
</MarkdownToJsx>
</div>
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **"Ask AI" chat — clickable citations and smaller niceties.** The `[1]`, `[2]`… citation markers in an answer are now **clickable**: click one to jump straight to that source in the list below (a smooth in-page scroll with a brief highlight — no new tab, no navigation). Also: a suggestion chip now **focuses the composer** when you pick it (so you can tweak and press Enter), and the answer **Copy** button now says "Copy failed" when the browser blocks clipboard access (e.g. on an insecure origin) instead of silently doing nothing. See `common/components/Markdown.tsx` (an optional `linkComponent`), `export/app/ask/{MessageBubble,Composer,AskChat}.tsx`.
- **"Ask AI" chat — an opt-in Report mode for long research sessions.** Turn on **Report mode** (in the new **Report** panel; tool-capable providers only) and the assistant maintains a persistent Markdown **report** document — a running canvas it updates via an `update_report` tool as you keep chatting, upserting well-titled sections that persist across turns. Crucially, while it's on the conversation is **compacted into the report** instead of replaying every prior question and answer: each turn sends a single *"report so far"* summary plus the usual deduplicated excerpt pool, so a long back-and-forth stays bounded in tokens instead of growing every turn. The report renders live in the panel (with an *updating…* shimmer while a turn writes to it) and is saved with the conversation, so it survives reloads; *New chat* clears it. On the Scripted transport there's no tool to drive it, so the toggle is disabled with a hint. See `export/app/lib/nativeTools/{shared,anthropic,openai,gemini}.ts` (the `update_report` tool), `export/app/lib/askConversation.ts` (`applyReportPatch`), `export/app/lib/searchAgent.ts` (compaction + executor), `export/app/ask/{useAskChat.ts,ReportPanel.tsx,AskChat.tsx}`, and `export/e2e/ask-chat.spec.ts`.
- **"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`.
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -25,9 +25,17 @@ export default function AskChat() {
} = s;
const scrollRef = useRef<HTMLDivElement | null>(null);
+ const composerRef = useRef<HTMLTextAreaElement | null>(null);
const stuckRef = useRef(true);
const [showJump, setShowJump] = useState(false);
+ // 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();
+ };
+
// 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 = () => {
@@ -204,7 +212,7 @@ export default function AskChat() {
<button
key={q}
type="button"
- onClick={() => setInput(q)}
+ onClick={() => fillSuggestion(q)}
className="rounded-full border border-border bg-card/40 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-brand hover:text-foreground"
>
{q}
@@ -248,6 +256,7 @@ export default function AskChat() {
hasKey={!!apiKey.trim()}
markdownOn={markdownOn}
setMarkdownOn={s.setMarkdownOn}
+ textareaRef={composerRef}
/>
</div>
);
diff --git a/export/app/ask/Composer.tsx b/export/app/ask/Composer.tsx
@@ -1,5 +1,7 @@
"use client";
+import type { RefObject } from "react";
+
type Props = {
input: string;
setInput: (v: string) => void;
@@ -10,6 +12,7 @@ type Props = {
hasKey: boolean;
markdownOn: boolean;
setMarkdownOn: (v: boolean) => void;
+ textareaRef?: RefObject<HTMLTextAreaElement | null>;
};
export function Composer(props: Props) {
@@ -23,6 +26,7 @@ export function Composer(props: Props) {
hasKey,
markdownOn,
setMarkdownOn,
+ textareaRef,
} = props;
return (
@@ -34,6 +38,7 @@ export function Composer(props: Props) {
className="flex flex-col gap-2"
>
<textarea
+ ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
diff --git a/export/app/ask/MessageBubble.tsx b/export/app/ask/MessageBubble.tsx
@@ -1,29 +1,92 @@
"use client";
-import { useState } from "react";
+import { useId, useState, type AnchorHTMLAttributes } from "react";
import { CheckIcon, CopyIcon, PencilIcon, RotateCwIcon } from "lucide-react";
import { Markdown } from "yt-dlp-transcript-common/components/Markdown";
import type { UiMessage } from "../lib/askConversation";
import { PipelineStatus } from "./PipelineStatus";
+// Turn `[n]` citation markers in an answer into links to the matching source in
+// the list below. Skips fenced/inline code, and only links numbers that have a
+// source (1..count). The anchor id is scoped per message via `cid`.
+function linkifyCitations(md: string, maxN: number, cid: string): string {
+ if (maxN <= 0) return md;
+ return md
+ .split(/(```[\s\S]*?```|`[^`]*`)/g)
+ .map((seg, i) =>
+ i % 2 === 1
+ ? seg
+ : seg.replace(/\[(\d+)\]/g, (m, d) => {
+ const n = parseInt(d, 10);
+ return n >= 1 && n <= maxN ? `[[${n}]](#cite-${cid}-${n})` : m;
+ }),
+ )
+ .join("");
+}
+
+// Link renderer for answer Markdown: an in-page `#cite-…` anchor scrolls to and
+// briefly highlights its source (no new tab); any other link opens externally.
+function CitationLink({ href, children, ...rest }: AnchorHTMLAttributes<HTMLAnchorElement>) {
+ if (typeof href === "string" && href.startsWith("#cite-")) {
+ return (
+ <a
+ href={href}
+ onClick={(e) => {
+ e.preventDefault();
+ const el = document.getElementById(href.slice(1));
+ if (!el) return;
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
+ el.classList.add("bg-brand-soft");
+ setTimeout(() => el.classList.remove("bg-brand-soft"), 900);
+ }}
+ className="font-mono text-brand no-underline hover:underline"
+ >
+ {children}
+ </a>
+ );
+ }
+ return (
+ <a
+ href={href}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-brand underline decoration-brand/40 hover:decoration-brand"
+ {...rest}
+ >
+ {children}
+ </a>
+ );
+}
+
function CopyButton({ text }: { text: string }) {
- const [copied, setCopied] = useState(false);
+ const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
+ const copy = async () => {
+ try {
+ // navigator.clipboard is undefined on insecure origins — surface a failure
+ // instead of silently no-op'ing (the old optional-chain swallowed it).
+ if (!navigator.clipboard) throw new Error("clipboard unavailable");
+ await navigator.clipboard.writeText(text);
+ setState("copied");
+ } catch {
+ setState("failed");
+ }
+ setTimeout(() => setState("idle"), 1500);
+ };
return (
<button
type="button"
- onClick={() => {
- void navigator.clipboard?.writeText(text).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- });
- }}
+ 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"
aria-label="Copy answer"
>
- {copied ? (
+ {state === "copied" ? (
<>
<CheckIcon className="size-3.5 text-brand" /> Copied
</>
+ ) : state === "failed" ? (
+ <>
+ <CopyIcon className="size-3.5 text-warning" /> Copy failed
+ </>
) : (
<>
<CopyIcon className="size-3.5" /> Copy
@@ -69,6 +132,9 @@ export function MessageBubble({
(message.phase === "done" && !!message.content));
const rerunLabel =
message.phase === "done" && message.content ? "Regenerate" : "Retry";
+ // Stable per-message id so citation anchors don't collide across messages.
+ const cid = useId().replace(/:/g, "");
+ const sourceCount = message.sources?.length ?? 0;
return (
<div className="flex flex-col gap-2 animate-in fade-in slide-in-from-bottom-2 motion-reduce:animate-none">
@@ -87,7 +153,9 @@ export function MessageBubble({
}
>
{renderMarkdown ? (
- <Markdown className="leading-relaxed">{message.content}</Markdown>
+ <Markdown className="leading-relaxed" linkComponent={CitationLink}>
+ {linkifyCitations(message.content, sourceCount, cid)}
+ </Markdown>
) : (
<span className="whitespace-pre-wrap leading-relaxed">
{message.content}
@@ -152,7 +220,8 @@ export function MessageBubble({
{message.sources.map((s, si) => (
<li
key={s.key}
- className="animate-in fade-in motion-reduce:animate-none"
+ id={`cite-${cid}-${si + 1}`}
+ className="scroll-mt-20 rounded transition-colors animate-in fade-in motion-reduce:animate-none"
style={{ animationDelay: `${Math.min(si, 8) * 40}ms`, animationFillMode: "both" }}
>
<span className="font-mono text-brand">[{si + 1}]</span>{" "}
diff --git a/export/e2e/ask-chat.spec.ts b/export/e2e/ask-chat.spec.ts
@@ -186,6 +186,23 @@ test.describe("ask chat", () => {
await expect(page.locator("li", { hasText: "point one" }).first()).toBeVisible();
});
+ test("inline [n] citations link to their source (in-page, no navigation)", async ({
+ page,
+ }) => {
+ await setup(page);
+ await ask(page, "tell me about the alpha discussion");
+ await expect(page.locator("li", { hasText: "point one" }).first()).toBeVisible();
+
+ // The [1] in the answer is a clickable in-page anchor to its source.
+ const cite = page.getByRole("link", { name: "[1]", exact: true });
+ await expect(cite).toBeVisible();
+ await expect(cite).toHaveAttribute("href", /^#cite-/);
+ // Clicking scrolls to the source — it must NOT navigate or add a hash.
+ await cite.click();
+ await expect(page).toHaveURL(/\/ask\/$/);
+ expect(page.url()).not.toContain("#");
+ });
+
test("a reformat follow-up reuses context without a junk search", async ({
page,
}) => {