diff --git a/app/frontend/components/Chat.tsx b/app/frontend/components/Chat.tsx
index 17a50eca..a2f2f6b5 100644
--- a/app/frontend/components/Chat.tsx
+++ b/app/frontend/components/Chat.tsx
@@ -376,7 +376,7 @@ function MessageViewWithCitations({
if (latestCitations.length > 0) {
result.push(
,
);
diff --git a/app/frontend/components/CitationBlock.tsx b/app/frontend/components/CitationBlock.tsx
index b4c463bd..3221e0ec 100644
--- a/app/frontend/components/CitationBlock.tsx
+++ b/app/frontend/components/CitationBlock.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import type { Citation } from "../lib/sessionStorage";
interface CitationBlockProps {
@@ -73,8 +73,20 @@ export default function CitationBlock({ citations }: CitationBlockProps) {
loading: false,
error: null,
});
+ // AbortController for the in-flight visualize fetch so a rapid close/reopen
+ // doesn't let a stale response overwrite the new request's state.
+ const abortRef = useRef(null);
+
+ // Abort any in-flight request on unmount.
+ useEffect(() => {
+ return () => abortRef.current?.abort();
+ }, []);
const fetchVisualGrounding = useCallback(async (chunkId: string) => {
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+
setVisualGrounding({
isOpen: true,
chunkId,
@@ -84,10 +96,12 @@ export default function CitationBlock({ citations }: CitationBlockProps) {
});
try {
- const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "";
- const response = await fetch(`${backendUrl}/api/visualize/${chunkId}`);
+ const response = await fetch(`/api/visualize/${chunkId}`, {
+ signal: controller.signal,
+ });
const data = await response.json();
+ if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error(data.error || "Failed to fetch visual grounding");
}
@@ -99,6 +113,7 @@ export default function CitationBlock({ citations }: CitationBlockProps) {
error: data.images?.length === 0 ? data.message : null,
}));
} catch (err) {
+ if (controller.signal.aborted) return;
setVisualGrounding((prev) => ({
...prev,
loading: false,
@@ -108,6 +123,8 @@ export default function CitationBlock({ citations }: CitationBlockProps) {
}, []);
const closeVisualGrounding = useCallback(() => {
+ abortRef.current?.abort();
+ abortRef.current = null;
setVisualGrounding({
isOpen: false,
chunkId: null,
diff --git a/app/frontend/components/DbInfo.tsx b/app/frontend/components/DbInfo.tsx
index 8f50ffa8..7d0869d3 100644
--- a/app/frontend/components/DbInfo.tsx
+++ b/app/frontend/components/DbInfo.tsx
@@ -25,8 +25,7 @@ export default function DbInfo() {
const [error, setError] = useState(null);
useEffect(() => {
- const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "";
- fetch(`${backendUrl}/api/info`)
+ fetch("/api/info")
.then((res) => res.json())
.then(setInfo)
.catch((err) => setError(err.message));
diff --git a/app/frontend/components/DocumentFilter.tsx b/app/frontend/components/DocumentFilter.tsx
index d2296961..1fa56d46 100644
--- a/app/frontend/components/DocumentFilter.tsx
+++ b/app/frontend/components/DocumentFilter.tsx
@@ -16,6 +16,8 @@ interface DocumentFilterProps {
onApply: (selected: string[]) => void;
}
+const getDisplayName = (doc: Document) => doc.title || doc.uri || doc.id;
+
export default function DocumentFilter({
isOpen,
onClose,
@@ -26,33 +28,40 @@ export default function DocumentFilter({
const [documents, setDocuments] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
- const [localSelected, setLocalSelected] = useState>(
- new Set(selected),
- );
+ // Track selection by document id — two docs can share a title, but ids
+ // are unique. Display names are only used for rendering and for the
+ // filter string returned to the parent.
+ const [localSelected, setLocalSelected] = useState>(new Set());
- // Reset local state when modal opens
+ // Refetch on every open so newly-added or deleted documents show up.
useEffect(() => {
- if (isOpen) {
- setLocalSelected(new Set(selected));
- setSearchTerm("");
- }
- }, [isOpen, selected]);
+ if (!isOpen) return;
+ setLoading(true);
+ fetch("/api/documents")
+ .then((res) => res.json())
+ .then((data) => {
+ setDocuments(data.documents || []);
+ setLoading(false);
+ })
+ .catch(() => {
+ setLoading(false);
+ });
+ }, [isOpen]);
- // Fetch documents when modal opens
+ // Seed local selection from the parent's display-name list once documents
+ // are available. Any doc whose display name is in `selected` starts checked.
useEffect(() => {
- if (isOpen && documents.length === 0) {
- setLoading(true);
- fetch("/api/documents")
- .then((res) => res.json())
- .then((data) => {
- setDocuments(data.documents || []);
- setLoading(false);
- })
- .catch(() => {
- setLoading(false);
- });
- }
- }, [isOpen, documents.length]);
+ if (!isOpen) return;
+ const selectedNames = new Set(selected);
+ setLocalSelected(
+ new Set(
+ documents
+ .filter((d) => selectedNames.has(getDisplayName(d)))
+ .map((d) => d.id),
+ ),
+ );
+ setSearchTerm("");
+ }, [isOpen, selected, documents]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -63,20 +72,24 @@ export default function DocumentFilter({
[onClose],
);
- const toggleDocument = (displayName: string) => {
+ const toggleDocument = (docId: string) => {
setLocalSelected((prev) => {
const next = new Set(prev);
- if (next.has(displayName)) {
- next.delete(displayName);
+ if (next.has(docId)) {
+ next.delete(docId);
} else {
- next.add(displayName);
+ next.add(docId);
}
return next;
});
};
const handleApply = () => {
- onApply(Array.from(localSelected));
+ const names = documents
+ .filter((d) => localSelected.has(d.id))
+ .map(getDisplayName);
+ // Dedupe: two selected docs sharing a title collapse to one filter term.
+ onApply(Array.from(new Set(names)));
onClose();
};
@@ -84,8 +97,6 @@ export default function DocumentFilter({
setLocalSelected(new Set());
};
- const getDisplayName = (doc: Document) => doc.title || doc.uri || doc.id;
-
const filteredDocuments = documents.filter((doc) => {
if (!searchTerm) return true;
const displayName = getDisplayName(doc).toLowerCase();
@@ -144,8 +155,8 @@ export default function DocumentFilter({