fix(dashboard): Previous/Next nav closing modal on stale background list

Background list polling intentionally pauses while a request's detail
modal is open, so hitting the edge of the in-memory sorted list didn't mean
there was really nothing newer/older — it just meant the client hadn't
fetched requests that landed in the background yet. handlePrev/handleNext
now resync the list once at that boundary and let a follow-up effect decide
whether to navigate or actually close, instead of assuming the boundary is
real.

Also removes onNavigateToLog from RequestLoggerV2/RequestTimeline's calls
into RequestLoggerDetail — that prop no longer exists after the detail
panel's cross-row next-turn navigation was removed in the prior commit.
This commit is contained in:
Markus Hartung
2026-08-06 06:01:01 +02:00
parent 9384391daf
commit ffbc46f463
2 changed files with 90 additions and 39 deletions

View File

@@ -188,6 +188,12 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
const hasScrolledRef = useRef(false);
const [providerNodes, setProviderNodes] = useState([]);
const visibleRef = useRef(true);
// Set when handlePrev/handleNext hits the edge of the (possibly stale —
// list polling pauses while a detail modal is open) in-memory list, so we
// can tell a genuine "no more items" from "more items landed in the
// background while the modal was open and we just haven't fetched them
// yet" before giving up and closing the modal.
const pendingBoundaryNavRef = useRef<null | "prev" | "next">(null);
const [visibleColumns, setVisibleColumns] = useState(() => {
const defaultVisible = Object.fromEntries(columns.map((c) => [c.key, true]));
@@ -750,9 +756,14 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
console.error("Failed to open previous log id:", error_);
});
} else {
closeDetail();
// List polling pauses while the modal is open (#background list can
// go stale), so hitting the edge of the in-memory array doesn't mean
// there's really nothing newer — resync once and let the effect below
// decide, instead of assuming this is the last item and closing.
pendingBoundaryNavRef.current = "prev";
fetchLogs(false);
}
}, [currentLogIndex, sortedLogsForNav]);
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
const handleNext = useCallback(() => {
const idx = currentLogIndex;
@@ -764,10 +775,44 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
.catch((error_) => {
console.error("Failed to open previous log id:", error_);
});
} else {
pendingBoundaryNavRef.current = "next";
fetchLogs(false);
}
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
// Resolves a pending boundary nav (see handlePrev/handleNext) once a
// triggered fetchLogs() resync has landed in sortedLogsForNav. Only fires
// when a boundary nav is actually pending, so this is a no-op on the
// normal (paused-while-modal-open) list-update cadence.
useEffect(() => {
const direction = pendingBoundaryNavRef.current;
if (!direction || !selectedLog) return;
pendingBoundaryNavRef.current = null;
const idx = sortedLogsForNav.findIndex((l) => l.id === selectedLog.id);
const target =
direction === "prev"
? idx > 0
? sortedLogsForNav[idx - 1]
: null
: idx >= 0 && idx < sortedLogsForNav.length - 1
? sortedLogsForNav[idx + 1]
: null;
if (target?.id) {
openDetail(target)
.then((r) => r)
.catch((error_) => {
console.error("Failed to open adjacent log id:", error_);
});
} else {
closeDetail();
}
}, [currentLogIndex, sortedLogsForNav]);
// openDetail/closeDetail are plain functions re-created every render
// (same as handlePrev/handleNext above and the rest of this file) —
// listing them would re-fire this effect on every render instead of
// only when sortedLogsForNav/selectedLog actually change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortedLogsForNav, selectedLog]);
const toggleDetailLogging = async () => {
setDetailLoggingLoading(true);
@@ -1629,7 +1674,6 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
closeDetail();
openDetail(r);
}}
onNavigateToLog={(id) => openDetail({ id })}
/>
)}
</div>

View File

@@ -481,40 +481,43 @@ export default function RequestTimeline({
// Deep-link support: open the request from ?id= on mount without waiting for
// it to show up in the polled `logs` list (mirrors RequestLoggerV2's openDetail).
const openById = useCallback(async (id: string) => {
try {
const url = new URL(globalThis.location.href);
url.searchParams.set("id", id);
router.replace(url.pathname + url.search);
} catch {
// ignore navigation errors
}
setDetailLoading(true);
try {
const res = await fetch(`/api/logs/${id}`, { cache: "no-store" });
const data = res.ok ? await res.json() : null;
if (data) {
setSelectedLog({
id: data.id ?? id,
timestamp: data.timestamp,
status: data.status ?? 0,
model: data.model ?? null,
provider: data.provider ?? null,
account: data.account ?? null,
duration: data.duration ?? 0,
tokens: data.tokens ?? { in: 0, out: 0 },
active: data.active,
error: data.error ?? null,
path: data.path ?? null,
});
setDetailData(data);
const openById = useCallback(
async (id: string) => {
try {
const url = new URL(globalThis.location.href);
url.searchParams.set("id", id);
router.replace(url.pathname + url.search);
} catch {
// ignore navigation errors
}
} catch {
// ignore fetch errors
} finally {
setDetailLoading(false);
}
}, [router]);
setDetailLoading(true);
try {
const res = await fetch(`/api/logs/${id}`, { cache: "no-store" });
const data = res.ok ? await res.json() : null;
if (data) {
setSelectedLog({
id: data.id ?? id,
timestamp: data.timestamp,
status: data.status ?? 0,
model: data.model ?? null,
provider: data.provider ?? null,
account: data.account ?? null,
duration: data.duration ?? 0,
tokens: data.tokens ?? { in: 0, out: 0 },
active: data.active,
error: data.error ?? null,
path: data.path ?? null,
});
setDetailData(data);
}
} catch {
// ignore fetch errors
} finally {
setDetailLoading(false);
}
},
[router]
);
useEffect(() => {
if (!initialSelectedId || initialOpenedRef.current) return;
@@ -832,7 +835,12 @@ export default function RequestTimeline({
bar pair sharing a lane. */}
<svg
className="absolute left-0 right-0 pointer-events-none text-primary/60"
style={{ top: AXIS_HEIGHT, height: (maxLane + 1) * LANE_HEIGHT, width: "100%", zIndex: 1 }}
style={{
top: AXIS_HEIGHT,
height: (maxLane + 1) * LANE_HEIGHT,
width: "100%",
zIndex: 1,
}}
viewBox={`0 0 100 ${(maxLane + 1) * LANE_HEIGHT}`}
preserveAspectRatio="none"
>
@@ -1028,7 +1036,6 @@ export default function RequestTimeline({
onNext={undefined}
relatedLogs={[]}
onSelectRelated={undefined}
onNavigateToLog={openById}
/>
)}
</div>