Files
OmniRoute/src/shared/components/RequestTimeline.tsx
Markus Hartung beb6ec857b 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>
2026-08-18 11:32:33 -03:00

909 lines
33 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
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";
export type { TimelineLog } from "@/shared/components/RequestTimeline.utils";
export { allocateLanes } from "@/shared/components/RequestTimeline.utils";
export default function RequestTimeline({
initialSelectedId,
}: {
initialSelectedId?: string | null;
} = {}) {
const router = useRouter();
const t = useTranslations("requestTimeline");
const [logs, setLogs] = useState<TimelineLog[]>([]);
const [mode, setMode] = useState<ViewMode>("follow");
const [zoom, setZoom] = useState(1);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
const [nowMs, setNowMs] = useState(0);
const [canvasWidth, setCanvasWidth] = useState(1200);
const [panOffsetMs, setPanOffsetMs] = useState(0);
const [panFrozenMs, setPanFrozenMs] = useState(0);
const [liveBaseMs, setLiveBaseMs] = useState(0);
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
// open/close so a stale `initialSelectedId` (App Router's router.replace()
// commits the URL after the re-render it triggers, so the prop can briefly
// still reflect the just-closed id on the very next render) can never
// 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")
.then((res) => (res.ok ? res.json() : []))
.then((data) => {
if (!cancelled) setLogs(data);
})
.catch(() => {});
const id = setInterval(() => {
// #8354: skip the poll while the tab is backgrounded (Page Visibility
// API), matching the pause-when-hidden pattern in RequestLoggerV2 and
// UsageStats so a background tab doesn't keep hammering the API.
if (document.visibilityState !== "visible") return;
fetch("/api/usage/call-logs?limit=200")
.then((res) => (res.ok ? res.json() : []))
.then((data) => setLogs(data))
.catch(() => {});
}, listPollSeconds * 1000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [listPollSeconds]);
useEffect(() => {
if (!canvasRef.current) return undefined;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setCanvasWidth(entry.contentRect.width);
}
});
observer.observe(canvasRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
let lastUpdate = 0;
const THROTTLE_MS = mode === "follow" ? 0 : 50;
const onFrame = (frame: number) => {
if (frame - lastUpdate >= THROTTLE_MS) {
lastUpdate = frame;
setNowMs(Date.now());
}
animRef.current = requestAnimationFrame(onFrame);
};
animRef.current = requestAnimationFrame(onFrame);
return () => cancelAnimationFrame(animRef.current);
}, [mode]);
const windowMs = VISIBLE_WINDOW_MS / zoom;
const timeRange = useMemo(() => {
if (mode === "follow") {
const end = nowMs + windowMs * (1 - FOLLOW_LINE_X);
const start = end - windowMs;
return { start, end };
}
if (mode === "live") {
const base = liveBaseMs || nowMs;
const elapsed = nowMs - base;
const snapWindow = windowMs * LIVE_LINE_FRACTION;
const slot = elapsed % snapWindow;
const start = nowMs - slot - windowMs * (1 - LIVE_LINE_FRACTION);
return { start, end: start + windowMs };
}
const base = panFrozenMs || nowMs;
const start = base - windowMs * 0.5 + panOffsetMs;
return { start, end: start + windowMs };
}, [mode, nowMs, windowMs, liveBaseMs, panFrozenMs, panOffsetMs]);
const { start: timeStart, end: timeEnd } = timeRange;
const nowLineX = useMemo(() => {
if (mode === "follow") {
return FOLLOW_LINE_X * 100;
}
const totalMs = timeEnd - timeStart;
if (totalMs <= 0) return 50;
return Math.max(0, Math.min(100, ((nowMs - timeStart) / totalMs) * 100));
}, [mode, nowMs, timeStart, timeEnd]);
const visibleLogs = useMemo(() => {
return logs.filter((log) => {
const { startMs, endMs } = computeBarRange(log, nowMs);
return endMs >= timeStart && startMs <= timeEnd;
});
}, [logs, timeStart, timeEnd, 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(() => {
const totalMs = timeEnd - timeStart;
return visibleLogs.map((log) => {
const { startMs, endMs } = computeBarRange(log, nowMs);
const leftPct = ((startMs - timeStart) / totalMs) * 100;
const rightPct = ((endMs - timeStart) / totalMs) * 100;
const widthPct = Math.max(rightPct - leftPct, (MIN_BAR_WIDTH / canvasWidth) * 100);
const lane = laneMap.get(log.id) ?? 0;
const topPx = lane * LANE_HEIGHT;
const color = getStatusColor(log.status, log.active);
const opacity = log.active ? 0.9 : 0.7;
return { log, leftPct, widthPct, topPx, color, opacity };
});
}, [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 [];
const MS_MIN = 60 * 1000;
const MS_10MIN = 10 * MS_MIN;
const MS_HOUR = 60 * MS_MIN;
const MS_DAY = 24 * MS_HOUR;
const startOfHour = (ms: number) => {
const d = new Date(ms);
d.setMinutes(0, 0, 0);
return d.getTime();
};
const startOfDay = (ms: number) => {
const d = new Date(ms);
d.setHours(0, 0, 0, 0);
return d.getTime();
};
const ticks: { pct: number; label: string; kind: "day" | "hour" | "minor" }[] = [];
const addTick = (ms: number, label: string, kind: "day" | "hour" | "minor") => {
const pct = ((ms - timeStart) / totalMs) * 100;
if (pct >= 0 && pct <= 100) {
ticks.push({ pct, label, kind });
}
};
if (totalMs <= MS_HOUR * 4) {
const interval = totalMs <= MS_10MIN ? MS_MIN : MS_10MIN;
const first = Math.ceil(timeStart / interval) * interval;
for (let ms = first; ms <= timeEnd; ms += interval) {
const isDay = startOfDay(ms) === ms;
const isHour = startOfHour(ms) === ms;
addTick(
ms,
isDay ? formatDateLabel(ms) : formatTimeAxis(ms),
isDay ? "day" : isHour ? "hour" : "minor"
);
}
} else if (totalMs <= MS_DAY) {
const interval = totalMs <= MS_HOUR * 6 ? MS_10MIN : MS_HOUR;
const first = Math.ceil(timeStart / interval) * interval;
for (let ms = first; ms <= timeEnd; ms += interval) {
const isDay = startOfDay(ms) === ms;
addTick(ms, isDay ? formatDateLabel(ms) : formatTimeAxis(ms), isDay ? "day" : "hour");
}
} else {
const firstDay = startOfDay(timeStart);
const start = firstDay < timeStart ? firstDay + MS_DAY : firstDay;
for (let ms = start; ms <= timeEnd; ms += MS_DAY) {
addTick(ms, formatDateLabel(ms), "day");
}
}
return ticks;
}, [timeStart, timeEnd]);
const contentHeight = Math.max((maxLane + 1) * LANE_HEIGHT + 20, 200);
const handleBarClick = useCallback(
(log: TimelineLog) => {
initialOpenedRef.current = true;
setSelectedLog(log);
setDetailData(null);
setDetailLoading(true);
try {
const url = new URL(globalThis.location.href);
url.searchParams.set("id", log.id);
router.replace(url.pathname + url.search);
} catch {
// ignore navigation errors
}
fetch(`/api/logs/${log.id}`, { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data) setDetailData(data);
})
.catch(() => {})
.finally(() => setDetailLoading(false));
},
[router]
);
const closeDetail = useCallback(() => {
initialOpenedRef.current = true;
setSelectedLog(null);
setDetailData(null);
try {
const url = new URL(globalThis.location.href);
url.searchParams.delete("id");
router.replace(url.pathname + url.search);
} catch {
// ignore navigation errors
}
}, [router]);
// 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);
}
} catch {
// ignore fetch errors
} finally {
setDetailLoading(false);
}
},
[router]
);
useEffect(() => {
if (!initialSelectedId || initialOpenedRef.current) return;
initialOpenedRef.current = true;
openById(initialSelectedId)
.then((r) => r)
.catch(() => {});
}, [initialSelectedId, openById]);
const handleBarHover = (log: TimelineLog, e: React.MouseEvent) => {
setHoveredId(log.id);
setTooltipPos({ x: e.clientX, y: e.clientY });
};
const handleBarLeave = () => {
setHoveredId(null);
setTooltipPos(null);
};
const handleReset = useCallback(() => {
setPanOffsetMs(0);
setLiveBaseMs(nowMs);
}, [nowMs]);
const handleModeChange = useCallback(
(newMode: ViewMode) => {
setMode(newMode);
setPanOffsetMs(0);
if (newMode === "pan") setPanFrozenMs(nowMs);
setLiveBaseMs(nowMs);
},
[nowMs]
);
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return;
if (mode !== "pan") {
setMode("pan");
setPanFrozenMs(nowMs);
setLiveBaseMs(nowMs);
}
setIsDragging(true);
setDragStartX(e.clientX);
setDragStartOffset(mode === "pan" ? panOffsetMs : 0);
};
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const msPerPx = windowMs / canvasWidth;
setPanOffsetMs(dragStartOffset - dx * msPerPx);
},
[isDragging, dragStartX, dragStartOffset, windowMs, canvasWidth]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
// --- Touch handlers (mobile panning + pinch-to-zoom) ---
// Track touch start position to distinguish taps from drags.
// A tap (< 10px movement) passes through to onClick for bar selection;
// a drag triggers pan mode.
const touchStartRef = useRef<{
x: number;
y: number;
dist: number;
offset: number;
moved: boolean;
} | null>(null);
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 1) {
const touch = e.touches[0];
touchStartRef.current = {
x: touch.clientX,
y: touch.clientY,
dist: 0,
offset: mode === "pan" ? panOffsetMs : 0,
moved: false,
};
} else if (e.touches.length === 2) {
// Pinch-to-zoom: record initial distance between two fingers
const [a, b] = [e.touches[0], e.touches[1]];
const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
touchStartRef.current = { x: 0, y: 0, dist, offset: zoom, moved: false };
setIsDragging(false);
}
};
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (!touchStartRef.current) return;
if (e.touches.length === 1) {
const touch = e.touches[0];
const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y;
// Only start panning after a 10px threshold to avoid stealing taps
if (!touchStartRef.current.moved && Math.hypot(dx, dy) < 10) return;
if (!touchStartRef.current.moved) {
touchStartRef.current.moved = true;
if (mode !== "pan") {
setMode("pan");
setPanFrozenMs(nowMs);
setLiveBaseMs(nowMs);
}
setIsDragging(true);
}
const msPerPx = windowMs / canvasWidth;
setPanOffsetMs(touchStartRef.current.offset - dx * msPerPx);
} else if (e.touches.length === 2 && touchStartRef.current.dist > 0) {
// Pinch-to-zoom
e.preventDefault();
const [a, b] = [e.touches[0], e.touches[1]];
const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
const scale = dist / touchStartRef.current.dist;
setZoom(Math.max(0.001, Math.min(8, touchStartRef.current.offset * scale)));
}
},
[mode, nowMs, windowMs, canvasWidth]
);
const handleTouchEnd = useCallback(() => {
setIsDragging(false);
touchStartRef.current = null;
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
const factor = e.deltaY < 0 ? 1.25 : 0.8;
setZoom((z) => Math.max(0.001, Math.min(8, z * factor)));
}, []);
const hoveredLog = hoveredId ? logs.find((l) => l.id === hoveredId) : null;
return (
<div className="flex flex-col h-full min-h-0 select-none">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0"
style={{ height: HEADER_HEIGHT }}
>
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold text-text-main">{t("title")}</h2>
<span className="text-[10px] text-text-muted font-mono">
{visibleLogs.length} visible / {logs.length} total
</span>
</div>
<div className="flex items-center gap-2">
{/* Mode selector */}
<div className="flex items-center rounded-lg border border-border overflow-hidden">
{(["follow", "live", "pan"] as ViewMode[]).map((m) => (
<button
key={m}
onClick={() => handleModeChange(m)}
title={t(`modes.${MODE_META[m].descriptionKey}`)}
className={`px-2.5 py-1 text-[11px] font-medium transition-colors ${
mode === m
? "bg-primary text-white"
: "bg-bg-subtle text-text-muted hover:text-text-main"
}`}
>
{t(`modes.${MODE_META[m].labelKey}`)}
</button>
))}
</div>
<button
onClick={handleReset}
title={`Jump to current time, NOW line resets to position ${Math.round(nowLineX)}%`}
className="px-2 py-1 text-[11px] text-text-muted hover:text-text-main bg-bg-subtle rounded-md border border-border transition-colors"
>
{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
onClick={() => setZoom((z) => Math.max(0.001, z * 0.5))}
className="px-2 py-1 text-[11px] text-text-muted hover:text-text-main bg-bg-subtle transition-colors"
>
-
</button>
<span className="px-1 text-[10px] text-text-muted font-mono min-w-[36px] text-center">
{zoom >= 1 ? `${zoom}x` : `${zoom * 100}%`}
</span>
<button
onClick={() => setZoom((z) => Math.min(8, z * 2))}
className="px-2 py-1 text-[11px] text-text-muted hover:text-text-main bg-bg-subtle transition-colors"
>
+
</button>
</div>
</div>
</div>
{/* Canvas */}
<div
ref={canvasRef}
className={`flex-1 min-h-0 overflow-hidden relative bg-surface ${
isDragging ? "cursor-grabbing" : mode === "pan" ? "cursor-grab" : ""
}`}
style={{ touchAction: "none" }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
{/* Scrollable content area */}
<div className="absolute left-0 right-0" style={{ height: contentHeight }}>
{/* Time axis */}
<div
className="absolute left-0 right-0 border-b border-border/50 bg-surface/90 backdrop-blur-sm z-5"
style={{ top: 0, height: AXIS_HEIGHT }}
>
{axisTicks.map((axisTick, i) => (
<div key={i} className="absolute" style={{ left: `${axisTick.pct}%`, top: 0 }}>
<div className="w-px h-3 bg-border -translate-x-1/2" />
<span className="block text-[9px] text-text-muted font-mono mt-0.5 whitespace-nowrap -translate-x-1/2 text-center">
{axisTick.label}
</span>
</div>
))}
</div>
{/* Horizontal grid lines */}
{Array.from({ length: maxLane + 1 }).map((_, i) => (
<div
key={`grid-${i}`}
className="absolute left-0 right-0 border-b border-border/20"
style={{ top: AXIS_HEIGHT + i * LANE_HEIGHT + BAR_HEIGHT }}
/>
))}
{/* Request bars */}
{barElements.map(({ log, leftPct, widthPct, topPx, color, opacity }) => (
<div
key={log.id}
data-testid={`timeline-bar-${log.id}`}
className="absolute rounded-sm cursor-pointer transition-opacity duration-100"
style={{
left: `${leftPct}%`,
width: `${widthPct}%`,
top: AXIS_HEIGHT + topPx,
height: BAR_HEIGHT,
backgroundColor: color,
opacity,
minWidth: MIN_BAR_WIDTH,
zIndex: 2,
}}
onClick={() => handleBarClick(log)}
onMouseEnter={(e) => handleBarHover(log, e)}
onMouseMove={(e) => setTooltipPos({ x: e.clientX, y: e.clientY })}
onMouseLeave={handleBarLeave}
>
<div className="w-full h-full flex items-center px-1.5 overflow-hidden">
<span className="text-[11px] font-mono text-white/90 truncate whitespace-nowrap">
{truncateModel(log.model)}
</span>
</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 */}
<div
className="absolute z-10 pointer-events-none"
style={{
left: `${nowLineX}%`,
top: 0,
bottom: 0,
}}
>
<div className="w-px h-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)] -translate-x-1/2" />
<div className="absolute top-[2px] -translate-x-1/2 bg-red-500 text-white text-[8px] font-mono px-1.5 py-0.5 rounded-b-sm font-bold tracking-wider">
NOW
</div>
</div>
{/* Vertical lines — full height of the canvas, same position as axis ticks */}
{axisTicks.map((axisTick) => (
<div
key={`vl-${axisTick.pct}`}
className="absolute top-0 bottom-0 pointer-events-none -translate-x-1/2"
style={{
left: `${axisTick.pct}%`,
width: axisTick.kind === "day" ? 2 : 1,
backgroundColor:
axisTick.kind === "day"
? "rgba(99,102,241,0.5)"
: axisTick.kind === "hour"
? "rgba(148,163,184,0.45)"
: "rgba(148,163,184,0.15)",
zIndex: 1,
}}
/>
))}
</div>
{/* Tooltip */}
{hoveredLog && tooltipPos && (
<div
className="fixed z-50 pointer-events-none bg-surface border border-border rounded-lg shadow-elevated p-3 max-w-[280px]"
style={{
left: tooltipPos.x + 12,
top: tooltipPos.y - 8,
}}
>
<div className="flex items-center gap-2 mb-1.5">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: getStatusColor(hoveredLog.status, hoveredLog.active) }}
/>
<span className="text-[11px] font-semibold text-text-main">
{hoveredLog.model || t("unknownModel")}
</span>
{hoveredLog.active && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-indigo-500/20 text-indigo-400 font-medium">
{t("active")}
</span>
)}
</div>
<div className="space-y-0.5 text-[10px] text-text-muted font-mono">
<div className="flex justify-between gap-4">
<span>{t("started")}</span>
<span>
{(() => {
const ts = new Date(hoveredLog.timestamp).getTime();
const startMs =
hoveredLog.active || hoveredLog.completed
? ts
: ts - (hoveredLog.duration || 0);
return new Date(startMs).toLocaleTimeString();
})()}
</span>
</div>
{!hoveredLog.active && (
<div className="flex justify-between gap-4">
<span>{t("ended")}</span>
<span>
{new Date(
hoveredLog.completed
? new Date(hoveredLog.timestamp).getTime() + (hoveredLog.duration || 0)
: new Date(hoveredLog.timestamp).getTime()
).toLocaleTimeString()}
</span>
</div>
)}
<div className="flex justify-between gap-4">
<span>{t("duration")}</span>
<span>
{hoveredLog.active
? `~${Math.round((nowMs - computeBarRange(hoveredLog, nowMs).startMs) / 1000).toLocaleString()}s`
: `${hoveredLog.duration.toLocaleString()}ms`}
</span>
</div>
<div className="flex justify-between gap-4">
<span>{t("status")}</span>
<span>{hoveredLog.status || t("pending")}</span>
</div>
{hoveredLog.provider && (
<div className="flex justify-between gap-4">
<span>{t("provider")}</span>
<span>{hoveredLog.provider}</span>
</div>
)}
<div className="flex justify-between gap-4">
<span>{t("tokens")}</span>
<span>
{hoveredLog.tokens.in.toLocaleString()} / {hoveredLog.tokens.out.toLocaleString()}
</span>
</div>
{hoveredLog.error && (
<div className="mt-1 text-red-400 text-[9px] break-all">{hoveredLog.error}</div>
)}
</div>
</div>
)}
{/* Legend */}
<div className="flex items-center gap-3 px-4 py-1.5 border-t border-border text-[9px] shrink-0">
<div className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#059669" }} />
<span className="text-text-muted">2xx</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#D97706" }} />
<span className="text-text-muted">4xx</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#DC2626" }} />
<span className="text-text-muted">5xx</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#6366F1" }} />
<span className="text-text-muted">{t("active")}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#6B7280" }} />
<span className="text-text-muted">{t("other")}</span>
</div>
<div className="ml-4 flex items-center gap-1.5 text-text-muted">
<div className="w-3 border-t border-dashed border-slate-400/40" />
<span>{t("tenMinutes")}</span>
<div className="w-3 border-t border-slate-400/60 ml-2" />
<span>{t("hour")}</span>
<div className="w-3 border-t-2 border-accent/40 ml-2" />
<span>{t("day")}</span>
</div>
<div
className="ml-auto text-text-muted italic"
title={t(`modes.${MODE_META[mode].descriptionKey}`)}
>
{t(`modes.${MODE_META[mode].descriptionKey}`)}
</div>
</div>
{selectedLog && (
<RequestLoggerDetail
log={selectedLog as any}
detail={detailData}
loading={detailLoading}
debugEnabled={selectedLog?.active ? true : detailLoggingEnabled}
emailsVisible={emailsVisible}
onClose={closeDetail}
onCopy={copyToClipboard}
onPrevious={undefined}
onNext={undefined}
relatedLogs={[]}
onSelectRelated={undefined}
/>
)}
</div>
);
}