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) {
|
if (latestCitations.length > 0) {
|
||||||
result.push(
|
result.push(
|
||||||
<CitationBlock
|
<CitationBlock
|
||||||
key={`citations-${i}`}
|
key={`citations-${msg.id}`}
|
||||||
citations={latestCitations}
|
citations={latestCitations}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue