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 = { 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 { const lanes: Lane[] = []; const laneConversation: (string | null)[] = []; const laneMap = new Map(); 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()}`; }