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) {
result.push(
<CitationBlock
key={`citations-${i}`}
key={`citations-${msg.id}`}
citations={latestCitations}
/>,
);

View file

@ -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<AbortController | null>(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,

View file

@ -25,8 +25,7 @@ export default function DbInfo() {
const [error, setError] = useState<string | null>(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));

View file

@ -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<Document[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [localSelected, setLocalSelected] = useState<Set<string>>(
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<Set<string>>(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({
<label key={doc.id} className="filter-item">
<input
type="checkbox"
checked={localSelected.has(displayName)}
onChange={() => toggleDocument(displayName)}
checked={localSelected.has(doc.id)}
onChange={() => toggleDocument(doc.id)}
/>
<span className="filter-item-label">{displayName}</span>
</label>