Fix frontend selection collisions, stale fetches, and URL consistency
This commit is contained in:
parent
821b7361e9
commit
a4275acdce
4 changed files with 65 additions and 38 deletions
|
|
@ -376,7 +376,7 @@ function MessageViewWithCitations({
|
|||
if (latestCitations.length > 0) {
|
||||
result.push(
|
||||
<CitationBlock
|
||||
key={`citations-${i}`}
|
||||
key={`citations-${msg.id}`}
|
||||
citations={latestCitations}
|
||||
/>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue