From a4275acdce26592547e904a7199930d5253112f7 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 24 Apr 2026 17:17:28 +0300 Subject: [PATCH] Fix frontend selection collisions, stale fetches, and URL consistency --- app/frontend/components/Chat.tsx | 2 +- app/frontend/components/CitationBlock.tsx | 23 ++++++- app/frontend/components/DbInfo.tsx | 3 +- app/frontend/components/DocumentFilter.tsx | 75 +++++++++++++--------- 4 files changed, 65 insertions(+), 38 deletions(-) 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({