Archilyzer · Source

archilyzer

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

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:
Mcommon/components/Markdown.tsx | 13+++++++++++--
Mexport/CHANGELOG.md | 1+
Mexport/app/ask/AskChat.tsx | 11++++++++++-
Mexport/app/ask/Composer.tsx | 5+++++
Mexport/app/ask/MessageBubble.tsx | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mexport/e2e/ask-chat.spec.ts | 17+++++++++++++++++
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, }) => {