feat(dashboard): agentic conversation tracking — v4, decoupled + storage-architecture concern resolved (#10263)

* feat(responses): virtualize previous_response_id continuation regardless of upstream support

OmniRoute now exposes OpenAI-compatible previous_response_id/store
continuation to clients unconditionally, even when the selected upstream
provider has no native Responses-API state support. Reconstruction happens
server-side in handleChatImplementation, before any downstream validation
or provider translation: OmniRoute resolves the response id back to the
full input/output it previously produced, prepends it to the client's
delta, and forwards the full reconstructed history upstream exactly as it
does today. Client<->OmniRoute traffic shrinks to the new delta only;
OmniRoute<->provider traffic is unchanged.

Storage reuses the existing call-log pipeline artifact (already gated by
call_log_pipeline_enabled, already retained/cleaned up by the existing
call-log lifecycle) instead of duplicating conversation content into a
second store -- only a lightweight call_logs.response_id index is new.
Every lookup is scoped by api_key_id so one client can never resolve
another client's stored conversation, and any unresolvable/missing/
size-limit-omitted state fails closed with OpenAI's own
previous_response_not_found contract.

Stacked on feat/openai-responses-store-toggle (#10121).

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

Every agentic chat request now gets a conversation id (X-ConversationId
response header). 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 that happen to share identical opening content.
Continuation detection excludes the system message from the identity
anchor, since real coding-agent CLIs commonly regenerate it every request
with live context (timestamp, cwd, git status) — without this, that
volatility alone broke every continuation check against real traffic.

- `/dashboard/logs`: new toggleable Conversation column.
- `/dashboard/logs/timeline`: requests sharing a conversation id 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 — Markdown rendering, per-turn timestamps, turn-relative
  view, click-any-turn navigation, live auto-refresh building the
  transcript in real time from the in-flight SSE chunk buffer while a
  request is still streaming, auto-scroll-to-bottom as the live turn grows.
- New `/dashboard/conversations` page listing conversations with 2+ turns,
  no-forking model (an edited/duplicated mid-history turn mints its own
  independent conversation instead of merging), pagination, duplicate-
  anchor fix.
- Configurable auto-refresh intervals on both the timeline and
  conversations list pages.
- Responses API tool-call gap fix: turnsFromOpenAiMessages only handled
  role-based Chat Completions messages, so bare {type:"function_call"} /
  {type:"function_call_output"} / {type:"reasoning"} items (real Responses
  API traffic) silently vanished from the Conversation Context panel.
- truncateForLog now counts input[] (Responses API), not just messages[]
  (Chat Completions), so a truncated /v1/responses request still shows a
  placeholder instead of nothing.
- RequestTimeline.tsx now reads the same debugEnabled/emailsVisible
  settings RequestLoggerV2.tsx already used, instead of hardcoding both
  false — the timeline view never showed SSE/stream-chunk events or
  respected email-masking, regardless of the actual setting.

Migrations 147/148 (agentic_conversations, conversation_turn_nodes) — 135
and 136 are now taken upstream; 143-145 are documented KNOWN_GAPS, so this
uses the next free slot past upstream's current highest.

Test plan:
- npm run typecheck:core — clean
- npm run lint — clean
- node --import tsx/esm scripts/check/check-migration-numbering.mjs — OK, 0 collisions
- 109 unit tests across the conversation-tracking, migration-renumber, and
  dashboard-wiring surface — 0 failures

* refactor(dashboard): reuse call-log artifacts for conversation transcript content

conversation_turn_nodes no longer stores turn text/tool-call content
(text_preview/block_kind/tool_name) -- it's identity-only now (id/parent/
content_hash), matching agentic_conversations' existing lightweight-index
shape. Every node's originating request is already fully captured by the
call-log pipeline artifact its last_correlation_id points at, so the
/dashboard/conversations tree view resolves each node's actual display
content on demand from there (open-sse/services/conversationTurnContent.ts),
re-running the same extractCanonicalTurns/hashTurnContent the write path
used and matching by content_hash, instead of duplicating conversation
content into a second store under a separate retention/gating policy. This
also drops the old 8000-char text_preview truncation entirely -- resolved
content is always full and untruncated.

The frontend contract is unchanged (tree API still returns
{textPreview, blockKind, toolName} per node), so the dashboard UI itself
(page.tsx, RequestLoggerDetail/RequestTimeline, sidebar, i18n) needed no
changes.

Renumbered the cherry-picked 147/148 migrations to 153/154 -- 147 now
collides with 147_api_keys_model_access_mode.sql, which landed on
release/v3.8.50 after this work was originally built.

Also includes a standalone, unrelated fix carried along from this rebase:
close isProviderModelHidden's missing function-body brace in
modelSelectModalHelpers.ts (separately landed as #10206).

Stacked on feat/responses-previous-response-id-virtualization (#3), which
is itself stacked on feat/openai-responses-store-toggle (#10121).

* fix(dashboard): resync conversation list on open so the live-text poll starts immediately

openConversation() seeded activeConversation (and therefore activeCallLogId,
which gates the live-partial-text poll effect) from whatever row snapshot the
list's own fixed-interval poll last produced. A conversation opened right
after a reply started streaming -- after that tick, before the next -- had
activeCallLogId still null, so the live-text poll never started; only a
subsequent background list-poll resync (already existed) picked it up,
which is why closing and reopening the same conversation "just worked".

loadConversations() is now a shared callback so openConversation can force
one immediately on open instead of waiting on pollSeconds.

Live-verified against omniroute-dev: opening a conversation mid-stream now
shows live reasoning on the first open.

* style: prettier formatting for conversationTurnContent.test.ts

* fix(db): close migration numbering gap left by decoupling from #3/#10262

153/154 (originally 154/155) were chosen back when this branch stacked on
top of the previous_response_id migration (153_call_logs_response_id.sql).
Decoupling removed that migration from this branch's history, leaving an
unused 153 slot that check-migration-numbering.test.ts correctly flags as
a gap.

* refactor(dashboard): split RequestTimeline/RequestLoggerDetail under the 1000-line file-size cap

Both files exceeded check-file-size's new-file cap after this PR's own
additions (RequestTimeline 1048, RequestLoggerDetail 1163). Extracted pure
non-component logic (types, constants, allocateLanes and its helpers) out
of RequestTimeline.tsx into RequestTimeline.utils.ts, and the two
self-contained presentational sub-components (PayloadSection,
ConversationContextSection + its private helper) out of
RequestLoggerDetail.tsx into RequestLoggerDetail.sections.tsx. No behavior
change; existing external imports (default exports, allocateLanes,
TimelineLog, CONVERSATION_LANE_REUSE_STORAGE_KEY) still resolve from the
original file paths.

* fix(db): renumber agentic-conversation migrations to clear 153 collision + sync migration-count docs

The refresh-merge of release/v3.8.50 exposed that the feature's three
migrations collided at slot 153 with the base's radar_local_model_state
(153) and its own call_logs_response_id. Migration runner enforces unique
numeric prefixes -> every DB init threw, red-ing Vitest, all Unit shards and
the DB-backed quality gates. Renumber the feature's pair to
155_agentic_conversations / 156_conversation_turn_nodes and move
call_logs_response_id to 154 (keeps 153_radar base-owned, preserves
agentic-before-turn_nodes ordering). Update SQL headers and the
154/156 references in feature code + tests.

Migration count is now 151 (was 148 stale in README/AGENTS/llm.txt) — sync
the doc counts to clear the docs-accuracy gate.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* fix(ui): drop unused CONVERSATION_LANE_REUSE_STORAGE_KEY re-export from RequestTimeline

Knip 6.32 (baseline 415) flags the public re-export of
CONVERSATION_LANE_REUSE_STORAGE_KEY from RequestTimeline.tsx as dead: no
external consumer imports it through that re-export (it is imported and
used directly from RequestTimeline.utils.ts inside the component). Removed
the unused re-export; the internal import stays. DEAD_TOTAL 416 -> 415,
back to the frozen baseline.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* fix(agentic-conversations): guard resolveConversationId, drop dead whole-chain export

- Wrap resolveConversationId() in try/catch in chat.ts, matching the
  defensive pattern used by every other best-effort side call nearby, so a
  DB hiccup in conversation tracking can't turn a working chat request into
  a hard failure.
- Remove getConversationTurnTree: knip's project scope excludes tests/**,
  so an export used only by tests can never register as used there. Swap
  its 8 test call sites to the paginated getConversationTurnPage (already
  the dashboard's canonical query) with a generous limit, collapsing to one
  query path instead of keeping a second whole-chain export alive solely
  for test convenience.
- Regenerate i18n llm.txt mirrors from root (pre-existing drift on this
  branch, unrelated to the above, caught by the docs-sync pre-commit gate).

Addresses PR review feedback.

* fix(i18n): close requestLogger conversation-column gap, fix domain-modules count drift

- fr.json, vi.json were missing requestLogger.columns.conversation (added
  in the conversation-tracking feature), failing i18n-vi-completeness.test.ts.
- docs/i18n/*/llm.txt mirrors still said 117 domain-specific files after an
  earlier rebase fixed the migration count but missed this companion number,
  failing check-docs-sync.mjs across all 42 locales.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(docs): restore PROXY_LOG_INCLUDE_IPS env/doc entries (env-doc-sync red)

.env.example and docs/reference/ENVIRONMENT.md were both missing the
PROXY_LOG_INCLUDE_IPS entry that src/lib/proxyLogger.ts already reads
(confirmed present at this branch's merge-base too, so this predates
the conversation-tracking work and is unrelated to it) -- the entry
was added on release/v3.8.50 after this branch's last sync and this
branch never picked it up. That gap red-lines
tests/unit/check-env-doc-sync.test.ts and
tests/unit/issue-7793-env-doc-sync-repro.test.ts (Unit Tests
fast-path 2/4 in CI). Restore both entries verbatim from the current
release/v3.8.50 tip -- no feature-code change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: hartmark <hartmark@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Markus Hartung
2026-08-18 16:32:33 +02:00
committed by GitHub
parent d93b24e761
commit beb6ec857b
130 changed files with 4941 additions and 481 deletions

View File

@@ -0,0 +1,242 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { ChatBubble } from "@/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble";
import { buildRequestTurns, buildResponseTurns } from "@/mitm/inspector/conversationNormalizer";
import type { InterceptedRequest, NormalizedTurn } from "@/mitm/inspector/types";
// ─── Payload Code Block ─────────────────────────────────────────────────────
export function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen = true }) {
const t = useTranslations("requestLogger.detail");
const [copied, setCopied] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const handleCopy = async () => {
const success = await onCopy();
if (success !== false) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">
{title}
</h3>
{collapsible && (
<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 ? t("collapse", { title }) : t("expand", { title })}
>
<span className="material-symbols-outlined text-[16px]">
{open ? "expand_less" : "expand_more"}
</span>
</button>
)}
</div>
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
aria-label={t("copyTitle", { title })}
>
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? t("copied") : t("copy")}
</button>
</div>
{open && (
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
{json}
</pre>
)}
</div>
);
}
// ─── Conversation context section ───────────────────────────────────────────
// Renders THIS request's own context (its request body's messages/input, plus
// its response) — a plain single-request normalization, same shape as the
// traffic-inspector's ConversationTab, no cross-request reconstruction. While
// the request is still generating (detail.active === true) the response side
// shows the partial text captured so far, refreshed on a short poll scoped to
// just this section.
const CONVERSATION_ACTIVE_POLL_INTERVAL_MS = 1200;
function asInterceptedResponseBody(responseBody: unknown): InterceptedRequest {
return {
id: "",
source: "custom-host",
timestamp: "",
method: "POST",
host: "",
path: "",
requestHeaders: {},
requestBody: null,
requestSize: 0,
responseHeaders: {},
responseBody: responseBody != null ? JSON.stringify(responseBody) : null,
responseSize: 0,
status: 0,
detectedKind: "llm",
};
}
export function ConversationContextSection({ log, detail }) {
const [open, setOpen] = useState(true);
const [liveDetail, setLiveDetail] = useState(detail);
const [liveRefresh, setLiveRefresh] = useState(() => {
try {
const v = localStorage.getItem("pref:conversationContext:liveRefresh");
return v == null ? true : v === "1";
} catch {
return true;
}
});
const turnsBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLiveDetail(detail);
}, [detail]);
// Same live-poll pattern as the SSE Events section (StreamSection below),
// but gated on liveRefresh too: an active request keeps generating either
// way, this toggle only controls whether THIS panel keeps fetching/
// redrawing while the user reads it.
useEffect(() => {
if (!liveDetail?.active || !liveRefresh) return;
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const tick = () => {
if (cancelled) return;
if (document.visibilityState !== "visible") {
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
return;
}
fetch(`/api/logs/${log.id}`, { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data) return;
setLiveDetail(data);
if (data.active) timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
})
.catch(() => {
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
});
};
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}, [liveDetail?.active, liveRefresh, log.id]);
const toggleLiveRefresh = () => {
const next = !liveRefresh;
setLiveRefresh(next);
try {
localStorage.setItem("pref:conversationContext:liveRefresh", next ? "1" : "0");
} catch {}
};
const scrollToBottom = () => {
const el = turnsBoxRef.current;
if (!el) return;
requestAnimationFrame(() => {
try {
el.scrollTop = el.scrollHeight;
} catch {}
});
};
const requestBody =
liveDetail?.requestBody ?? liveDetail?.pipelinePayloads?.clientRequest ?? null;
const requestTurns = buildRequestTurns(requestBody) ?? [];
const responseBody = liveDetail?.responseBody ?? null;
const responseTurns: NormalizedTurn[] =
responseBody != null
? buildResponseTurns(asInterceptedResponseBody(responseBody))
: liveDetail?.partialAssistantText
? [
{
role: "assistant",
blocks: [{ type: "text", text: liveDetail.partialAssistantText }],
},
]
: [];
const allTurns: NormalizedTurn[] = [...requestTurns, ...responseTurns];
// Follow new content as it streams in — same idea as StreamSection's
// autoscroll effect, tied to the same liveRefresh toggle.
useEffect(() => {
if (!liveRefresh || !open) return;
scrollToBottom();
}, [allTurns.length, liveDetail?.partialAssistantText, liveRefresh, open]);
if (allTurns.length === 0) return null;
return (
<div>
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-3">
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">
Conversation Context
</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 Conversation Context" : "Expand Conversation Context"}
>
<span className="material-symbols-outlined text-[16px]">
{open ? "expand_less" : "expand_more"}
</span>
</button>
</div>
{open && (
<div className="flex items-center gap-1">
{liveDetail?.active && (
<button
onClick={toggleLiveRefresh}
title={liveRefresh ? "Live refresh: on" : "Live refresh: off"}
className={`p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors ${liveRefresh ? "text-primary" : ""}`}
aria-pressed={liveRefresh}
>
<span className="material-symbols-outlined text-[18px]">
{liveRefresh ? "sync" : "sync_disabled"}
</span>
</button>
)}
<button
onClick={scrollToBottom}
title="Go to bottom"
className="p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label="Go to bottom"
>
<span className="material-symbols-outlined text-[18px]">vertical_align_bottom</span>
</button>
</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"
>
{allTurns.map((turn, i) => (
<ChatBubble key={i} turn={turn} />
))}
</div>
)}
</div>
);
}

View File

@@ -9,60 +9,10 @@ import {
} from "@/shared/constants/colors";
import { formatDuration, formatApiKeyLabel, maskAccount } from "@/shared/utils/formatting";
import { formatErrorForDisplay } from "@/shared/utils/formatting";
// ─── Payload Code Block ─────────────────────────────────────────────────────
function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen = true }) {
const t = useTranslations("requestLogger.detail");
const [copied, setCopied] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const handleCopy = async () => {
const success = await onCopy();
if (success !== false) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">
{title}
</h3>
{collapsible && (
<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 ? t("collapse", { title }) : t("expand", { title })}
>
<span className="material-symbols-outlined text-[16px]">
{open ? "expand_less" : "expand_more"}
</span>
</button>
)}
</div>
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
aria-label={t("copyTitle", { title })}
>
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? t("copied") : t("copy")}
</button>
</div>
{open && (
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
{json}
</pre>
)}
</div>
);
}
import {
PayloadSection,
ConversationContextSection,
} from "@/shared/components/RequestLoggerDetail.sections";
// ─── Stream section + Detail Modal ───────────────────────────────────────────────────────────
@@ -354,7 +304,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"
@@ -362,12 +312,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 ? (
@@ -414,23 +364,31 @@ export default function RequestLoggerDetail({
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={onPrevious}
disabled={!onPrevious}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label={t("previousRequest")}
>
<span className="material-symbols-outlined text-[18px]">chevron_left</span>
</button>
<button
onClick={onNext}
disabled={!onNext}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label={t("nextRequest")}
>
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
<div className="flex items-center gap-1 shrink-0">
{/* Only rendered when a caller actually wires up navigation (RequestLoggerV2's
list view) — a caller with no ordered-list context to navigate through
(conversations page, RequestTimeline) passes neither, so there's nothing
to show instead of a permanently-disabled dead button. */}
{(onPrevious || onNext) && (
<>
<button
onClick={onPrevious}
disabled={!onPrevious}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label={t("previousRequest")}
>
<span className="material-symbols-outlined text-[18px]">chevron_left</span>
</button>
<button
onClick={onNext}
disabled={!onNext}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label={t("nextRequest")}
>
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
</>
)}
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
@@ -441,7 +399,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">
@@ -868,6 +826,8 @@ export default function RequestLoggerDetail({
</div>
) : (
<>
<ConversationContextSection key={log.id} log={log} detail={detail} />
{streamChunks && streamChunks.provider && (
<StreamSection
title={t("providerEventStream")}

View File

@@ -125,6 +125,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]
);
@@ -188,6 +189,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 +757,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 +776,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);
@@ -1241,6 +1287,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">
@@ -1588,6 +1637,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>
);
})}

View File

@@ -3,134 +3,35 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { getHttpStatusStyle } from "@/shared/constants/colors";
import { copyToClipboard } from "@/shared/utils/clipboard";
import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import {
type TimelineLog,
type ViewMode,
VISIBLE_WINDOW_MS,
BAR_HEIGHT,
LANE_GAP,
LANE_HEIGHT,
HEADER_HEIGHT,
AXIS_HEIGHT,
MIN_BAR_WIDTH,
DEFAULT_LIST_POLL_SECONDS,
TIMELINE_LIST_POLL_STORAGE_KEY,
FOLLOW_LINE_X,
LIVE_LINE_FRACTION,
computeBarRange,
MODE_META,
formatTimeAxis,
getStatusColor,
CONVERSATION_LANE_REUSE_STORAGE_KEY,
allocateLanes,
truncateModel,
formatDateLabel,
} from "@/shared/components/RequestTimeline.utils";
interface TimelineLog {
id: string;
timestamp: string;
status: number;
model: string | null;
provider: string | null;
account: string | null;
duration: number;
tokens: { in: number; out: number };
active?: boolean;
completed?: boolean;
error?: string | null;
path?: string | null;
}
interface Lane {
startMs: number;
endMs: number;
}
type ViewMode = "follow" | "live" | "pan";
const VISIBLE_WINDOW_MS = 5 * 60 * 1000;
const BAR_HEIGHT = 28;
const LANE_GAP = 4;
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 FOLLOW_LINE_X = 0.75;
const LIVE_LINE_FRACTION = 0.9;
function computeBarRange(log: TimelineLog, nowMs: number): { startMs: number; endMs: number } {
const ts = new Date(log.timestamp).getTime();
if (log.active) return { startMs: ts, endMs: nowMs };
if (log.completed) return { startMs: ts, endMs: ts + (log.duration || 0) };
return { startMs: ts - (log.duration || 0), endMs: ts };
}
const MODE_META: Record<ViewMode, { labelKey: string; descriptionKey: string }> = {
follow: {
labelKey: "follow",
descriptionKey: "followDescription",
},
live: {
labelKey: "now",
descriptionKey: "nowDescription",
},
pan: {
labelKey: "pan",
descriptionKey: "panDescription",
},
};
function formatTimeAxis(ms: number): string {
const d = new Date(ms);
const h = d.getHours().toString().padStart(2, "0");
const m = d.getMinutes().toString().padStart(2, "0");
const s = d.getSeconds().toString().padStart(2, "0");
return `${h}:${m}:${s}`;
}
function getStatusColor(status: number, active: boolean | undefined): string {
if (active) return "#6366F1";
return getHttpStatusStyle(status).bg;
}
function allocateLanes(items: TimelineLog[], nowMs: number): Map<string, number> {
const lanes: Lane[] = [];
const laneMap = new Map<string, number>();
const sorted = [...items].sort((a, b) => {
const aStart = new Date(a.timestamp).getTime();
const bStart = new Date(b.timestamp).getTime();
return aStart - bStart;
});
for (const item of sorted) {
const { startMs, endMs } = computeBarRange(item, nowMs);
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 (!placed) {
laneMap.set(item.id, lanes.length);
lanes.push({ startMs, endMs });
}
}
return laneMap;
}
function truncateModel(model: string | null): string {
if (!model) return "";
const parts = model.split("/");
const short = parts[parts.length - 1];
return short.length > 16 ? short.slice(0, 15) + "\u2026" : short;
}
function formatDateLabel(ms: number): string {
const d = new Date(ms);
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
return `${months[d.getMonth()]} ${d.getDate()}`;
}
export type { TimelineLog } from "@/shared/components/RequestTimeline.utils";
export { allocateLanes } from "@/shared/components/RequestTimeline.utils";
export default function RequestTimeline({
initialSelectedId,
@@ -152,9 +53,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
@@ -164,6 +87,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")
@@ -181,12 +114,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;
@@ -252,7 +185,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(() => {
@@ -273,6 +209,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 [];
@@ -374,33 +343,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) => {
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);
}
}, []);
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;
@@ -576,6 +555,51 @@ export default function RequestTimeline({
>
{t("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
@@ -668,6 +692,47 @@ 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 */}
@@ -828,8 +893,8 @@ 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}

View File

@@ -0,0 +1,169 @@
import { getHttpStatusStyle } from "@/shared/constants/colors";
export interface TimelineLog {
id: string;
timestamp: string;
status: number;
model: string | null;
provider: string | null;
account: string | null;
duration: number;
tokens: { in: number; out: number };
active?: boolean;
completed?: boolean;
error?: string | null;
path?: string | null;
/** Conversation id (X-ConversationId) — same field as call_logs.session_tag. */
sessionTag?: string | null;
}
export interface Lane {
startMs: number;
endMs: number;
}
export type ViewMode = "follow" | "live" | "pan";
export const VISIBLE_WINDOW_MS = 5 * 60 * 1000;
export const BAR_HEIGHT = 28;
export const LANE_GAP = 4;
export const LANE_HEIGHT = BAR_HEIGHT + LANE_GAP;
export const HEADER_HEIGHT = 48;
export const AXIS_HEIGHT = 32;
export const MIN_BAR_WIDTH = 3;
export const DEFAULT_LIST_POLL_SECONDS = 2;
export const TIMELINE_LIST_POLL_STORAGE_KEY = "timelineListPollSeconds";
export const FOLLOW_LINE_X = 0.75;
export const LIVE_LINE_FRACTION = 0.9;
export function computeBarRange(
log: TimelineLog,
nowMs: number
): { startMs: number; endMs: number } {
const ts = new Date(log.timestamp).getTime();
if (log.active) return { startMs: ts, endMs: nowMs };
if (log.completed) return { startMs: ts, endMs: ts + (log.duration || 0) };
return { startMs: ts - (log.duration || 0), endMs: ts };
}
export const MODE_META: Record<ViewMode, { labelKey: string; descriptionKey: string }> = {
follow: {
labelKey: "follow",
descriptionKey: "followDescription",
},
live: {
labelKey: "now",
descriptionKey: "nowDescription",
},
pan: {
labelKey: "pan",
descriptionKey: "panDescription",
},
};
export function formatTimeAxis(ms: number): string {
const d = new Date(ms);
const h = d.getHours().toString().padStart(2, "0");
const m = d.getMinutes().toString().padStart(2, "0");
const s = d.getSeconds().toString().padStart(2, "0");
return `${h}:${m}:${s}`;
}
export function getStatusColor(status: number, active: boolean | undefined): string {
if (active) return "#6366F1";
return getHttpStatusStyle(status).bg;
}
export 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) => {
const aStart = new Date(a.timestamp).getTime();
const bStart = new Date(b.timestamp).getTime();
return aStart - bStart;
});
for (const item of sorted) {
const { startMs, endMs } = computeBarRange(item, nowMs);
const conversationId = item.sessionTag || null;
let placed = false;
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 });
}
}
return laneMap;
}
export function truncateModel(model: string | null): string {
if (!model) return "";
const parts = model.split("/");
const short = parts[parts.length - 1];
return short.length > 16 ? short.slice(0, 15) + "…" : short;
}
export function formatDateLabel(ms: number): string {
const d = new Date(ms);
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
return `${months[d.getMonth()]} ${d.getDate()}`;
}

View File

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

View File

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