feat(dashboard): add agentic conversation tracking with live transcript view

Every agentic chat request now gets a conversation id (X-ConversationId
response header), and OmniRoute detects when a follow-up request continues
the same conversation via fingerprint + bounded prefix-hash matching, with a
strict-growth invariant to prevent false merges between independent
single-shot requests.

Dashboard changes:
- /dashboard/logs: toggleable Conversation column
- /dashboard/logs/timeline: same-conversation requests share a timeline lane,
  connected by an arrow, with a configurable lane-reuse window
- Request detail panel: new "Full Conversation" transcript above the raw SSE
  event stream, with Markdown rendering, per-turn timestamps, turn-relative
  view (only turns up to the one you opened, with a jump-to-next link),
  click-any-turn-to-open-its-log navigation, and live auto-refresh (with an
  auto-follow toggle, matching the event stream's autoscroll pattern) that
  rebuilds the transcript in real time from the in-flight SSE chunk buffer
  while a request is still streaming
- New /dashboard/conversations page listing only conversations with 2+ turns
- Configurable auto-refresh intervals on both the timeline and conversations
  list pages

Also fixes a pre-existing bug where the timeline view never showed SSE/
stream-chunk events or respected email-masking, because RequestTimeline.tsx
hardcoded debugEnabled/emailsVisible instead of reading the same
server-side/store state RequestLoggerV2.tsx already used, and makes the
request detail panel and conversations list responsive on mobile.
This commit is contained in:
Markus Hartung
2026-08-04 17:41:21 +02:00
parent 2cb7567d66
commit 47b4a55f2d
69 changed files with 4625 additions and 1118 deletions

View File

@@ -8,6 +8,11 @@ import {
} from "@/shared/constants/colors";
import { formatDuration, formatApiKeyLabel, maskAccount } from "@/shared/utils/formatting";
import { formatErrorForDisplay } from "@/shared/utils/formatting";
import { ChatBubble } from "@/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble";
// Same key RequestTimeline.tsx persists its lane-reuse-window setting under —
// deliberately reused (not a separate setting) so "is this conversation still
// in progress" means the same thing everywhere in the dashboard.
import { CONVERSATION_LANE_REUSE_STORAGE_KEY } from "@/shared/components/RequestTimeline";
// ─── Payload Code Block ─────────────────────────────────────────────────────
@@ -62,6 +67,283 @@ function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen =
);
}
// ─── Full Conversation transcript section ───────────────────────────────────
// Renders the multi-turn chat transcript for this request's conversation
// (see open-sse/services/conversationTracker.ts and
// src/mitm/inspector/multiRowConversation.ts). The API route
// (src/app/api/logs/[id]/route.ts) already reconstructs the turn-relative
// transcript (turns up to and including the currently-viewed request) tagged
// with each turn's own source call_logs id + timestamp, so this component
// only needs to render + wire up navigation/auto-refresh — no normalization
// happens here.
// Waiting for a brand new row/turn to appear (nothing streaming right now).
const CONVERSATION_POLL_INTERVAL_MS = 4000;
// The currently-viewed row itself is actively streaming (detail.active===true)
// — poll fast so the live turn's text visibly grows, matching the raw SSE panel.
const CONVERSATION_ACTIVE_POLL_INTERVAL_MS = 1200;
const DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES = 2;
// Set right before navigating via "View next message" so the freshly-mounted
// section (a new request's detail remounts this component via key={log.id})
// knows to scroll itself into view instead of leaving the reader at the top
// of the modal — sessionStorage survives the remount without needing this
// threaded as a prop through every host page (RequestLoggerV2/RequestTimeline/
// the conversations list panel).
const CONVERSATION_SCROLL_FLAG_KEY = "conversationScrollToPanelOnMount";
// Same naming convention as StreamSection's "pref:stream:autoscroll".
const CONVERSATION_AUTO_FOLLOW_STORAGE_KEY = "pref:conversation:autoFollow";
function getConversationReuseWindowMs() {
try {
const saved = localStorage.getItem(CONVERSATION_LANE_REUSE_STORAGE_KEY);
const minutes = saved ? Number(saved) : DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES;
if (Number.isFinite(minutes) && minutes > 0) return minutes * 60 * 1000;
} catch {
// localStorage unavailable — fall through to default
}
return DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES * 60 * 1000;
}
function ConversationTranscriptSection({
turns,
nextId,
isLatest,
lastSeenAt,
earlierTurnsOmitted,
currentLogId,
onNavigateToLog,
}) {
const [open, setOpen] = useState(true);
const [polling, setPolling] = useState(false);
const [liveTurns, setLiveTurns] = useState(turns);
const [autoFollow, setAutoFollow] = useState(() => {
try {
const v = localStorage.getItem(CONVERSATION_AUTO_FOLLOW_STORAGE_KEY);
return v == null ? true : v === "1";
} catch {
return true;
}
});
const [reuseMinutes, setReuseMinutes] = useState(() => {
try {
const saved = localStorage.getItem(CONVERSATION_LANE_REUSE_STORAGE_KEY);
const n = saved ? Number(saved) : DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES;
} catch {
return DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES;
}
});
const sectionRef = useRef<HTMLDivElement>(null);
const turnsBoxRef = useRef<HTMLDivElement>(null);
// Keep the box scrolled to the newest turn as liveTurns grows — same
// scroll-on-content-change idea as StreamSection's autoscroll, applied to
// the turn list instead of the raw chunk text.
useEffect(() => {
if (!open) return;
const el = turnsBoxRef.current;
if (!el) return;
requestAnimationFrame(() => {
try {
el.scrollTop = el.scrollHeight;
} catch {
// ignore — best-effort UX only
}
});
}, [liveTurns, open]);
const toggleAutoFollow = () => {
const next = !autoFollow;
setAutoFollow(next);
try {
localStorage.setItem(CONVERSATION_AUTO_FOLLOW_STORAGE_KEY, next ? "1" : "0");
} catch {
// localStorage unavailable — the toggle still works for this session
}
};
useEffect(() => {
let shouldScroll = false;
try {
shouldScroll = sessionStorage.getItem(CONVERSATION_SCROLL_FLAG_KEY) === "1";
if (shouldScroll) sessionStorage.removeItem(CONVERSATION_SCROLL_FLAG_KEY);
} catch {
// sessionStorage unavailable — skip the scroll-into-view convenience
}
if (shouldScroll) sectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, []);
useEffect(() => {
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const scheduleNext = (delayMs: number) => {
if (cancelled) return;
timeoutId = setTimeout(tick, delayMs);
};
// "Is this conversation still in progress" depends on Date.now(), an
// impure read — it must not happen directly during render (useMemo) or as
// a synchronous setState call at the top of this effect. Deferring the
// first evaluation into a callback (same shape as the ticks that follow
// it) mirrors the pattern RequestTimeline.tsx already uses for its own
// Date.now()-based nowMs state (set inside a requestAnimationFrame
// callback, never synchronously in the effect body).
// Self-rescheduling (setTimeout, not setInterval) so the delay can shrink
// to CONVERSATION_ACTIVE_POLL_INTERVAL_MS while the currently-viewed
// request is itself streaming, and fall back to the slower interval once
// it's just waiting for a new row to appear.
const tick = () => {
if (cancelled) return;
if (!isLatest || !lastSeenAt) {
setPolling(false);
return;
}
const reuseWindowMs = getConversationReuseWindowMs();
const withinWindow = Date.now() - new Date(lastSeenAt).getTime() < reuseWindowMs;
setPolling(withinWindow);
if (!withinWindow) return;
if (document.visibilityState !== "visible") {
scheduleNext(CONVERSATION_POLL_INTERVAL_MS);
return;
}
fetch(`/api/logs/${currentLogId}`, { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data) return;
if (Array.isArray(data.conversationTurns)) setLiveTurns(data.conversationTurns);
if (data.conversationNextId && autoFollow) {
onNavigateToLog(data.conversationNextId);
return; // this section is about to unmount (key={log.id} remount)
}
scheduleNext(
data.active ? CONVERSATION_ACTIVE_POLL_INTERVAL_MS : CONVERSATION_POLL_INTERVAL_MS
);
})
.catch(() => {
scheduleNext(CONVERSATION_POLL_INTERVAL_MS);
});
};
timeoutId = setTimeout(tick, 0);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}, [isLatest, lastSeenAt, currentLogId, onNavigateToLog, autoFollow]);
return (
<div ref={sectionRef}>
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-3">
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">
Full Conversation
</h3>
<button
onClick={() => setOpen((v) => !v)}
className="p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label={open ? "Collapse Full Conversation" : "Expand Full Conversation"}
>
<span className="material-symbols-outlined text-[16px]">
{open ? "expand_less" : "expand_more"}
</span>
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleAutoFollow}
title={autoFollow ? "Auto-follow: on (jumps to the next turn as soon as it lands)" : "Auto-follow: off"}
className={`p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors ${autoFollow ? "text-primary" : ""}`}
aria-pressed={autoFollow}
aria-label="Toggle auto-follow to next turn"
>
<span className="material-symbols-outlined text-[18px]">vertical_align_bottom</span>
</button>
{/* Same setting Timeline's "Lane reuse" control persists under (shared key) —
changing it here also changes when Timeline treats a lane as reusable. */}
<label
className="flex items-center gap-1 px-2 py-1 text-[10px] text-text-muted bg-bg-subtle rounded-md border border-border"
title="How long after the last turn this conversation is still considered 'in progress' and auto-refreshed."
>
<span>Auto-refresh</span>
<input
type="number"
min={1}
step={1}
value={reuseMinutes}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const next = Math.max(1, Number(e.target.value) || 1);
setReuseMinutes(next);
try {
localStorage.setItem(CONVERSATION_LANE_REUSE_STORAGE_KEY, String(next));
} catch {
// localStorage unavailable — the input still works for this session
}
}}
className="w-8 bg-transparent text-center font-mono focus:outline-none"
/>
<span>min</span>
</label>
</div>
</div>
{open && (
<div
ref={turnsBoxRef}
className="rounded-xl bg-black/5 dark:bg-black/30 border border-border max-h-150 overflow-y-auto p-3 space-y-2"
>
{earlierTurnsOmitted && (
<div className="text-xs text-text-muted italic mb-2">
Earlier turns not shown for this long-running conversation.
</div>
)}
{liveTurns.map((turn, i) => {
const isCurrent = turn.sourceCallLogId === currentLogId;
return (
<ChatBubble
key={i}
turn={turn}
isCurrent={isCurrent}
onClick={
turn.sourceCallLogId && !isCurrent
? () => onNavigateToLog(turn.sourceCallLogId)
: undefined
}
/>
);
})}
{nextId && (
<button
onClick={() => {
try {
sessionStorage.setItem(CONVERSATION_SCROLL_FLAG_KEY, "1");
} catch {
// sessionStorage unavailable — navigation still works, just without
// the scroll-into-view convenience
}
onNavigateToLog(nextId);
}}
className="w-full text-center text-xs text-primary hover:underline py-2"
>
View next message
</button>
)}
{polling && (
<div className="flex items-center justify-center gap-1.5 text-[10px] text-text-muted py-1">
{/* Same ring-spinner markup as the "in progress" status badge above
(not the shared <Spinner> icon glyph, which spins visibly off-axis). */}
<span className="inline-block h-3 w-3 rounded-full border-2 border-current border-t-transparent animate-spin" />
<span>watching for new turns</span>
</div>
)}
</div>
)}
</div>
);
}
// ─── Stream section + Detail Modal ───────────────────────────────────────────────────────────
function StreamSection({ title, json, onCopy }) {
@@ -195,6 +477,7 @@ export default function RequestLoggerDetail({
onNext,
relatedLogs = [],
onSelectRelated,
onNavigateToLog,
}) {
// Close on Escape key
useEffect(() => {
@@ -348,7 +631,7 @@ export default function RequestLoggerDetail({
const codexAccountRotation = getCodexAccountRotation(detail);
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[5vh]"
className="fixed inset-0 z-50 flex items-start justify-center px-2 pt-[5vh] sm:px-4"
onClick={onClose}
role="dialog"
aria-modal="true"
@@ -356,12 +639,12 @@ export default function RequestLoggerDetail({
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-bg-primary border border-border rounded-xl w-full max-w-225 max-h-[90vh] overflow-y-auto shadow-2xl"
className="relative w-full max-w-225 max-h-[90vh] overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-bg-primary shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl">
<div className="flex items-center gap-3">
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-3 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl sm:px-6 sm:py-4">
<div className="flex flex-wrap items-center gap-2 min-w-0 sm:gap-3">
<div className="flex flex-col">
<div className="flex items-center gap-2">
{log.active ? (
@@ -408,7 +691,7 @@ export default function RequestLoggerDetail({
</span>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onPrevious}
disabled={!onPrevious}
@@ -435,7 +718,7 @@ export default function RequestLoggerDetail({
</div>
</div>
<div className="p-6 flex flex-col gap-6">
<div className="p-4 flex flex-col gap-6 sm:p-6">
{/* Metadata Grid */}
{log.active ? (
<div className="flex flex-wrap gap-4 p-4 bg-bg-subtle rounded-xl border border-border">
@@ -840,6 +1123,19 @@ export default function RequestLoggerDetail({
</div>
) : (
<>
{Array.isArray(detail?.conversationTurns) && detail.conversationTurns.length > 0 && (
<ConversationTranscriptSection
key={log.id}
turns={detail.conversationTurns}
nextId={detail.conversationNextId ?? null}
isLatest={detail.conversationIsLatest ?? false}
lastSeenAt={detail.conversationLastSeenAt ?? null}
earlierTurnsOmitted={detail.conversationEarlierTurnsOmitted ?? false}
currentLogId={log.id}
onNavigateToLog={onNavigateToLog}
/>
)}
{streamChunks && streamChunks.provider && (
<StreamSection
title="Provider Event Stream"

View File

@@ -124,6 +124,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
{ key: "tps", label: t("columns.tps") },
{ key: "duration", label: t("columns.duration") },
{ key: "time", label: t("columns.time") },
{ key: "conversation", label: t("columns.conversation") },
],
[t]
);
@@ -1240,6 +1241,9 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
{getSortIndicator("time")}
</th>
)}
{visibleColumns.conversation && (
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.conversation")}</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border/30">
@@ -1563,6 +1567,15 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
{formatTime(log.timestamp)}
</td>
)}
{visibleColumns.conversation && (
<td className="px-3 py-2 font-mono text-[10px] text-text-muted">
{log.sessionTag ? (
<span title={log.sessionTag}>{log.sessionTag.slice(0, 12)}</span>
) : (
<span className="text-text-muted"></span>
)}
</td>
)}
</tr>
);
})}
@@ -1616,6 +1629,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
closeDetail();
openDetail(r);
}}
onNavigateToLog={(id) => openDetail({ id })}
/>
)}
</div>

View File

@@ -5,8 +5,9 @@ import { useRouter } from "next/navigation";
import { getHttpStatusStyle } from "@/shared/constants/colors";
import { copyToClipboard } from "@/shared/utils/clipboard";
import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
interface TimelineLog {
export interface TimelineLog {
id: string;
timestamp: string;
status: number;
@@ -19,6 +20,8 @@ interface TimelineLog {
completed?: boolean;
error?: string | null;
path?: string | null;
/** Conversation id (X-ConversationId) — same field as call_logs.session_tag. */
sessionTag?: string | null;
}
interface Lane {
@@ -35,7 +38,8 @@ const LANE_HEIGHT = BAR_HEIGHT + LANE_GAP;
const HEADER_HEIGHT = 48;
const AXIS_HEIGHT = 32;
const MIN_BAR_WIDTH = 3;
const POLL_INTERVAL_MS = 2000;
const DEFAULT_LIST_POLL_SECONDS = 2;
const TIMELINE_LIST_POLL_STORAGE_KEY = "timelineListPollSeconds";
const FOLLOW_LINE_X = 0.75;
const LIVE_LINE_FRACTION = 0.9;
@@ -74,8 +78,28 @@ function getStatusColor(status: number, active: boolean | undefined): string {
return getHttpStatusStyle(status).bg;
}
function allocateLanes(items: TimelineLog[], nowMs: number): Map<string, number> {
const DEFAULT_CONVERSATION_LANE_REUSE_WINDOW_MS = 2 * 60 * 1000;
// Exported so other components (e.g. the "Full Conversation" transcript panel
// in RequestLoggerDetail.tsx) can decide "is this conversation still in
// progress" using the SAME setting as the timeline's lane-reuse window,
// rather than a separate, potentially-inconsistent one.
export const CONVERSATION_LANE_REUSE_STORAGE_KEY = "timelineConversationLaneReuseMinutes";
/**
* Assigns each item a lane (row) index. Items sharing a `sessionTag`
* (conversation id) are forced onto the same lane as long as the gap since
* that lane's last item is within `reuseWindowMs` — after that, the lane is
* free again and falls back to the normal greedy overlap-avoidance packing
* below (unrelated to any conversation).
*/
export function allocateLanes(
items: TimelineLog[],
nowMs: number,
reuseWindowMs: number = DEFAULT_CONVERSATION_LANE_REUSE_WINDOW_MS
): Map<string, number> {
const lanes: Lane[] = [];
const laneConversation: (string | null)[] = [];
const laneMap = new Map<string, number>();
const sorted = [...items].sort((a, b) => {
@@ -86,18 +110,35 @@ function allocateLanes(items: TimelineLog[], nowMs: number): Map<string, number>
for (const item of sorted) {
const { startMs, endMs } = computeBarRange(item, nowMs);
const conversationId = item.sessionTag || null;
let placed = false;
for (let i = 0; i < lanes.length; i++) {
if (lanes[i].endMs < startMs) {
lanes[i] = { startMs, endMs };
laneMap.set(item.id, i);
placed = true;
break;
if (conversationId) {
for (let i = 0; i < lanes.length; i++) {
if (laneConversation[i] === conversationId && startMs - lanes[i].endMs <= reuseWindowMs) {
lanes[i] = { startMs, endMs };
laneMap.set(item.id, i);
placed = true;
break;
}
}
}
if (!placed) {
for (let i = 0; i < lanes.length; i++) {
if (lanes[i].endMs < startMs) {
lanes[i] = { startMs, endMs };
laneConversation[i] = conversationId;
laneMap.set(item.id, i);
placed = true;
break;
}
}
}
if (!placed) {
laneMap.set(item.id, lanes.length);
laneConversation.push(conversationId);
lanes.push({ startMs, endMs });
}
}
@@ -150,9 +191,31 @@ export default function RequestTimeline({
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartOffset, setDragStartOffset] = useState(0);
const { emailsVisible } = useEmailPrivacyStore();
const [selectedLog, setSelectedLog] = useState<TimelineLog | null>(null);
const [detailData, setDetailData] = useState<any>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false);
const [conversationLaneReuseMinutes, setConversationLaneReuseMinutes] = useState(() => {
if (globalThis.window === undefined) return 2;
try {
const saved = localStorage.getItem(CONVERSATION_LANE_REUSE_STORAGE_KEY);
const parsed = saved ? Number(saved) : 2;
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
} catch {
return 2;
}
});
const [listPollSeconds, setListPollSeconds] = useState(() => {
if (globalThis.window === undefined) return DEFAULT_LIST_POLL_SECONDS;
try {
const saved = localStorage.getItem(TIMELINE_LIST_POLL_STORAGE_KEY);
const parsed = saved ? Number(saved) : DEFAULT_LIST_POLL_SECONDS;
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIST_POLL_SECONDS;
} catch {
return DEFAULT_LIST_POLL_SECONDS;
}
});
const canvasRef = useRef<HTMLDivElement>(null);
const animRef = useRef<number>(0);
// Guards the ?id= deep-link mount effect below. Also armed by any manual
@@ -162,6 +225,16 @@ export default function RequestTimeline({
// reopen the modal right after the user closed it.
const initialOpenedRef = useRef(false);
useEffect(() => {
fetch("/api/logs/detail?limit=1")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!data) return;
setDetailLoggingEnabled(data.enabled === true);
})
.catch(() => {});
}, []);
useEffect(() => {
let cancelled = false;
fetch("/api/usage/call-logs?limit=200")
@@ -179,12 +252,12 @@ export default function RequestTimeline({
.then((res) => (res.ok ? res.json() : []))
.then((data) => setLogs(data))
.catch(() => {});
}, POLL_INTERVAL_MS);
}, listPollSeconds * 1000);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
}, [listPollSeconds]);
useEffect(() => {
if (!canvasRef.current) return undefined;
@@ -250,7 +323,10 @@ export default function RequestTimeline({
});
}, [logs, timeStart, timeEnd, nowMs]);
const laneMap = useMemo(() => allocateLanes(logs, nowMs), [logs, nowMs]);
const laneMap = useMemo(
() => allocateLanes(logs, nowMs, conversationLaneReuseMinutes * 60 * 1000),
[logs, nowMs, conversationLaneReuseMinutes]
);
const maxLane = useMemo(() => (laneMap.size > 0 ? Math.max(...laneMap.values()) : 0), [laneMap]);
const barElements = useMemo(() => {
@@ -271,6 +347,39 @@ export default function RequestTimeline({
});
}, [visibleLogs, timeStart, timeEnd, nowMs, laneMap, canvasWidth]);
// One connector per consecutive pair of bars sharing a conversation id AND
// lane (i.e. allocateLanes actually treated them as one continuous
// conversation, not two bars that just happen to be adjacent).
const connectorElements = useMemo(() => {
const byConversation = new Map<string, typeof barElements>();
for (const el of barElements) {
const cid = el.log.sessionTag;
if (!cid) continue;
const list = byConversation.get(cid);
if (list) list.push(el);
else byConversation.set(cid, [el]);
}
const connectors: { id: string; x1: number; x2: number; y: number }[] = [];
for (const els of byConversation.values()) {
const sorted = [...els].sort(
(a, b) => new Date(a.log.timestamp).getTime() - new Date(b.log.timestamp).getTime()
);
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i];
const b = sorted[i + 1];
if (a.topPx !== b.topPx) continue; // different lanes — reuse window lapsed
connectors.push({
id: `${a.log.id}-${b.log.id}`,
x1: a.leftPct + a.widthPct,
x2: b.leftPct,
y: a.topPx + BAR_HEIGHT / 2,
});
}
}
return connectors;
}, [barElements]);
const axisTicks = useMemo(() => {
const totalMs = timeEnd - timeStart;
if (totalMs <= 0) return [];
@@ -373,6 +482,13 @@ 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" });
@@ -398,7 +514,7 @@ export default function RequestTimeline({
} finally {
setDetailLoading(false);
}
}, []);
}, [router]);
useEffect(() => {
if (!initialSelectedId || initialOpenedRef.current) return;
@@ -574,6 +690,51 @@ export default function RequestTimeline({
>
Reset
</button>
{/* Conversation lane-reuse window: how long a lane stays reserved
for its conversation before falling back to normal packing. */}
<label
className="flex items-center gap-1 px-2 py-1 text-[11px] text-text-muted bg-bg-subtle rounded-md border border-border"
title="Requests sharing a conversation id stay on the same timeline row as long as the gap between them is under this many minutes."
>
<span>Lane reuse</span>
<input
type="number"
min={1}
step={1}
value={conversationLaneReuseMinutes}
onChange={(e) => {
const next = Math.max(1, Number(e.target.value) || 1);
setConversationLaneReuseMinutes(next);
try {
localStorage.setItem(CONVERSATION_LANE_REUSE_STORAGE_KEY, String(next));
} catch {}
}}
className="w-10 bg-transparent text-center font-mono focus:outline-none"
/>
<span>min</span>
</label>
{/* How often the timeline re-polls /api/usage/call-logs for new rows. */}
<label
className="flex items-center gap-1 px-2 py-1 text-[11px] text-text-muted bg-bg-subtle rounded-md border border-border"
title="How often the timeline re-fetches the request list from the server."
>
<span>Auto-refresh</span>
<input
type="number"
min={1}
step={1}
value={listPollSeconds}
onChange={(e) => {
const next = Math.max(1, Number(e.target.value) || 1);
setListPollSeconds(next);
try {
localStorage.setItem(TIMELINE_LIST_POLL_STORAGE_KEY, String(next));
} catch {}
}}
className="w-10 bg-transparent text-center font-mono focus:outline-none"
/>
<span>s</span>
</label>
{/* Zoom */}
<div className="flex items-center gap-1 rounded-lg border border-border overflow-hidden">
<button
@@ -666,6 +827,42 @@ export default function RequestTimeline({
</div>
</div>
))}
{/* Conversation connectors — one arrow per consecutive same-conversation
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 }}
viewBox={`0 0 100 ${(maxLane + 1) * LANE_HEIGHT}`}
preserveAspectRatio="none"
>
<defs>
<marker
id="conversation-connector-arrow"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M0,0 L10,5 L0,10 z" fill="currentColor" />
</marker>
</defs>
{connectorElements.map(({ id, x1, x2, y }) => (
<line
key={id}
x1={x1}
y1={y}
x2={x2}
y2={y}
stroke="currentColor"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
markerEnd="url(#conversation-connector-arrow)"
/>
))}
</svg>
</div>
{/* NOW line — full height of the canvas, outside content div */}
@@ -823,14 +1020,15 @@ export default function RequestTimeline({
log={selectedLog as any}
detail={detailData}
loading={detailLoading}
debugEnabled={false}
emailsVisible={false}
debugEnabled={selectedLog?.active ? true : detailLoggingEnabled}
emailsVisible={emailsVisible}
onClose={closeDetail}
onCopy={copyToClipboard}
onPrevious={undefined}
onNext={undefined}
relatedLogs={[]}
onSelectRelated={undefined}
onNavigateToLog={openById}
/>
)}
</div>

View File

@@ -401,6 +401,13 @@ const LOGS_GROUP: SidebarItemGroup = {
subtitleKey: "logsTimelineSubtitle",
icon: "view_timeline",
},
{
id: "conversations",
href: "/dashboard/conversations",
i18nKey: "conversations",
subtitleKey: "conversationsSubtitle",
icon: "forum",
},
],
};

View File

@@ -54,6 +54,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"logs-proxy",
"logs-console",
"logs-timeline",
"conversations",
"logs-activity",
"health",
"runtime",