Fix frontend selection collisions, stale fetches, and URL consistency

This commit is contained in:
Yiorgis Gozadinos 2026-04-24 17:17:28 +03:00
parent 821b7361e9
commit a4275acdce
No known key found for this signature in database
4 changed files with 65 additions and 38 deletions

View file

@ -376,7 +376,7 @@ function MessageViewWithCitations({
if (latestCitations.length > 0) { if (latestCitations.length > 0) {
result.push( result.push(
<CitationBlock <CitationBlock
key={`citations-${i}`} key={`citations-${msg.id}`}
citations={latestCitations} citations={latestCitations}
/>, />,
); );

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Citation } from "../lib/sessionStorage"; import type { Citation } from "../lib/sessionStorage";
interface CitationBlockProps { interface CitationBlockProps {
@ -73,8 +73,20 @@ export default function CitationBlock({ citations }: CitationBlockProps) {
loading: false, loading: false,
error: null, 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<AbortController | null>(null);
// Abort any in-flight request on unmount.
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
const fetchVisualGrounding = useCallback(async (chunkId: string) => { const fetchVisualGrounding = useCallback(async (chunkId: string) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setVisualGrounding({ setVisualGrounding({
isOpen: true, isOpen: true,
chunkId, chunkId,
@ -84,10 +96,12 @@ export default function CitationBlock({ citations }: CitationBlockProps) {
}); });
try { try {
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || ""; const response = await fetch(`/api/visualize/${chunkId}`, {
const response = await fetch(`${backendUrl}/api/visualize/${chunkId}`); signal: controller.signal,
});
const data = await response.json(); const data = await response.json();
if (controller.signal.aborted) return;
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || "Failed to fetch visual grounding"); 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, error: data.images?.length === 0 ? data.message : null,
})); }));
} catch (err) { } catch (err) {
if (controller.signal.aborted) return;
setVisualGrounding((prev) => ({ setVisualGrounding((prev) => ({
...prev, ...prev,
loading: false, loading: false,
@ -108,6 +123,8 @@ export default function CitationBlock({ citations }: CitationBlockProps) {
}, []); }, []);
const closeVisualGrounding = useCallback(() => { const closeVisualGrounding = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setVisualGrounding({ setVisualGrounding({
isOpen: false, isOpen: false,
chunkId: null, chunkId: null,

View file

@ -25,8 +25,7 @@ export default function DbInfo() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || ""; fetch("/api/info")
fetch(`${backendUrl}/api/info`)
.then((res) => res.json()) .then((res) => res.json())
.then(setInfo) .then(setInfo)
.catch((err) => setError(err.message)); .catch((err) => setError(err.message));

View file

@ -16,6 +16,8 @@ interface DocumentFilterProps {
onApply: (selected: string[]) => void; onApply: (selected: string[]) => void;
} }
const getDisplayName = (doc: Document) => doc.title || doc.uri || doc.id;
export default function DocumentFilter({ export default function DocumentFilter({
isOpen, isOpen,
onClose, onClose,
@ -26,21 +28,14 @@ export default function DocumentFilter({
const [documents, setDocuments] = useState<Document[]>([]); const [documents, setDocuments] = useState<Document[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [localSelected, setLocalSelected] = useState<Set<string>>( // Track selection by document id — two docs can share a title, but ids
new Set(selected), // are unique. Display names are only used for rendering and for the
); // filter string returned to the parent.
const [localSelected, setLocalSelected] = useState<Set<string>>(new Set());
// Reset local state when modal opens // Refetch on every open so newly-added or deleted documents show up.
useEffect(() => { useEffect(() => {
if (isOpen) { if (!isOpen) return;
setLocalSelected(new Set(selected));
setSearchTerm("");
}
}, [isOpen, selected]);
// Fetch documents when modal opens
useEffect(() => {
if (isOpen && documents.length === 0) {
setLoading(true); setLoading(true);
fetch("/api/documents") fetch("/api/documents")
.then((res) => res.json()) .then((res) => res.json())
@ -51,8 +46,22 @@ export default function DocumentFilter({
.catch(() => { .catch(() => {
setLoading(false); setLoading(false);
}); });
} }, [isOpen]);
}, [isOpen, documents.length]);
// 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) 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( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@ -63,20 +72,24 @@ export default function DocumentFilter({
[onClose], [onClose],
); );
const toggleDocument = (displayName: string) => { const toggleDocument = (docId: string) => {
setLocalSelected((prev) => { setLocalSelected((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(displayName)) { if (next.has(docId)) {
next.delete(displayName); next.delete(docId);
} else { } else {
next.add(displayName); next.add(docId);
} }
return next; return next;
}); });
}; };
const handleApply = () => { 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(); onClose();
}; };
@ -84,8 +97,6 @@ export default function DocumentFilter({
setLocalSelected(new Set()); setLocalSelected(new Set());
}; };
const getDisplayName = (doc: Document) => doc.title || doc.uri || doc.id;
const filteredDocuments = documents.filter((doc) => { const filteredDocuments = documents.filter((doc) => {
if (!searchTerm) return true; if (!searchTerm) return true;
const displayName = getDisplayName(doc).toLowerCase(); const displayName = getDisplayName(doc).toLowerCase();
@ -144,8 +155,8 @@ export default function DocumentFilter({
<label key={doc.id} className="filter-item"> <label key={doc.id} className="filter-item">
<input <input
type="checkbox" type="checkbox"
checked={localSelected.has(displayName)} checked={localSelected.has(doc.id)}
onChange={() => toggleDocument(displayName)} onChange={() => toggleDocument(doc.id)}
/> />
<span className="filter-item-label">{displayName}</span> <span className="filter-item-label">{displayName}</span>
</label> </label>