From 42a33790ae742902e6ecaef7e716f15bd465cbc2 Mon Sep 17 00:00:00 2001
From: mimran-khan
Date: Thu, 12 Nov 2026 02:50:50 +0530
Subject: [PATCH 1/2] feat: open document citations in the page viewer
A citation in an answer opens the references viewer on the cited page.
The references rail lists those pages, and a citation the model split
across two lines stays one inline link.
Signed-off-by: mimran-khan
---
src/frontend/components/ChatMessagesView.tsx | 74 ++-
.../components/DocumentReferences.tsx | 117 +++++
.../components/SourceDocumentViewer.tsx | 265 +++++++++++
.../components/document-open-context.ts | 3 +
.../components/document-open.test.tsx | 150 ++++++
src/frontend/global.css | 13 +-
src/frontend/hooks/useStreamingAPI.ts | 23 +
src/frontend/lib/document-references.test.ts | 153 ++++++
src/frontend/lib/document-references.ts | 448 ++++++++++++++++++
src/frontend/lib/processed-document.test.ts | 89 ++++
src/frontend/lib/processed-document.ts | 216 +++++++++
src/server/citation-images.test.ts | 28 ++
src/server/citation-images.ts | 33 ++
13 files changed, 1591 insertions(+), 21 deletions(-)
create mode 100644 src/frontend/components/DocumentReferences.tsx
create mode 100644 src/frontend/components/SourceDocumentViewer.tsx
create mode 100644 src/frontend/components/document-open-context.ts
create mode 100644 src/frontend/components/document-open.test.tsx
create mode 100644 src/frontend/lib/document-references.test.ts
create mode 100644 src/frontend/lib/document-references.ts
create mode 100644 src/frontend/lib/processed-document.test.ts
create mode 100644 src/frontend/lib/processed-document.ts
create mode 100644 src/server/citation-images.test.ts
create mode 100644 src/server/citation-images.ts
diff --git a/src/frontend/components/ChatMessagesView.tsx b/src/frontend/components/ChatMessagesView.tsx
index e39063a2..daa61755 100644
--- a/src/frontend/components/ChatMessagesView.tsx
+++ b/src/frontend/components/ChatMessagesView.tsx
@@ -4,13 +4,12 @@ import { AlertCircle, Check, ChevronDown, ChevronRight, Copy, Download, Loader2,
import type { InterruptInfo } from "../types/deep-agent";
import { InputForm } from "./InputForm";
import { McpStatusPanel } from "./McpStatusPanel";
-import { useState, ReactNode, useMemo, useEffect, useRef, useCallback, cloneElement, isValidElement } from "react";
+import { useState, ReactNode, useMemo, useEffect, useRef, useCallback, useContext, cloneElement, isValidElement } from "react";
import { cn } from "../lib/utils";
import {
Dropdown,
DropdownItem,
DropdownList,
- Label,
MenuToggle,
type MenuToggleElement,
} from "@patternfly/react-core";
@@ -26,6 +25,9 @@ import { markdownSanitizeSchema } from "../lib/markdown-sanitize";
import { isSubAgentToolCall, type SubagentTranscriptEntry } from "../types/deep-agent";
import { SubAgentIndicator } from "./SubAgentIndicator";
import { ToolCallRow } from "./ToolCallRow";
+import { DocumentOpenContext } from "./document-open-context";
+import { DocumentChrome } from "./DocumentReferences";
+import { fileIdFromCitationHref, isInAppHref, joinSplitCitationLinks } from "../lib/document-references";
import { ChartCard, PlotlyCard, useContentSegments } from "./InlineChart";
import { TodoStrip } from "./TodoStrip";
import { FeedbackButtons, FeedbackCommentBox, useFeedback } from "./FeedbackButtons";
@@ -287,6 +289,37 @@ function MessageActionsRow({
);
}
+function CitationLink({ className, children, href }: MdComponentProps) {
+ const openDocument = useContext(DocumentOpenContext);
+ const link = typeof href === "string" ? href : "";
+ const fileId = fileIdFromCitationHref(link);
+ if (fileId) {
+ return (
+
+ );
+ }
+ if (isInAppHref(link, globalThis.location?.origin ?? "")) {
+ return {children};
+ }
+ return (
+
+ {children}
+ (opens in new tab)
+
+ );
+}
+
const mdComponents = {
h1: ({ className, children, ...props }: MdComponentProps) => (
@@ -308,20 +341,7 @@ const mdComponents = {
{children}
),
- a: ({ className, children, href, ...props }: MdComponentProps) => (
-
- ),
+ a: CitationLink,
ul: ({ className, children, ...props }: MdComponentProps) => (
{children}
@@ -760,7 +780,7 @@ interface AIMessageRendererProps {
}
function AnswerStream({ text }: { text: string }) {
- const parts = useContentSegments(text);
+ const parts = useContentSegments(joinSplitCitationLinks(text));
return (
{parts.map((part, index) => {
@@ -778,6 +798,20 @@ function AnswerStream({ text }: { text: string }) {
export function AIMessageRenderer({ message, pendingInterrupt, onInterruptResume, onAlwaysAllow, globalApprovalIndex = 0, approvalSlotOffset = 0, totalActionRequests = 0, allDecisionsMade = false, onSingleDecision, thinkingLive = false, questionContent, interruptContent, turnMessages }: AIMessageRendererProps) {
const source = turnMessages && turnMessages.length > 0 ? turnMessages : [message];
+ const openDocument = useContext(DocumentOpenContext);
+ const keepDocumentInPage = useCallback((event: React.MouseEvent) => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ const anchor = target.closest("a");
+ if (!(anchor instanceof HTMLAnchorElement)) return;
+ const href = anchor.getAttribute("href") ?? "";
+ const fileId = fileIdFromCitationHref(href);
+ const staysInChat = fileId !== null || isInAppHref(href, globalThis.location?.origin ?? "");
+ if (!staysInChat) return;
+ event.preventDefault();
+ event.stopPropagation();
+ if (fileId) openDocument?.(fileId, href);
+ }, [openDocument]);
const [thoughtChoice, setThoughtChoice] = useState
>({});
const messageKey = JSON.stringify(source);
@@ -932,7 +966,7 @@ export function AIMessageRenderer({ message, pendingInterrupt, onInterruptResume
if (item.kind === 'thinking') {
return (
- {item.text}
+ {joinSplitCitationLinks(item.text)}
);
}
@@ -999,7 +1033,7 @@ export function AIMessageRenderer({ message, pendingInterrupt, onInterruptResume
}, [messageKey, thoughtChoice, globalApprovalIndex, allDecisionsMade, pendingInterrupt, onInterruptResume, onAlwaysAllow, onSingleDecision, totalActionRequests, approvalSlotByToolCallId, thinkingLive, questionContent, interruptContent, pendingToolNames]);
return (
-
+
{renderMessage}
);
@@ -1193,6 +1227,7 @@ export function ChatMessagesView({
const showExport = Boolean(onExportMarkdown && onExportJson && messages.length > 0);
return (
+
+
);
}
diff --git a/src/frontend/components/DocumentReferences.tsx b/src/frontend/components/DocumentReferences.tsx
new file mode 100644
index 00000000..d92c9152
--- /dev/null
+++ b/src/frontend/components/DocumentReferences.tsx
@@ -0,0 +1,117 @@
+import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
+import { BookOpen, ChevronRight, FileText } from "lucide-react";
+import { documentReferences, viewerPages, type DocumentReference } from "../lib/document-references";
+import { prefetchCitedPages } from "../lib/processed-document";
+import { callMcpAppTool } from "../services/mcp-apps-api";
+import { DocumentOpenContext } from "./document-open-context";
+import { SourceDocumentViewer } from "./SourceDocumentViewer";
+
+function pageLabel(pages: number[]): string {
+ if (pages.length === 0) return "";
+ return `p. ${pages.join(", ")}`;
+}
+
+export function ReferencesRail({
+ references,
+ activeFileId,
+ onOpen,
+}: {
+ references: DocumentReference[];
+ activeFileId: string | null;
+ onOpen: (fileId: string) => void;
+}) {
+ const [expanded, setExpanded] = useState(true);
+ if (references.length === 0) return null;
+ if (!expanded) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+export function DocumentChrome({
+ messages,
+ children,
+}: {
+ messages: readonly unknown[];
+ children: ReactNode;
+}) {
+ const references = useMemo(() => documentReferences(messages), [messages]);
+ const pages = useMemo(() => viewerPages(references), [references]);
+ useEffect(() => {
+ prefetchCitedPages(pages, callMcpAppTool);
+ }, [pages]);
+ const [openDoc, setOpenDoc] = useState<{ fileId: string; href?: string } | null>(null);
+ const [activeFileId, setActiveFileId] = useState
(null);
+ const openDocument = useCallback((fileId: string, href?: string) => {
+ setOpenDoc({ fileId, href });
+ setActiveFileId(fileId);
+ }, []);
+ const showFile = useCallback((fileId: string) => setActiveFileId(fileId), []);
+ return (
+
+
+ {openDoc ? (
+ {
+ setOpenDoc(null);
+ setActiveFileId(null);
+ }}
+ onActiveFile={showFile}
+ onOpenDocument={openDocument}
+ />
+ ) : null}
+
+ );
+}
diff --git a/src/frontend/components/SourceDocumentViewer.tsx b/src/frontend/components/SourceDocumentViewer.tsx
new file mode 100644
index 00000000..b2770433
--- /dev/null
+++ b/src/frontend/components/SourceDocumentViewer.tsx
@@ -0,0 +1,265 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import rehypeSanitize from "rehype-sanitize";
+import { ChevronLeft, ChevronRight, FileText, RotateCcw, X, ZoomIn, ZoomOut } from "lucide-react";
+import { markdownSanitizeSchema } from "../lib/markdown-sanitize";
+import { fileIdFromCitationHref, isInAppHref, viewerPages, viewerStartIndex, type DocumentReference } from "../lib/document-references";
+import { loadPageImages, peekPageImage } from "../lib/processed-document";
+import { callMcpAppTool } from "../services/mcp-apps-api";
+import type { CitationImage } from "../../server/citation-images";
+
+function excerpt(text: string): string {
+ const plain = text.replace(/^#+\s*/gm, "").replace(/\s+/g, " ").trim();
+ if (plain.length <= 140) return plain;
+ return `${plain.slice(0, 137)}...`;
+}
+
+function ViewerToolbar({
+ title,
+ pageLabel,
+ zoom,
+ onZoomIn,
+ onZoomOut,
+ onReset,
+ onPrev,
+ onNext,
+ canPrev,
+ canNext,
+ showNav,
+ onClose,
+}: {
+ title: string;
+ pageLabel: string;
+ zoom: number;
+ onZoomIn: () => void;
+ onZoomOut: () => void;
+ onReset: () => void;
+ onPrev: () => void;
+ onNext: () => void;
+ canPrev: boolean;
+ canNext: boolean;
+ showNav: boolean;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+
+
{title}
+
{pageLabel}
+
+
+
+ {showNav ? (
+ <>
+
+
+
+ >
+ ) : null}
+
+
{Math.round(zoom * 100)}%
+
+
+
+
+
+
+ );
+}
+
+function PageImage({ title, image }: { title: string; image: CitationImage }) {
+ return (
+
+ );
+}
+
+function DocumentPaper({
+ title,
+ markdown,
+ image,
+ loading,
+ onOpenDocument,
+}: {
+ title: string;
+ markdown: string;
+ image: CitationImage | null;
+ loading: boolean;
+ onOpenDocument?: (fileId: string, href?: string) => void;
+}) {
+ if (image) return ;
+ if (markdown) {
+ return ;
+ }
+ if (loading) {
+ return Loading the page...
;
+ }
+ return This document was not in the retrieved passages.
;
+}
+
+function DocumentArticle({
+ markdown,
+ onOpenDocument,
+}: {
+ markdown: string;
+ onOpenDocument?: (fileId: string, href?: string) => void;
+}) {
+ return (
+
+ {markdown ? (
+ {
+ const link = typeof href === "string" ? href : "";
+ const fileId = fileIdFromCitationHref(link);
+ if (fileId && onOpenDocument) {
+ return (
+
+ );
+ }
+ if (fileId || isInAppHref(link, globalThis.location?.origin ?? "")) {
+ return {children};
+ }
+ return {children};
+ },
+ }}
+ >
+ {markdown}
+
+ ) : null}
+
+ );
+}
+
+function pageCaption(page: number | null, index: number, count: number): string {
+ const where = count > 1 ? `${index + 1} of ${count}` : "";
+ const label = page != null ? `Page ${page}` : "Document";
+ return [label, where].filter(Boolean).join(" · ");
+}
+
+export function SourceDocumentViewer({
+ fileId,
+ href = "",
+ references,
+ onClose,
+ onActiveFile,
+ onOpenDocument,
+}: {
+ fileId: string;
+ href?: string;
+ references: readonly DocumentReference[];
+ onClose: () => void;
+ onActiveFile?: (fileId: string) => void;
+ onOpenDocument?: (fileId: string, href?: string) => void;
+}) {
+ const pages = useMemo(() => viewerPages(references), [references]);
+ const pagesRef = useRef(pages);
+ pagesRef.current = pages;
+ const [index, setIndex] = useState(() => viewerStartIndex(pages, fileId, href));
+ const [zoom, setZoom] = useState(1);
+ const [readyTick, setReadyTick] = useState(0);
+ const current = pages[index] ?? pages[0];
+ const title = current?.title ?? "Document";
+ const markdown = current?.text ?? "";
+ const storedImage = current?.pipeline && current.chunkId
+ ? peekPageImage(current.pipeline, current.chunkId)
+ : null;
+ const image = storedImage ?? null;
+ const waitingForImage = Boolean(current?.pipeline && current.chunkId && storedImage === undefined && readyTick >= 0);
+
+ useEffect(() => {
+ setIndex(viewerStartIndex(pagesRef.current, fileId, href));
+ }, [fileId, href]);
+
+ useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ if (event.key === "ArrowRight") setIndex((value) => Math.min(value + 1, Math.max(pagesRef.current.length - 1, 0)));
+ if (event.key === "ArrowLeft") setIndex((value) => Math.max(value - 1, 0));
+ if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(value + 0.25, 3));
+ if (event.key === "-") setZoom((value) => Math.max(value - 0.25, 0.5));
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ useEffect(() => {
+ const page = pages[index];
+ if (!page) return;
+ onActiveFile?.(page.fileId);
+ if (!page.pipeline || !page.chunkId) return;
+ if (peekPageImage(page.pipeline, page.chunkId) !== undefined) return;
+ let active = true;
+ loadPageImages(page.pipeline, page.chunkId, [], callMcpAppTool).finally(() => {
+ if (active) setReadyTick((value) => value + 1);
+ });
+ return () => {
+ active = false;
+ };
+ }, [index, pages, onActiveFile]);
+
+ const cited = current?.text ? excerpt(current.text) : "";
+ const pageLabel = pageCaption(current?.page ?? null, index, pages.length);
+
+ return createPortal(
+
+
setZoom((value) => Math.min(value + 0.25, 3))}
+ onZoomOut={() => setZoom((value) => Math.max(value - 0.25, 0.5))}
+ onReset={() => setZoom(1)}
+ onPrev={() => setIndex((value) => Math.max(value - 1, 0))}
+ onNext={() => setIndex((value) => Math.min(value + 1, Math.max(pages.length - 1, 0)))}
+ canPrev={index > 0}
+ canNext={index < pages.length - 1}
+ showNav={pages.length > 1}
+ onClose={onClose}
+ />
+
+ {cited ? (
+
+
+
+ Cited passage
+ {": "}
+ {cited}
+
+
+ ) : null}
+ ,
+ document.body,
+ );
+}
diff --git a/src/frontend/components/document-open-context.ts b/src/frontend/components/document-open-context.ts
new file mode 100644
index 00000000..3e623388
--- /dev/null
+++ b/src/frontend/components/document-open-context.ts
@@ -0,0 +1,3 @@
+import { createContext } from "react";
+
+export const DocumentOpenContext = createContext<((fileId: string, href?: string) => void) | null>(null);
diff --git a/src/frontend/components/document-open.test.tsx b/src/frontend/components/document-open.test.tsx
new file mode 100644
index 00000000..5b7ec494
--- /dev/null
+++ b/src/frontend/components/document-open.test.tsx
@@ -0,0 +1,150 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { Message } from "@langchain/langgraph-sdk";
+import { callMcpAppTool } from "../services/mcp-apps-api";
+import { AIMessageRenderer } from "./ChatMessagesView";
+import { DocumentChrome } from "./DocumentReferences";
+
+vi.mock("../services/mcp-apps-api", () => ({
+ callMcpAppTool: vi.fn(),
+}));
+
+const doc = "https://docs.google.com/document/d/doc-1";
+
+describe("document open", () => {
+ it("opens the document in the page viewer", async () => {
+ vi.mocked(callMcpAppTool).mockImplementation(async (_mcp, name) => {
+ if (name === "get_processed_document") {
+ return {
+ content: [{
+ type: "text",
+ text: `Processed document:\n${JSON.stringify({ markdown_content: "## Principles\n\nStandardize on RHEL." })}`,
+ }],
+ };
+ }
+ return { content: [] };
+ });
+ const user = userEvent.setup();
+ const message = {
+ type: "ai",
+ id: "m1",
+ content: `See [CY26 RHEL](${doc}).`,
+ tool_calls: [{
+ name: "get_chunks_for_embeddings",
+ content: `Found 1 chunks for query in pipeline "analyzeproductrhel":\n${JSON.stringify([{
+ file_id: "doc-1",
+ chunk_text: "## Principles\nStandardize on RHEL.",
+ }])}\n`,
+ }],
+ } as Message;
+ render(
+
+
+ ,
+ );
+ expect(screen.queryAllByRole("link")).toHaveLength(0);
+ const rail = screen.getByRole("complementary", { name: "References" });
+ await user.click(within(rail).getByRole("button", { name: /CY26 RHEL/ }));
+ expect(document.querySelector("iframe")).toBeNull();
+ expect(await screen.findByRole("dialog", { name: "CY26 RHEL" })).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Principles" })).toBeInTheDocument();
+ expect(screen.getByText("Standardize on RHEL.")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Close document" }));
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("opens the viewer from a bare file id and does not follow a relative reference", async () => {
+ const fileId = "1kay0XsAOelA-mJ1kIWvXC2KAAKonMurqxMNIZ0yL95s";
+ const user = userEvent.setup();
+ const message = {
+ type: "ai",
+ id: "m-bare",
+ content: `See [CY26 RHEL](${fileId}). The [\`flag\` reference](./calculate_bookings_flag.md) stays here.`,
+ tool_calls: [{
+ name: "get_chunks_for_embeddings",
+ content: `Found 1 chunks for query in pipeline "analyzeproductrhel":\n${JSON.stringify([{
+ file_id: fileId,
+ chunk_text: "Standardize on RHEL.",
+ }])}\n`,
+ }],
+ } as Message;
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByRole("link", { name: /reference/ })).toBeNull();
+ expect(screen.getByText(/reference/)).toBeInTheDocument();
+ const citations = screen.getAllByRole("button", { name: "CY26 RHEL" });
+ expect(citations[0]).toHaveClass("citation-link");
+ await user.click(citations[0]);
+ expect(await screen.findByRole("dialog", { name: "CY26 RHEL" })).toBeInTheDocument();
+ });
+
+ it("renders the citation page from the returned image", async () => {
+ vi.mocked(callMcpAppTool).mockImplementation(async (_mcp, name) => {
+ if (name === "get_citation_image") {
+ return { content: [{ type: "image", data: "abc", mimeType: "image/png" }] };
+ }
+ return { content: [] };
+ });
+ const user = userEvent.setup();
+ const message = {
+ type: "ai",
+ id: "m2",
+ content: `See [CY26 RHEL](${doc}).`,
+ tool_calls: [{
+ name: "get_chunks_for_embeddings",
+ content: `Found 1 chunks for query in pipeline "productstrategycy26":\n${JSON.stringify([{
+ file_id: "doc-1",
+ chunk_id: "pipelines/doc.pdf#1",
+ chunk_text: "page text",
+ regions: [{ pageNo: 1, bbox: [0, 0, 1, 1], image: "page.png" }],
+ }])}\n`,
+ }],
+ } as Message;
+ render(
+
+
+ ,
+ );
+ await user.click(within(screen.getByRole("complementary", { name: "References" })).getByRole("button", { name: /CY26 RHEL/ }));
+ const image = await screen.findByRole("img", { name: "CY26 RHEL page" });
+ expect(image).toHaveAttribute("src", "data:image/png;base64,abc");
+ expect(screen.queryByText("page text")).toBeNull();
+ });
+
+ it("opens a pipeline citation and moves to the next referenced page", async () => {
+ vi.mocked(callMcpAppTool).mockResolvedValue({ content: [] });
+ const edge = "1cNsyB6IoH4rpHM6lKeRdGkgO0Vzvy1f4-RvkRJ_6NmA";
+ const rhel = "17dPGPaiFp-eBYOZVtosqtDa03opeliamHhafZVrRVxU";
+ const edgeChunk = `pipelines/productstrategycy26/stages/crawl/${edge}.pdf#13`;
+ const rhelChunk = `pipelines/productstrategycy27/stages/crawl/${rhel}.pdf#28`;
+ const user = userEvent.setup();
+ const message = {
+ type: "ai",
+ id: "m-pages",
+ content: `See [Edge Strategy, p.9](${edgeChunk}) and [RHEL Strategy, p.19](${rhelChunk}).`,
+ tool_calls: [{
+ name: "get_chunks_for_embeddings",
+ content: `Found 2 chunks for query in pipeline "productstrategycy26":\n${JSON.stringify([
+ { file_id: edge, chunk_id: edgeChunk, chunk_text: "Edge page nine", regions: [{ pageNo: 9 }] },
+ { file_id: rhel, chunk_id: rhelChunk, chunk_text: "RHEL page nineteen", regions: [{ pageNo: 19 }] },
+ ])}\n`,
+ }],
+ } as Message;
+ render(
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Edge Strategy, p.9" }));
+ expect(await screen.findByRole("dialog", { name: "Edge Strategy, p.9" })).toBeInTheDocument();
+ expect(screen.getByText("Edge page nine")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Next page" }));
+ expect(screen.getByRole("dialog", { name: "RHEL Strategy, p.19" })).toBeInTheDocument();
+ expect(screen.getByText("RHEL page nineteen")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Previous page" }));
+ expect(screen.getByText("Edge page nine")).toBeInTheDocument();
+ });
+});
diff --git a/src/frontend/global.css b/src/frontend/global.css
index dddd0633..4ee35f9d 100644
--- a/src/frontend/global.css
+++ b/src/frontend/global.css
@@ -432,17 +432,26 @@ input[type="url"]:focus {
font-style: italic;
}
-.ai-response-body a {
+.ai-response-body a,
+button.citation-link {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: color-mix(in srgb, var(--primary) 30%, transparent);
transition: color 0.15s ease, text-decoration-color 0.15s ease;
}
-.ai-response-body a:hover {
+.ai-response-body a:hover,
+button.citation-link:hover {
color: color-mix(in srgb, var(--primary) 80%, var(--foreground));
text-decoration-color: color-mix(in srgb, var(--primary) 60%, transparent);
}
+button.citation-link {
+ background: none;
+ border: 0;
+ padding: 0;
+ font: inherit;
+ cursor: pointer;
+}
.ai-response-body ul {
list-style-type: disc;
diff --git a/src/frontend/hooks/useStreamingAPI.ts b/src/frontend/hooks/useStreamingAPI.ts
index a38d04f7..e2734cc4 100644
--- a/src/frontend/hooks/useStreamingAPI.ts
+++ b/src/frontend/hooks/useStreamingAPI.ts
@@ -8,7 +8,10 @@ import {
type StreamCallback,
type StreamStatus,
} from '@/lib/streaming/StreamingManager';
+import { citationTargetsInChunkResult, citationTargetsInText } from '@/lib/document-references';
+import { prefetchCitedPages } from '@/lib/processed-document';
import { getStreamingManager } from '@/lib/streaming/streamingManagerRegistry';
+import { callMcpAppTool } from '@/services/mcp-apps-api';
import { useAppDispatch, useAppSelector } from '@/redux/hooks';
import type { AppDispatch } from '@/redux/store';
import {
@@ -37,6 +40,18 @@ import { isSubAgentToolCall, extractSubAgentName } from '@/types/deep-agent';
import type { HITLInterruptValue, InterruptInfo } from '@/types/deep-agent';
import { mergeMessageWithMcpModelContext } from '@/types/mcp-apps';
+function warmAnswerText(buffer: { current: string }, content: string, part: 'thinking' | 'text' | undefined): void {
+ if (part === 'thinking' || !content) return;
+ buffer.current += content;
+ const targets = citationTargetsInText(buffer.current);
+ if (targets.length > 0) prefetchCitedPages(targets, callMcpAppTool);
+}
+
+function warmToolMessage(message: { name?: string; content?: unknown }): void {
+ const targets = citationTargetsInChunkResult(message.name, message.content);
+ if (targets.length > 0) prefetchCitedPages(targets, callMcpAppTool);
+}
+
type TokenPart = 'thinking' | 'text';
function dispatchSubagentMessage(
@@ -469,6 +484,7 @@ export function useStreamingAPI(threadId: string) {
}>({ streamStartTime: null, firstTokenTime: null, streamEndTime: null });
const isStreamingTokensRef = useRef(false);
const userCancelledRef = useRef(false);
+ const citedAnswerRef = useRef('');
const submitLockRef = useRef(false);
const streamEndedWithInterruptRef = useRef(false);
const pendingInterruptRef = useRef(streamingState.pendingInterrupt);
@@ -577,6 +593,7 @@ export function useStreamingAPI(threadId: string) {
try {
userCancelledRef.current = false;
+ citedAnswerRef.current = '';
setRetryCount(0);
setWasInterrupted(false);
const initialMessageCount = messagesRef.current.length;
@@ -718,6 +735,7 @@ export function useStreamingAPI(threadId: string) {
if (streamClockRef.current.firstTokenTime == null) {
streamClockRef.current.firstTokenTime = Date.now();
}
+ warmAnswerText(citedAnswerRef, content, part);
dispatchStreamToken(dispatch, threadId, content, messageId, part, isStreamingTokensRef, subagentToolCallId);
},
onDraftDiscard() {
@@ -764,6 +782,7 @@ export function useStreamingAPI(threadId: string) {
}
if (m.type === 'tool') {
+ warmToolMessage(m);
dispatch(
mergeToolResult({
chatId: threadId,
@@ -1309,6 +1328,7 @@ export function useStreamingAPI(threadId: string) {
const callbacks: StreamCallback = {
onToken(content, messageId, part, subagentToolCallId) {
lastTokenTimeRef.current = Date.now();
+ warmAnswerText(citedAnswerRef, content, part);
dispatchStreamToken(dispatch, threadId, content, messageId, part, isStreamingTokensRef, subagentToolCallId);
},
onDraftDiscard() {
@@ -1330,6 +1350,7 @@ export function useStreamingAPI(threadId: string) {
return;
}
if (m.type === 'tool') {
+ warmToolMessage(m);
dispatch(
mergeToolResult({
chatId: threadId,
@@ -1448,6 +1469,7 @@ export function useStreamingAPI(threadId: string) {
onToken(content, messageId, part, subagentToolCallId) {
resumeStreamSawProgress = true;
lastTokenTimeRef.current = Date.now();
+ warmAnswerText(citedAnswerRef, content, part);
dispatchStreamToken(dispatch, threadId, content, messageId, part, isStreamingTokensRef, subagentToolCallId);
},
onDraftDiscard() {
@@ -1468,6 +1490,7 @@ export function useStreamingAPI(threadId: string) {
resumeStreamSawProgress = true;
if (m.type === 'tool') {
+ warmToolMessage(m);
dispatch(
mergeToolResult({
chatId: threadId,
diff --git a/src/frontend/lib/document-references.test.ts b/src/frontend/lib/document-references.test.ts
new file mode 100644
index 00000000..964d18c8
--- /dev/null
+++ b/src/frontend/lib/document-references.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from "vitest";
+import { citationTargetsInChunkResult, citationTargetsInText, documentReferences, fileIdFromCitationHref, fileIdFromDocumentUrl, isInAppHref, joinSplitCitationLinks, viewerPages } from "./document-references";
+
+const CHUNK = {
+ file_id: "doc-1",
+ chunk_id: "pipelines/a.pdf#4",
+ chunk_text: "RHEL strategy\nStandardize on security.",
+ regions: [{ pageNo: 3, bbox: [0, 0, 1, 1], image: "page.png" }],
+};
+
+describe("joinSplitCitationLinks", () => {
+ it("puts a wrapped title and file path back on one line", () => {
+ const href = "pipelines/productstrategycy26/stages/crawl/1nv5kjjHtWqhp6F_QwSuYH3TWdmvoLF0YMZSm7_9oWdM.pdf";
+ const raw = `in 2028 [Red Hat AI CY2026 Product Strategy]\n(${href}) .`;
+ expect(joinSplitCitationLinks(raw)).toBe(
+ `in 2028 [Red Hat AI CY2026 Product Strategy](${href}) .`,
+ );
+ const wrapped = `[Red\nHat AI CY2026 Product Strategy, p.12]\n(${href})`;
+ expect(joinSplitCitationLinks(wrapped)).toBe(
+ `[Red Hat AI CY2026 Product Strategy, p.12](${href})`,
+ );
+ const intact = `See [Edge](${href}#13).`;
+ expect(joinSplitCitationLinks(intact)).toBe(intact);
+ });
+});
+
+describe("citation warmup", () => {
+ it("starts from a citation path as soon as the answer contains it", () => {
+ const fileId = "1cNsyB6IoH4rpHM6lKeRdGkgO0Vzvy1f4-RvkRJ_6NmA";
+ const chunkId = `pipelines/productstrategycy26/stages/crawl/${fileId}.pdf#13`;
+ const partial = `See [Edge Strategy, p.9](${chunkId}`;
+ expect(citationTargetsInText(partial)).toEqual([
+ { pipeline: "productstrategycy26", chunkId },
+ ]);
+ expect(citationTargetsInText("See [flag](./calculate_bookings_flag.md)")).toEqual([]);
+ });
+
+ it("starts from chunk ids in a search result before the answer exists", () => {
+ const chunkId = "pipelines/productstrategycy27/stages/crawl/17dPGPaiFp-eBYOZVtosqtDa03opeliamHhafZVrRVxU.pdf#28";
+ const content = `Found 1 chunks for query in pipeline "productstrategycy27":\n[{"chunk_id":"${chunkId}","chunk_text":"RHEL"}]`;
+ expect(citationTargetsInChunkResult("get_chunks_for_embeddings", content)).toEqual([
+ { pipeline: "productstrategycy27", chunkId },
+ ]);
+ expect(citationTargetsInChunkResult("execute_sql", "select 1")).toEqual([]);
+ });
+});
+
+describe("documentReferences", () => {
+ it("reads the file id from a document url", () => {
+ expect(fileIdFromDocumentUrl("https://docs.google.com/document/d/doc-1")).toBe("doc-1");
+ expect(fileIdFromDocumentUrl("https://example.com/doc")).toBeNull();
+ });
+
+ it("reads a bare file id and ignores a relative reference link", () => {
+ const fileId = "1kay0XsAOelA-mJ1kIWvXC2KAAKonMurqxMNIZ0yL95s";
+ expect(fileIdFromCitationHref(fileId)).toBe(fileId);
+ expect(fileIdFromCitationHref(`https://docs.google.com/document/d/${fileId}`)).toBe(fileId);
+ expect(fileIdFromCitationHref("./calculate_bookings_flag.md")).toBeNull();
+ const cited = "1cNsyB6IoH4rpHM6lKeRdGkgO0Vzvy1f4-RvkRJ_6NmA";
+ expect(fileIdFromCitationHref(`pipelines/productstrategycy26/stages/crawl/${cited}.pdf#13`)).toBe(cited);
+ expect(isInAppHref("./calculate_bookings_flag.md")).toBe(true);
+ expect(isInAppHref("https://docs.redhat.com/en/documentation/rhel")).toBe(false);
+ });
+
+ it("builds a reference from retrieved chunks and the answer title", () => {
+ const messages = [
+ {
+ type: "ai",
+ tool_calls: [{
+ name: "get_chunks_for_embeddings",
+ content: `Found 1 chunks for query in pipeline "analyzeproductrhel":\n${JSON.stringify([CHUNK])}\n`,
+ }],
+ },
+ {
+ type: "ai",
+ content: "See [CY26 Product Strategy - RHEL](https://docs.google.com/document/d/doc-1).",
+ tool_calls: [{
+ name: "get_citation_image",
+ args: { chunk_id: "pipelines/a.pdf#4" },
+ }],
+ },
+ ];
+ const references = documentReferences(messages);
+ expect(references).toHaveLength(1);
+ expect(references[0]?.title).toBe("CY26 Product Strategy - RHEL");
+ expect(references[0]?.pages).toEqual([3]);
+ expect(references[0]?.pipeline).toBe("analyzeproductrhel");
+ expect(references[0]?.images).toEqual([]);
+ expect(references[0]?.passages[0]?.text).toContain("Standardize on security.");
+ });
+
+ it("uses the answer label when the link target is only the file id", () => {
+ const fileId = "1kay0XsAOelA-mJ1kIWvXC2KAAKonMurqxMNIZ0yL95s";
+ const messages = [
+ {
+ type: "ai",
+ content: `See [CY26 Product Strategy - RHEL](${fileId}).`,
+ tool_calls: [{
+ name: "get_chunks_for_embeddings",
+ content: `Found 1 chunks for query in pipeline "analyzeproductrhel":\n${JSON.stringify([{
+ ...CHUNK,
+ file_id: fileId,
+ }])}\n`,
+ }],
+ },
+ ];
+ expect(documentReferences(messages)[0]?.title).toBe("CY26 Product Strategy - RHEL");
+ });
+
+ it("keeps documents when the chunk list is cut off", () => {
+ const fileId = "1gLzn3TWNpjj_cReT5kOXFfUjHdcGK5AP";
+ const opened = "1RZg2uzP1WbxumFuRkceZcndgQDOsiSOb";
+ const full = JSON.stringify([
+ { file_id: fileId, chunk_text: "RHEL 10 considerations", chunk_id: "pipelines/a.pdf#1" },
+ { file_id: opened, chunk_text: "upgrade notes" },
+ ]);
+ const cut = full.slice(0, full.indexOf("upgrade") + 4);
+ const content = `Found 100 chunks for query in pipeline "aifrhel":\n${cut}\n\n[Result shortened from 90000 characters.]`;
+ const messages = [{
+ type: "ai",
+ content: `See [Considerations](${fileId}) and [Upgrading](${opened}).`,
+ tool_calls: [
+ { name: "get_chunks_for_embeddings", content },
+ { name: "get_processed_document", args: { pipeline_name: "aifrhel", file_id: opened } },
+ ],
+ }];
+ const references = documentReferences(messages);
+ expect(references.map((item) => item.fileId)).toEqual([fileId, opened]);
+ expect(references.every((item) => item.pipeline === "aifrhel")).toBe(true);
+ expect(references[0]?.title).toBe("Considerations");
+ });
+
+ it("keeps each cited chunk as its own page", () => {
+ const edge = "1cNsyB6IoH4rpHM6lKeRdGkgO0Vzvy1f4-RvkRJ_6NmA";
+ const rhel = "17dPGPaiFp-eBYOZVtosqtDa03opeliamHhafZVrRVxU";
+ const edgeChunk = `pipelines/productstrategycy26/stages/crawl/${edge}.pdf#13`;
+ const rhelChunk = `pipelines/productstrategycy27/stages/crawl/${rhel}.pdf#28`;
+ const messages = [{
+ type: "ai",
+ content: `See [Edge Strategy, p.9](${edgeChunk}) and [RHEL Strategy, p.19](${rhelChunk}).`,
+ tool_calls: [{
+ name: "get_chunks_for_embeddings",
+ content: `Found 2 chunks for query in pipeline "productstrategycy26":\n${JSON.stringify([
+ { file_id: edge, chunk_id: edgeChunk, chunk_text: "Edge page", regions: [{ pageNo: 9 }] },
+ { file_id: rhel, chunk_id: rhelChunk, chunk_text: "RHEL page", regions: [{ pageNo: 19 }] },
+ ])}\n`,
+ }],
+ }];
+ const pages = viewerPages(documentReferences(messages));
+ expect(pages.map((page) => page.page)).toEqual([9, 19]);
+ expect(pages.map((page) => page.chunkId)).toEqual([edgeChunk, rhelChunk]);
+ });
+});
diff --git a/src/frontend/lib/document-references.ts b/src/frontend/lib/document-references.ts
new file mode 100644
index 00000000..23f5e12a
--- /dev/null
+++ b/src/frontend/lib/document-references.ts
@@ -0,0 +1,448 @@
+import type { CitationImage } from "../../server/citation-images";
+
+export type DocumentPassage = {
+ page: number | null;
+ text: string;
+ chunkId: string | null;
+};
+
+export type DocumentReference = {
+ fileId: string;
+ title: string;
+ pipeline: string;
+ pages: number[];
+ passages: DocumentPassage[];
+ images: CitationImage[];
+};
+
+const MAX_DOCUMENTS = 8;
+const MAX_PASSAGES = 8;
+const CHUNK_FILE = /(?:^|\/)([A-Za-z0-9_-]{25,})\.pdf(?:#(\d+))?$/i;
+const CHUNK_PATH = /pipelines\/([A-Za-z0-9_-]+)\/[^\s)"']+?\.pdf#\d+/g;
+const CHUNK_ID_FIELD = /"chunk_id"\s*:\s*"([^"]+)"/g;
+const MAX_WARM_PAGES = 12;
+const PIPELINE_IN_PATH = /(?:^|\/)pipelines\/([A-Za-z0-9_-]+)\//i;
+const PAGE_IN_LABEL = /,\s*p\.?\s*(\d+)\s*$/i;
+const MAX_PASSAGE_CHARS = 12000;
+const MAX_TITLE_CHARS = 90;
+
+const DOCUMENT_URL = /https:\/\/(?:docs|drive)\.google\.com\/(?:document|spreadsheets|presentation|file)(?:\/u\/\d+)?\/d\/([A-Za-z0-9_-]+)/;
+const MARKDOWN_LINK = /\[([^\]]+)\]\(([^)\s]+)\)/g;
+const SPLIT_CITATION = /\[([^[\]]+?)\][ \t\r\n]+\((pipelines\/[^\s)]+?\.pdf(?:#\d+)?|https:\/\/(?:docs|drive)\.google\.com\/[^\s)]+)\)/g;
+
+/** A model often wraps a long citation so the title and path are not one link. */
+export function joinSplitCitationLinks(text: string): string {
+ return text.replace(SPLIT_CITATION, (_full, label: string, href: string) => {
+ const title = label.replace(/\s+/g, " ").trim();
+ return `[${title}](${href})`;
+ });
+}
+const BARE_FILE_ID = /^[A-Za-z0-9_-]{25,}$/;
+const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
+
+type ToolCall = {
+ name?: string;
+ args?: unknown;
+ content?: unknown;
+};
+
+export function fileIdFromDocumentUrl(href: string): string | null {
+ const match = href.match(new RegExp(DOCUMENT_URL.source));
+ return match?.[1] ?? null;
+}
+
+export type CitationTarget = {
+ fileId: string;
+ chunkId: string;
+ pipeline: string;
+ page: number | null;
+};
+
+/** A pipeline chunk path such as pipelines/name/stages/crawl/.pdf#13. */
+export function citationTarget(href: string, label = ""): CitationTarget | null {
+ const trimmed = href.trim();
+ const match = CHUNK_FILE.exec(trimmed);
+ if (!match?.[1]) return null;
+ const pageMatch = PAGE_IN_LABEL.exec(label.trim());
+ const page = pageMatch?.[1] ? Number(pageMatch[1]) : null;
+ return {
+ fileId: match[1],
+ chunkId: trimmed,
+ pipeline: PIPELINE_IN_PATH.exec(trimmed)?.[1] ?? "",
+ page: page != null && Number.isFinite(page) ? page : null,
+ };
+}
+
+export type WarmTarget = { pipeline: string; chunkId: string };
+
+function pushWarmTarget(targets: WarmTarget[], seen: Set, pipeline: string, chunkId: string): void {
+ if (!pipeline || !chunkId || seen.has(chunkId) || targets.length >= MAX_WARM_PAGES) return;
+ seen.add(chunkId);
+ targets.push({ pipeline, chunkId });
+}
+
+/** Chunk paths already written into the answer, including a partial stream. */
+export function citationTargetsInText(text: string): WarmTarget[] {
+ const targets: WarmTarget[] = [];
+ const seen = new Set();
+ for (const match of text.matchAll(new RegExp(CHUNK_PATH.source, "g"))) {
+ pushWarmTarget(targets, seen, match[1] ?? "", match[0] ?? "");
+ }
+ return targets;
+}
+
+/** Cited pages inside a chunk-search result, so images can start before the answer. */
+export function citationTargetsInChunkResult(name: string | undefined, content: unknown): WarmTarget[] {
+ const text = textOf(content);
+ const namedChunkSearch = typeof name === "string" && name.endsWith("get_chunks_for_embeddings");
+ if (!namedChunkSearch && !text.includes('"chunk_id"')) return [];
+ const headerPipeline = /pipeline "([^"]+)"/.exec(text)?.[1] ?? "";
+ const targets: WarmTarget[] = [];
+ const seen = new Set();
+ for (const match of text.matchAll(new RegExp(CHUNK_ID_FIELD.source, "g"))) {
+ const chunkId = match[1] ?? "";
+ const pipeline = /pipelines\/([A-Za-z0-9_-]+)\//.exec(chunkId)?.[1] ?? headerPipeline;
+ pushWarmTarget(targets, seen, pipeline, chunkId);
+ }
+ return targets;
+}
+
+/** A document URL, a bare file id, or a pipeline chunk path. */
+export function fileIdFromCitationHref(href: string): string | null {
+ const fromUrl = fileIdFromDocumentUrl(href);
+ if (fromUrl) return fromUrl;
+ const fromChunk = citationTarget(href);
+ if (fromChunk) return fromChunk.fileId;
+ const trimmed = href.trim();
+ return BARE_FILE_ID.test(trimmed) ? trimmed : null;
+}
+
+/** Relative and same-app links resolve under /chat and open another chat. */
+export function isInAppHref(href: string, origin = ""): boolean {
+ const trimmed = href.trim();
+ if (!trimmed || trimmed.startsWith("#")) return true;
+ if (!HAS_SCHEME.test(trimmed)) return true;
+ if (!origin) return false;
+ try {
+ return new URL(trimmed).origin === origin;
+ } catch {
+ return true;
+ }
+}
+
+function isChunkSearch(name: string | undefined): boolean {
+ return typeof name === "string" && name.endsWith("get_chunks_for_embeddings");
+}
+
+function isProcessedDocument(name: string | undefined): boolean {
+ return typeof name === "string" && name.endsWith("get_processed_document");
+}
+
+function argumentText(args: unknown, key: string): string {
+ if (!args || typeof args !== "object" || Array.isArray(args)) return "";
+ const value = (args as Record)[key];
+ return typeof value === "string" ? value : "";
+}
+
+function parseJsonArray(text: string): unknown[] {
+ const start = text.indexOf("[");
+ if (start < 0) return [];
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+ for (let index = start; index < text.length; index += 1) {
+ const char = text[index];
+ if (inString) {
+ if (escaped) escaped = false;
+ else if (char === "\\") escaped = true;
+ else if (char === '"') inString = false;
+ continue;
+ }
+ if (char === '"') inString = true;
+ else if (char === "[") depth += 1;
+ else if (char === "]") {
+ depth -= 1;
+ if (depth === 0) {
+ try {
+ const value: unknown = JSON.parse(text.slice(start, index + 1));
+ return Array.isArray(value) ? value : [];
+ } catch {
+ return completeObjects(text);
+ }
+ }
+ }
+ }
+ return completeObjects(text);
+}
+
+function completeObjects(text: string): unknown[] {
+ const start = text.indexOf("[");
+ if (start < 0) return [];
+ const objects: unknown[] = [];
+ let depth = 0;
+ let objectStart = -1;
+ let inString = false;
+ let escaped = false;
+ for (let index = start + 1; index < text.length; index += 1) {
+ const char = text[index];
+ if (inString) {
+ if (escaped) escaped = false;
+ else if (char === "\\") escaped = true;
+ else if (char === '"') inString = false;
+ continue;
+ }
+ if (char === '"') inString = true;
+ else if (char === "{") {
+ if (depth === 0) objectStart = index;
+ depth += 1;
+ } else if (char === "}") {
+ depth -= 1;
+ if (depth === 0 && objectStart >= 0) {
+ objects.push(parsedValue(text.slice(objectStart, index + 1)));
+ objectStart = -1;
+ }
+ } else if (char === "]" && depth === 0) break;
+ }
+ return objects.filter((item) => item !== undefined);
+}
+
+function parsedValue(text: string): unknown {
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ return undefined;
+ }
+}
+
+function titleFromText(text: string): string {
+ const line = text.split("\n").map((part) => part.trim()).find(Boolean) ?? "Document";
+ const plain = line
+ .replace(/^#+\s*/, "")
+ .replace(/\*\*/g, "")
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
+ if (plain.length <= MAX_TITLE_CHARS) return plain;
+ return `${plain.slice(0, MAX_TITLE_CHARS - 3)}...`;
+}
+
+function pageNumbers(regions: unknown): number[] {
+ if (!Array.isArray(regions)) return [];
+ const pages = new Set();
+ for (const region of regions) {
+ if (!region || typeof region !== "object") continue;
+ const page = (region as { pageNo?: unknown }).pageNo;
+ if (typeof page === "number" && Number.isFinite(page)) pages.add(page);
+ }
+ return [...pages].sort((left, right) => left - right);
+}
+
+function toolCalls(messages: readonly unknown[]): ToolCall[] {
+ const calls: ToolCall[] = [];
+ for (const message of messages) {
+ if (!message || typeof message !== "object") continue;
+ const list = (message as { tool_calls?: unknown }).tool_calls;
+ if (!Array.isArray(list)) continue;
+ for (const call of list) {
+ if (call && typeof call === "object") calls.push(call as ToolCall);
+ }
+ }
+ return calls;
+}
+
+function textOf(content: unknown): string {
+ if (typeof content === "string") return content;
+ if (!Array.isArray(content)) return "";
+ return content.map((block) => {
+ if (typeof block === "string") return block;
+ if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
+ const text = (block as { text?: unknown }).text;
+ return typeof text === "string" ? text : "";
+ }
+ return "";
+ }).join("");
+}
+
+function answerText(messages: readonly unknown[]): string {
+ const parts: string[] = [];
+ for (const message of messages) {
+ if (!message || typeof message !== "object") continue;
+ if (String((message as { type?: unknown }).type).toLowerCase() !== "ai") continue;
+ parts.push(textOf((message as { content?: unknown }).content));
+ }
+ return parts.join("\n");
+}
+
+function titlesFromAnswer(text: string): Map {
+ const titles = new Map();
+ for (const match of joinSplitCitationLinks(text).matchAll(MARKDOWN_LINK)) {
+ const fileId = fileIdFromCitationHref(match[2] ?? "");
+ const title = match[1]?.trim();
+ if (fileId && title && !titles.has(fileId)) titles.set(fileId, title);
+ }
+ return titles;
+}
+
+function rememberProcessedDocuments(
+ calls: ToolCall[],
+ byFile: Map,
+ titles: Map,
+): void {
+ for (const call of calls) {
+ if (!isProcessedDocument(call.name)) continue;
+ const fileId = argumentText(call.args, "file_id");
+ const pipeline = argumentText(call.args, "pipeline_name");
+ if (!fileId) continue;
+ const existing = byFile.get(fileId);
+ if (existing) {
+ if (!existing.pipeline) existing.pipeline = pipeline;
+ continue;
+ }
+ byFile.set(fileId, emptyReference(fileId, titles.get(fileId) ?? "Document", pipeline));
+ }
+ const pipelines = new Set([...byFile.values()].map((item) => item.pipeline).filter(Boolean));
+ if (pipelines.size !== 1) return;
+ const pipeline = [...pipelines][0] ?? "";
+ for (const [fileId, title] of titles) {
+ if (!byFile.has(fileId)) byFile.set(fileId, emptyReference(fileId, title, pipeline));
+ }
+}
+
+function emptyReference(fileId: string, title: string, pipeline: string): DocumentReference {
+ return { fileId, title, pipeline, pages: [], passages: [], images: [] };
+}
+
+function rememberPage(reference: DocumentReference, page: number | null): void {
+ if (page == null || reference.pages.includes(page)) return;
+ reference.pages.push(page);
+ reference.pages.sort((left, right) => left - right);
+}
+
+function addCitedPassage(reference: DocumentReference, target: CitationTarget): void {
+ const found = reference.passages.find((passage) => passage.chunkId === target.chunkId);
+ if (found) {
+ if (found.page == null && target.page != null) found.page = target.page;
+ rememberPage(reference, target.page ?? found.page);
+ return;
+ }
+ if (reference.passages.length >= MAX_PASSAGES) return;
+ reference.passages.push({ page: target.page, text: "", chunkId: target.chunkId });
+ rememberPage(reference, target.page);
+}
+
+function rememberAnswerCitations(text: string, byFile: Map): void {
+ for (const match of joinSplitCitationLinks(text).matchAll(MARKDOWN_LINK)) {
+ const label = match[1]?.trim() ?? "";
+ const target = citationTarget(match[2] ?? "", label);
+ if (!target) continue;
+ const existing = byFile.get(target.fileId);
+ if (!existing) {
+ const created = emptyReference(target.fileId, label || "Document", target.pipeline);
+ addCitedPassage(created, target);
+ byFile.set(target.fileId, created);
+ continue;
+ }
+ if (!existing.pipeline && target.pipeline) existing.pipeline = target.pipeline;
+ if (existing.title === "Document" && label) existing.title = label;
+ addCitedPassage(existing, target);
+ }
+}
+
+export type ViewerPage = {
+ fileId: string;
+ title: string;
+ pipeline: string;
+ page: number | null;
+ chunkId: string | null;
+ text: string;
+};
+
+/** Cited pages in reference order. One document can contribute several pages. */
+export function viewerPages(references: readonly DocumentReference[]): ViewerPage[] {
+ const pages: ViewerPage[] = [];
+ for (const reference of references) {
+ if (reference.passages.length === 0) {
+ pages.push({
+ fileId: reference.fileId,
+ title: reference.title,
+ pipeline: reference.pipeline,
+ page: reference.pages[0] ?? null,
+ chunkId: null,
+ text: "",
+ });
+ continue;
+ }
+ for (const passage of reference.passages) {
+ pages.push({
+ fileId: reference.fileId,
+ title: reference.title,
+ pipeline: reference.pipeline,
+ page: passage.page,
+ chunkId: passage.chunkId,
+ text: passage.text,
+ });
+ }
+ }
+ return pages;
+}
+
+export function viewerStartIndex(pages: readonly ViewerPage[], fileId: string, href = ""): number {
+ const chunkId = href.trim();
+ if (chunkId) {
+ const exact = pages.findIndex((page) => page.chunkId === chunkId);
+ if (exact >= 0) return exact;
+ }
+ const first = pages.findIndex((page) => page.fileId === fileId);
+ return first >= 0 ? first : 0;
+}
+
+/** Documents retrieved in this turn, cited ones first. */
+export function documentReferences(messages: readonly unknown[]): DocumentReference[] {
+ const titles = titlesFromAnswer(answerText(messages));
+ const byFile = new Map();
+
+ for (const call of toolCalls(messages)) {
+ const content = textOf(call.content);
+ if (!isChunkSearch(call.name) || !content) continue;
+ const pipeline = /pipeline "([^"]+)"/.exec(content)?.[1] ?? "";
+ for (const item of parseJsonArray(content)) {
+ if (!item || typeof item !== "object") continue;
+ const record = item as { file_id?: unknown; chunk_text?: unknown; chunk_id?: unknown; regions?: unknown };
+ const fileId = typeof record.file_id === "string" ? record.file_id : "";
+ const text = typeof record.chunk_text === "string" ? record.chunk_text.trim() : "";
+ if (!fileId || !text) continue;
+ const chunkId = typeof record.chunk_id === "string" ? record.chunk_id : null;
+ const pages = pageNumbers(record.regions);
+ const passage: DocumentPassage = {
+ page: pages[0] ?? null,
+ text: text.length > MAX_PASSAGE_CHARS ? `${text.slice(0, MAX_PASSAGE_CHARS - 3)}...` : text,
+ chunkId,
+ };
+ const existing = byFile.get(fileId);
+ if (!existing) {
+ byFile.set(fileId, {
+ fileId,
+ title: titles.get(fileId) ?? titleFromText(text),
+ pipeline,
+ pages,
+ passages: [passage],
+ images: [],
+ });
+ continue;
+ }
+ for (const page of pages) {
+ if (!existing.pages.includes(page)) existing.pages.push(page);
+ }
+ existing.pages.sort((left, right) => left - right);
+ if (existing.passages.length < MAX_PASSAGES) existing.passages.push(passage);
+ }
+ }
+
+ rememberProcessedDocuments(toolCalls(messages), byFile, titles);
+ rememberAnswerCitations(answerText(messages), byFile);
+
+ const cited = [...titles.keys()].filter((fileId) => byFile.has(fileId));
+ const rest = [...byFile.keys()].filter((fileId) => !cited.includes(fileId));
+ return [...cited, ...rest].slice(0, MAX_DOCUMENTS).flatMap((fileId) => {
+ const reference = byFile.get(fileId);
+ return reference ? [reference] : [];
+ });
+}
diff --git a/src/frontend/lib/processed-document.test.ts b/src/frontend/lib/processed-document.test.ts
new file mode 100644
index 00000000..bd2d011a
--- /dev/null
+++ b/src/frontend/lib/processed-document.test.ts
@@ -0,0 +1,89 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { DocumentReference } from "./document-references";
+import { clearCitedPageCache, loadDocumentPages, loadDocumentView, loadPageImages, peekPageImage, processedDocumentMarkdown } from "./processed-document";
+
+const reference: DocumentReference = {
+ fileId: "doc-1",
+ title: "CY26 RHEL",
+ pipeline: "analyzeproductrhel",
+ pages: [],
+ passages: [{ page: null, text: "short passage", chunkId: "path#1" }],
+ images: [],
+};
+
+describe("processedDocumentMarkdown", () => {
+ beforeEach(() => {
+ clearCitedPageCache();
+ });
+ it("reads the document body and decodes entities", () => {
+ const payload = {
+ content: [{
+ type: "text",
+ text: `Processed document:\n${JSON.stringify({ markdown_content: "R&D {kept}" })}`,
+ }],
+ };
+ expect(processedDocumentMarkdown(payload)).toBe("R&D {kept}");
+ });
+
+ it("returns nothing when the tool reports an error", () => {
+ expect(processedDocumentMarkdown({ isError: true, content: [{ type: "text", text: "{}" }] })).toBe("");
+ });
+});
+
+describe("loadDocumentView", () => {
+ beforeEach(() => {
+ clearCitedPageCache();
+ });
+
+ it("loads the processed document and the page image", async () => {
+ const callTool = vi.fn(async (_mcp: string, name: string) => {
+ if (name === "get_processed_document") {
+ return { content: [{ type: "text", text: `Doc:\n${JSON.stringify({ markdown_content: "## Strategy" })}` }] };
+ }
+ return { content: [{ type: "image", data: "abc", mimeType: "image/png" }] };
+ });
+ const view = await loadDocumentView(reference, callTool);
+ expect(view.markdown).toBe("## Strategy");
+ expect(view.images).toEqual([{ mimeType: "image/png", data: "abc" }]);
+ expect(callTool).toHaveBeenCalledWith("unstructured-mcp", "get_citation_image", {
+ pipeline_name: "analyzeproductrhel",
+ chunk_id: "path#1",
+ });
+ });
+
+ it("reports the first page before the later pages return", async () => {
+ let releaseSecond: () => void = () => undefined;
+ const second = new Promise((resolve) => {
+ releaseSecond = resolve;
+ });
+ const callTool = vi.fn(async (_mcp: string, _name: string, args: { chunk_id?: string }) => {
+ if (args.chunk_id === "b") await second;
+ return { content: [{ type: "image", data: args.chunk_id, mimeType: "image/png" }] };
+ });
+ const seen: string[][] = [];
+ const pending = loadDocumentPages("productstrategycy26", ["a", "b"], [], callTool, (images) => {
+ seen.push(images.map((image) => image.data));
+ });
+ await vi.waitFor(() => expect(seen[0]).toEqual(["a"]));
+ releaseSecond();
+ await pending;
+ expect(seen.at(-1)).toEqual(["a", "b"]);
+ });
+
+ it("keeps the retrieved passage when the document call fails", async () => {
+ const callTool = vi.fn(async () => {
+ throw new Error("down");
+ });
+ const view = await loadDocumentView({ ...reference, passages: [{ ...reference.passages[0], chunkId: null }] }, callTool);
+ expect(view.markdown).toBe("short passage");
+ expect(view.images).toEqual([]);
+ });
+
+ it("reuses a page image that was already fetched", async () => {
+ const callTool = vi.fn(async () => ({ content: [{ type: "image", data: "cached", mimeType: "image/png" }] }));
+ await loadPageImages("productstrategycy26", "chunk-9", [], callTool);
+ expect(peekPageImage("productstrategycy26", "chunk-9")?.data).toBe("cached");
+ await loadPageImages("productstrategycy26", "chunk-9", [], callTool);
+ expect(callTool).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/frontend/lib/processed-document.ts b/src/frontend/lib/processed-document.ts
new file mode 100644
index 00000000..838eb4ee
--- /dev/null
+++ b/src/frontend/lib/processed-document.ts
@@ -0,0 +1,216 @@
+import { citationImagesFromContent, type CitationImage } from "../../server/citation-images";
+import type { DocumentReference } from "./document-references";
+
+const MCP_SERVER = "unstructured-mcp";
+const MAX_DOCUMENT_CHARS = 500_000;
+
+export type McpToolCaller = (
+ mcpName: string,
+ name: string,
+ args: Record,
+) => Promise<{ content?: unknown; isError?: boolean }>;
+
+export type DocumentView = {
+ markdown: string;
+ images: CitationImage[];
+};
+
+function textOfPayload(payload: unknown): string {
+ if (typeof payload === "string") return payload;
+ if (!payload || typeof payload !== "object") return "";
+ const content = (payload as { content?: unknown }).content;
+ if (typeof content === "string") return content;
+ if (!Array.isArray(content)) return "";
+ return content.map((block) => {
+ if (!block || typeof block !== "object") return "";
+ if ((block as { type?: unknown }).type !== "text") return "";
+ const text = (block as { text?: unknown }).text;
+ return typeof text === "string" ? text : "";
+ }).join("\n");
+}
+
+function objectFromText(text: string): Record | null {
+ const start = text.indexOf("{");
+ if (start < 0) return null;
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+ for (let index = start; index < text.length; index += 1) {
+ const char = text[index];
+ if (inString) {
+ if (escaped) escaped = false;
+ else if (char === "\\") escaped = true;
+ else if (char === '"') inString = false;
+ continue;
+ }
+ if (char === '"') inString = true;
+ else if (char === "{") depth += 1;
+ else if (char === "}") {
+ depth -= 1;
+ if (depth === 0) return parsedObject(text.slice(start, index + 1));
+ }
+ }
+ return null;
+}
+
+function parsedObject(text: string): Record | null {
+ try {
+ const value: unknown = JSON.parse(text);
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ return value as Record;
+ } catch {
+ return null;
+ }
+}
+
+function decodeEntities(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+}
+
+/** Markdown body of a get_processed_document tool result. */
+export function processedDocumentMarkdown(payload: unknown): string {
+ if (payload && typeof payload === "object" && (payload as { isError?: unknown }).isError === true) return "";
+ const record = objectFromText(textOfPayload(payload));
+ const markdown = record?.markdown_content;
+ if (typeof markdown !== "string") return "";
+ return decodeEntities(markdown).slice(0, MAX_DOCUMENT_CHARS);
+}
+
+const inflightMarkdown = new Map>();
+const inflightImages = new Map>();
+const storedImages = new Map();
+
+function imageKey(pipeline: string, chunkId: string): string {
+ return `${pipeline}:${chunkId}`;
+}
+
+/** The page image, if this session already fetched it. `undefined` means it has not finished. */
+export function peekPageImage(pipeline: string, chunkId: string): CitationImage | null | undefined {
+ const stored = storedImages.get(imageKey(pipeline, chunkId));
+ if (!stored) return undefined;
+ return stored[0] ?? null;
+}
+
+export function clearCitedPageCache(): void {
+ inflightImages.clear();
+ storedImages.clear();
+}
+
+async function readProcessed(pipeline: string, fileId: string, callTool: McpToolCaller): Promise {
+ try {
+ const result = await callTool(MCP_SERVER, "get_processed_document", {
+ pipeline_name: pipeline,
+ file_id: fileId,
+ });
+ return processedDocumentMarkdown(result);
+ } catch {
+ return "";
+ }
+}
+
+async function readImages(pipeline: string, chunkId: string, callTool: McpToolCaller): Promise {
+ try {
+ const result = await callTool(MCP_SERVER, "get_citation_image", {
+ pipeline_name: pipeline,
+ chunk_id: chunkId,
+ });
+ return citationImagesFromContent(result.content);
+ } catch {
+ return [];
+ }
+}
+
+/** One in-flight fetch per document, shared if the viewer mounts twice. */
+export function loadProcessedMarkdown(pipeline: string, fileId: string, callTool: McpToolCaller): Promise {
+ if (!pipeline || !fileId) return Promise.resolve("");
+ const key = `${pipeline}:${fileId}`;
+ const existing = inflightMarkdown.get(key);
+ if (existing) return existing;
+ const pending = readProcessed(pipeline, fileId, callTool).finally(() => {
+ inflightMarkdown.delete(key);
+ });
+ inflightMarkdown.set(key, pending);
+ return pending;
+}
+
+/** One fetch per chunk for the whole session. A later open reuses the stored image. */
+export function loadPageImages(
+ pipeline: string,
+ chunkId: string,
+ existing: CitationImage[],
+ callTool: McpToolCaller,
+): Promise {
+ if (existing.length > 0) return Promise.resolve(existing);
+ if (!pipeline || !chunkId) return Promise.resolve([]);
+ const key = imageKey(pipeline, chunkId);
+ const stored = storedImages.get(key);
+ if (stored) return Promise.resolve(stored);
+ const current = inflightImages.get(key);
+ if (current) return current;
+ const pending = readImages(pipeline, chunkId, callTool).then((images) => {
+ storedImages.set(key, images);
+ return images;
+ }).finally(() => {
+ inflightImages.delete(key);
+ });
+ inflightImages.set(key, pending);
+ return pending;
+}
+
+/** Start every cited page image while the answer is on screen, before a click. */
+export function prefetchCitedPages(
+ pages: readonly { pipeline: string; chunkId: string | null }[],
+ callTool: McpToolCaller,
+): void {
+ for (const page of pages) {
+ if (!page.pipeline || !page.chunkId) continue;
+ void loadPageImages(page.pipeline, page.chunkId, [], callTool);
+ }
+}
+
+function takePages(pages: CitationImage[][], limit = 6): CitationImage[] {
+ const images: CitationImage[] = [];
+ for (const page of pages) {
+ for (const image of page) {
+ images.push(image);
+ if (images.length >= limit) return images;
+ }
+ }
+ return images;
+}
+
+/** Page pictures in citation order. The first page is reported before the later pages return. */
+export async function loadDocumentPages(
+ pipeline: string,
+ chunkIds: readonly string[],
+ existing: CitationImage[],
+ callTool: McpToolCaller,
+ onUpdate?: (images: CitationImage[]) => void,
+): Promise {
+ if (existing.length > 0) return existing;
+ const ids = [...new Set(chunkIds.filter((id) => id.length > 0))].slice(0, 3);
+ if (ids.length === 0) return [];
+ const first = await loadPageImages(pipeline, ids[0], [], callTool);
+ let images = takePages([first]);
+ if (images.length > 0) onUpdate?.(images);
+ const rest = await Promise.all(ids.slice(1).map((id) => loadPageImages(pipeline, id, [], callTool)));
+ images = takePages([first, ...rest]);
+ if (rest.length > 0 && images.length > 0) onUpdate?.(images);
+ return images;
+}
+
+/** Full document text plus any page image the unstructured server can return. */
+export async function loadDocumentView(reference: DocumentReference, callTool: McpToolCaller): Promise {
+ const chunkId = reference.passages.find((passage) => passage.chunkId)?.chunkId ?? "";
+ const [markdown, images] = await Promise.all([
+ loadProcessedMarkdown(reference.pipeline, reference.fileId, callTool),
+ loadPageImages(reference.pipeline, chunkId, reference.images, callTool),
+ ]);
+ const fallback = reference.passages.map((passage) => passage.text).join("\n\n");
+ return { markdown: markdown || fallback, images };
+}
diff --git a/src/server/citation-images.test.ts b/src/server/citation-images.test.ts
new file mode 100644
index 00000000..a0f97d7d
--- /dev/null
+++ b/src/server/citation-images.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { citationImagesFromContent } from "./citation-images";
+
+describe("citationImagesFromContent", () => {
+ it("keeps png and jpeg blocks and drops text", () => {
+ const images = citationImagesFromContent([
+ { type: "text", text: "Citation for chunk, 1 page(s): 94" },
+ { type: "image", base64: "abc", mime_type: "image/png" },
+ { type: "image", data: "def", mimeType: "image/jpeg" },
+ ]);
+ expect(images).toEqual([
+ { mimeType: "image/png", data: "abc" },
+ { mimeType: "image/jpeg", data: "def" },
+ ]);
+ });
+
+ it("returns nothing for a plain string", () => {
+ expect(citationImagesFromContent("Citation for chunk")).toEqual([]);
+ });
+
+ it("rejects a non-image mime and a remote url", () => {
+ const images = citationImagesFromContent([
+ { type: "image", data: "abc", mimeType: "image/svg+xml" },
+ { type: "image", data: "https://example.com/a.png", mimeType: "image/png" },
+ ]);
+ expect(images).toEqual([]);
+ });
+});
diff --git a/src/server/citation-images.ts b/src/server/citation-images.ts
new file mode 100644
index 00000000..5fb93725
--- /dev/null
+++ b/src/server/citation-images.ts
@@ -0,0 +1,33 @@
+/** A page crop returned by get_citation_image. */
+export type CitationImage = {
+ mimeType: string;
+ data: string;
+};
+
+const MAX_IMAGES = 4;
+const MAX_DATA_CHARS = 1_500_000;
+const ALLOWED_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
+
+function imageBlock(block: unknown): CitationImage | null {
+ if (!block || typeof block !== "object") return null;
+ const record = block as Record;
+ if (record.type !== "image") return null;
+ const data = typeof record.base64 === "string" ? record.base64 : typeof record.data === "string" ? record.data : "";
+ if (!data || data.startsWith("http") || data.length > MAX_DATA_CHARS) return null;
+ const mime = typeof record.mime_type === "string" ? record.mime_type : typeof record.mimeType === "string" ? record.mimeType : "image/png";
+ if (!ALLOWED_MIME.has(mime)) return null;
+ return { mimeType: mime, data };
+}
+
+/** Pull page crops out of a tool message. Text blocks are left for the caller. */
+export function citationImagesFromContent(content: unknown): CitationImage[] {
+ if (!Array.isArray(content)) return [];
+ const images: CitationImage[] = [];
+ for (const block of content) {
+ const image = imageBlock(block);
+ if (!image) continue;
+ images.push(image);
+ if (images.length >= MAX_IMAGES) break;
+ }
+ return images;
+}
From fdd7ec0c11020aba9f092784717d6df7aa29834c Mon Sep 17 00:00:00 2001
From: mimran-khan
Date: Thu, 12 Nov 2026 12:32:32 +0530
Subject: [PATCH 2/2] fix: finish interrupt streams so auto-approve can
complete
An interrupt with no answer text was treated as a dropped stream, so the
run stayed loading and the approval never resumed. A paused interrupt now
ends the stream, and the auto-approve test reports the thread as interrupted.
Signed-off-by: mimran-khan
---
e2e/hitl/auto-approve.spec.ts | 17 ++++++++++++++---
src/frontend/hooks/useStreamingAPI.ts | 14 ++++++++++++++
src/frontend/redux/slices/chats.test.ts | 2 +-
3 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/e2e/hitl/auto-approve.spec.ts b/e2e/hitl/auto-approve.spec.ts
index 3670080d..37ab4657 100644
--- a/e2e/hitl/auto-approve.spec.ts
+++ b/e2e/hitl/auto-approve.spec.ts
@@ -35,6 +35,18 @@ async function makeInterruptStream(
callCount: { n: number },
page: import('@playwright/test').Page,
): Promise {
+ await page.route('**/api/proxy/agent/threads/*', (route) => {
+ const path = new URL(route.request().url()).pathname;
+ const isThreadRecord = /\/threads\/(?!search$)[^/]+$/.test(path);
+ if (route.request().method() === 'GET' && isThreadRecord) {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ status: 'interrupted' }),
+ });
+ }
+ return route.fallback();
+ });
await page.route('**/api/proxy/agent/v1/stream', (route) => {
callCount.n++;
if (callCount.n === 1) {
@@ -78,8 +90,7 @@ test.describe('HITL — auto-approve', () => {
const chat = new ChatPage(page);
await chat.expectChatRoute();
- // The response should complete without the interrupt banner ever appearing
- await chat.waitForAIResponse(15_000);
+ await expect(page.getByText('Done automatically.')).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Action Required')).not.toBeVisible();
await expect(page.getByText('Input Required')).not.toBeVisible();
expect(callCount.n).toBeGreaterThanOrEqual(2);
@@ -129,7 +140,7 @@ test.describe('HITL — auto-approve', () => {
const chat = new ChatPage(page);
await chat.expectChatRoute();
- await chat.waitForAIResponse(15_000);
+ await expect(page.getByText('Done automatically.')).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Action Required')).not.toBeVisible();
expect(callCount.n).toBeGreaterThanOrEqual(2);
},
diff --git a/src/frontend/hooks/useStreamingAPI.ts b/src/frontend/hooks/useStreamingAPI.ts
index e2734cc4..aaff6b12 100644
--- a/src/frontend/hooks/useStreamingAPI.ts
+++ b/src/frontend/hooks/useStreamingAPI.ts
@@ -1004,6 +1004,20 @@ export function useStreamingAPI(threadId: string) {
dispatch(resolveAllPendingToolCalls({ chatId: threadId }));
}
lastSuccessfulConnectionRef.current = Date.now();
+ if (streamEndedWithInterruptRef.current) {
+ dispatch(updateStreamingState({
+ chatId: threadId,
+ state: {
+ isLoading: false,
+ isConnected: false,
+ isReconnecting: false,
+ reconnectAttempt: 0,
+ streamDroppedMidResponse: false,
+ },
+ }));
+ finish('success');
+ return;
+ }
const gotFullResponse = messagesRef.current.length > initialMessageCount + 1;
if (gotFullResponse && recoveryIntervalRef.current) {
clearInterval(recoveryIntervalRef.current);
diff --git a/src/frontend/redux/slices/chats.test.ts b/src/frontend/redux/slices/chats.test.ts
index b0c99538..4cecbc04 100644
--- a/src/frontend/redux/slices/chats.test.ts
+++ b/src/frontend/redux/slices/chats.test.ts
@@ -168,7 +168,7 @@ describe('chats slice — appendMessageToChat', () => {
describe('chats slice — subagent transcript', () => {
function withSubagentCard() {
- let s = chatsReducer(initialState(), addChat(makeChat('c1')));
+ const s = chatsReducer(initialState(), addChat(makeChat('c1')));
const card: TestMessage = {
type: 'ai',
content: 'Asking the analyst.',