diff --git a/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx b/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx index 7dacd87837..38a74a8867 100644 --- a/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx @@ -148,7 +148,11 @@ export default function MarkdownMessage({ content, className }: MarkdownMessageP }; return ( -
+ // break-words: long unspaced runs (raw JSON, ids, tokens) have no natural + // wrap point, so without it they overflow their container instead of + // wrapping — invisible in a wide full-page layout, glaring in a narrower + // one (e.g. the conversation tree modal). +
{content} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx index d6c7f4084f..fbc67c8b27 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx @@ -9,12 +9,11 @@ import { MessageContent } from "./MessageContent"; interface ChatBubbleProps { turn: NormalizedTurn; - /** Present only for multi-row conversation transcripts (see - * src/mitm/inspector/multiRowConversation.ts) — makes the bubble clickable, - * navigating to the request log that produced this turn. Absent for the - * single-request traffic-inspector usage. */ + /** Optional — makes the bubble clickable when a caller has somewhere to + * navigate to for this turn (e.g. a tree/list view linking back to the + * request that produced it). */ onClick?: () => void; - /** True when this turn belongs to the request log currently open — shown + /** True when this turn belongs to the request currently open — shown * highlighted instead of clickable (nowhere further to navigate to). */ isCurrent?: boolean; } diff --git a/src/app/api/logs/[id]/route.ts b/src/app/api/logs/[id]/route.ts index c24834aebd..e632d36908 100644 --- a/src/app/api/logs/[id]/route.ts +++ b/src/app/api/logs/[id]/route.ts @@ -2,76 +2,6 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getCallLogById } from "@/lib/usageDb"; import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory"; -import { getAllCallLogsForConversation } from "@/lib/db/agenticConversations"; -import { buildMultiRowConversation, type LoadedCallLogRow } from "@/mitm/inspector/multiRowConversation"; - -// Reconstructing N rows means N sequential getCallLogById disk reads — bound -// how many earlier turns get loaded so a very long-running agent session -// doesn't add unbounded latency to opening any one of its turns. -const MAX_LOADED_ROWS = 50; - -interface ConversationAttachment { - conversationTurns: unknown[]; - conversationNextId: string | null; - conversationIsLatest: boolean; - conversationLastSeenAt: string | null; - conversationEarlierTurnsOmitted: boolean; -} - -/** - * "Full Conversation" panel data: every call_logs row sharing this entry's - * conversation id (session_tag), reconstructed into one chronological, - * per-turn-tagged transcript truncated to the turns visible as of the - * CURRENTLY viewed row (turn-relative view) — not always "the latest turn". - */ -async function buildConversationAttachment( - sessionTag: string | null | undefined, - currentEntry: any -): Promise { - if (!sessionTag) return null; - try { - const allRefs = getAllCallLogsForConversation(sessionTag); - if (allRefs.length === 0) return null; - - const currentIndex = allRefs.findIndex((r) => r.id === String(currentEntry.id)); - const found = currentIndex !== -1; - - // Defensive: the current row should always appear in its own conversation's - // row list. If it somehow doesn't, show everything with no "next" link - // and no "in progress" auto-refresh rather than crash or guess wrong. - let refsToLoad = found ? allRefs.slice(0, currentIndex + 1) : allRefs; - const earlierTurnsOmitted = refsToLoad.length > MAX_LOADED_ROWS; - if (earlierTurnsOmitted) refsToLoad = refsToLoad.slice(-MAX_LOADED_ROWS); - - const loadedRows: LoadedCallLogRow[] = []; - for (const ref of refsToLoad) { - const entry = - ref.id === String(currentEntry.id) ? currentEntry : await getCallLogById(ref.id); - if (!entry) continue; - loadedRows.push({ - id: String(entry.id), - timestamp: String(entry.timestamp ?? ref.timestamp), - requestBody: entry.requestBody ?? null, - responseBody: entry.responseBody ?? null, - }); - } - - const conversationTurns = buildMultiRowConversation(loadedRows); - const nextRef = found ? (allRefs[currentIndex + 1] ?? null) : null; - const lastRef = allRefs[allRefs.length - 1] ?? null; - - return { - conversationTurns, - conversationNextId: nextRef?.id ?? null, - conversationIsLatest: found && currentIndex === allRefs.length - 1, - conversationLastSeenAt: lastRef?.timestamp ?? null, - conversationEarlierTurnsOmitted: earlierTurnsOmitted, - }; - } catch (e) { - console.warn("/api/logs/[id] - failed to build conversation transcript:", e); - return null; - } -} // Best-effort parse of the accumulated SSE `data:` lines captured live for an // in-flight request (open-sse/utils/requestLogger.ts's appendConvertedChunk @@ -84,6 +14,7 @@ function extractPartialAssistantText( for (const chunkArr of [streamChunks.client, streamChunks.provider, streamChunks.openai]) { if (!Array.isArray(chunkArr) || chunkArr.length === 0) continue; let text = ""; + let reasoning = ""; for (const raw of chunkArr) { for (const line of String(raw).split("\n")) { const idx = line.indexOf("data:"); @@ -94,72 +25,24 @@ function extractPartialAssistantText( const parsed = JSON.parse(jsonStr); const delta = parsed?.choices?.[0]?.delta ?? parsed?.choices?.[0]?.message; if (typeof delta?.content === "string") text += delta.content; + if (typeof delta?.reasoning_content === "string") reasoning += delta.reasoning_content; } catch { // partial/malformed chunk line (e.g. cut mid-write) — skip it } } } if (text) return text; + // Reasoning-model providers (e.g. DeepSeek-R1-style) stream + // `reasoning_content` before any visible `content` — with only the + // content check above, the live panel had nothing new to show for the + // whole reasoning phase and looked frozen even while the SSE event + // stream kept visibly ticking. Surface the reasoning text meanwhile so + // the panel keeps progressing. + if (reasoning) return `_Thinking…_\n\n${reasoning}`; } return ""; } -/** - * Same idea as buildConversationAttachment, but for a request that hasn't - * finished (and isn't in call_logs yet): prior turns come from already- - * persisted rows, and the currently-streaming reply is reconstructed from the - * live streamChunks capture — so the "Full Conversation" panel can grow in - * real time while a request is still generating, matching the raw SSE panel. - */ -async function buildInFlightConversationAttachment(pendingRequestDetail: { - id: string; - sessionTag?: string | null; - clientRequest?: unknown; - streamChunks?: { provider?: string[]; openai?: string[]; client?: string[] } | null; -}): Promise { - const sessionTag = pendingRequestDetail.sessionTag; - if (!sessionTag) return null; - try { - const allRefs = getAllCallLogsForConversation(sessionTag); - const earlierTurnsOmitted = allRefs.length > MAX_LOADED_ROWS; - const refsToLoad = earlierTurnsOmitted ? allRefs.slice(-MAX_LOADED_ROWS) : allRefs; - - const loadedRows: LoadedCallLogRow[] = []; - for (const ref of refsToLoad) { - const entry = await getCallLogById(ref.id); - if (!entry) continue; - loadedRows.push({ - id: String(entry.id), - timestamp: String(entry.timestamp ?? ref.timestamp), - requestBody: entry.requestBody ?? null, - responseBody: entry.responseBody ?? null, - }); - } - - const partialText = extractPartialAssistantText(pendingRequestDetail.streamChunks); - const nowIso = new Date().toISOString(); - loadedRows.push({ - id: pendingRequestDetail.id, - timestamp: nowIso, - requestBody: pendingRequestDetail.clientRequest ?? null, - responseBody: partialText - ? { choices: [{ message: { role: "assistant", content: partialText } }] } - : null, - }); - - return { - conversationTurns: buildMultiRowConversation(loadedRows), - conversationNextId: null, - conversationIsLatest: true, - conversationLastSeenAt: nowIso, - conversationEarlierTurnsOmitted: earlierTurnsOmitted, - }; - } catch (e) { - console.warn("/api/logs/[id] - failed to build in-flight conversation transcript:", e); - return null; - } -} - export const dynamic = "force-dynamic"; export async function GET( @@ -200,18 +83,12 @@ export async function GET( active: true, pipelinePayloads, hasPipelineDetails: true, + // The still-generating reply so far — the request's own context + // panel renders this alongside its (already-complete) requestBody + // instead of waiting for the stream to finish. + partialAssistantText: extractPartialAssistantText(pendingRequestDetail.streamChunks), }; - const inFlightConversationAttachment = await buildInFlightConversationAttachment({ - id: pendingRequestDetail.id, - sessionTag: (pendingRequestDetail as any).sessionTag ?? null, - clientRequest: pendingRequestDetail.clientRequest, - streamChunks: pendingRequestDetail.streamChunks, - }); - if (inFlightConversationAttachment) { - Object.assign(activeEntry, inFlightConversationAttachment); - } - return NextResponse.json(activeEntry); } } catch (e) { @@ -270,14 +147,6 @@ export async function GET( if (!persistedRequest) return NextResponse.json({ error: "Not found" }, { status: 404 }); - const conversationAttachment = await buildConversationAttachment( - (persistedRequest as any).sessionTag, - persistedRequest - ); - if (conversationAttachment) { - Object.assign(persistedRequest, conversationAttachment); - } - return NextResponse.json(persistedRequest); } catch (err) { console.error("[API ERROR] /api/logs/[id] failed:", err); diff --git a/src/mitm/inspector/conversationNormalizer.ts b/src/mitm/inspector/conversationNormalizer.ts index b9813718fe..d0347e03b1 100644 --- a/src/mitm/inspector/conversationNormalizer.ts +++ b/src/mitm/inspector/conversationNormalizer.ts @@ -79,8 +79,7 @@ function blocksFromOpenAiContent(content: unknown): NormalizedBlock[] { } else if (type === "tool_result") { out.push({ type: "tool_result", - tool_use_id: - typeof block.tool_use_id === "string" ? block.tool_use_id : "", + tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : "", content: block.content ?? null, }); } else if (typeof block.text === "string") { @@ -94,10 +93,7 @@ function blocksFromOpenAiContent(content: unknown): NormalizedBlock[] { * OpenAI assistant messages may declare `tool_calls`. Each becomes a * `tool_use` block alongside any text content. */ -function appendOpenAiToolCalls( - blocks: NormalizedBlock[], - toolCalls: unknown -): NormalizedBlock[] { +function appendOpenAiToolCalls(blocks: NormalizedBlock[], toolCalls: unknown): NormalizedBlock[] { if (!Array.isArray(toolCalls)) return blocks; for (const raw of toolCalls) { const tc = asRecord(raw); @@ -126,11 +122,74 @@ function appendOpenAiToolCalls( /** * Build NormalizedTurn[] from OpenAI / Anthropic chat messages. */ +/** Responses API reasoning items carry `summary: [{type: "summary_text", text}]`. */ +function reasoningSummaryText(summary: unknown): string { + if (!Array.isArray(summary)) return ""; + const parts: string[] = []; + for (const raw of summary) { + const block = asRecord(raw); + if (block && typeof block.text === "string") parts.push(block.text); + } + return parts.join("\n\n"); +} + function turnsFromOpenAiMessages(messages: unknown[]): NormalizedTurn[] { const out: NormalizedTurn[] = []; for (const raw of messages) { const msg = asRecord(raw); if (!msg) continue; + + // Responses API items for tool activity/reasoning carry no `role` at + // all — they're distinguished by `type` instead. Handle these before the + // role-based branches below, which would otherwise silently drop them + // (empty `content`, no `tool_calls`, `normalizeRole(undefined)` defaults + // to "user") — the exact gap that made a real OpenClaw request's + // function_call/function_call_output items vanish from the Conversation + // Context panel entirely (2026-08-06). + if (msg.type === "function_call") { + let parsedInput: unknown = {}; + if (typeof msg.arguments === "string") { + try { + parsedInput = JSON.parse(msg.arguments); + } catch { + parsedInput = msg.arguments; + } + } else if (msg.arguments != null) { + parsedInput = msg.arguments; + } + out.push({ + role: "assistant", + blocks: [ + { + type: "tool_use", + id: typeof msg.call_id === "string" ? msg.call_id : "", + name: typeof msg.name === "string" ? msg.name : "", + input: parsedInput, + }, + ], + }); + continue; + } + if (msg.type === "function_call_output") { + out.push({ + role: "tool", + blocks: [ + { + type: "tool_result", + tool_use_id: typeof msg.call_id === "string" ? msg.call_id : "", + content: msg.output ?? null, + }, + ], + }); + continue; + } + if (msg.type === "reasoning") { + const text = reasoningSummaryText(msg.summary); + if (!text) continue; + out.push({ role: "assistant", blocks: [{ type: "text", text }] }); + continue; + } + const role = normalizeRole(msg.role); if (msg.role === "tool" || msg.role === "function") { @@ -374,9 +433,7 @@ export function buildResponseTurns(req: InterceptedRequest): NormalizedTurn[] { * Normalize an intercepted LLM request + response into a provider-agnostic * conversation. Returns `null` for non-LLM requests or unparseable payloads. */ -export function normalizeConversation( - req: InterceptedRequest -): NormalizedConversation | null { +export function normalizeConversation(req: InterceptedRequest): NormalizedConversation | null { if (req.detectedKind !== "llm") return null; const requestBody = tryParseJson(req.requestBody); diff --git a/src/mitm/inspector/multiRowConversation.ts b/src/mitm/inspector/multiRowConversation.ts deleted file mode 100644 index 3493b0943c..0000000000 --- a/src/mitm/inspector/multiRowConversation.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Multi-row conversation transcript builder. - * - * The single-request `normalizeConversation()` (conversationNormalizer.ts) - * builds a transcript from ONE request+response pair — fine for the - * traffic-inspector's per-request view, but a multi-turn agentic - * conversation is actually N separate call_logs rows (one per HTTP request), - * each carrying its own real timestamp. This module reconstructs a single, - * chronological turn list across all of them, tagging every turn with the - * call_logs row (id + timestamp) that actually produced it — needed for - * per-turn timestamps and click-to-navigate-to-that-turn's-log. - * - * Relies on the invariant enforced by - * open-sse/services/conversationTracker.ts::resolveConversationId: rows - * sharing a conversation id have STRICTLY increasing request-turn counts - * (a real continuation always appends at least the assistant's reply + a new - * turn). The delta between consecutive rows' turn counts is therefore always - * >= 0 by construction; the `Math.max(0, ...)` clamp below is a defensive - * backstop, not load-bearing for well-formed data. - */ - -import { buildRequestTurns, buildResponseTurns } from "./conversationNormalizer.ts"; -import type { InterceptedRequest, NormalizedTurn } from "./types.ts"; - -export interface ConversationTurn extends NormalizedTurn { - sourceCallLogId: string; - timestamp: string; -} - -export interface LoadedCallLogRow { - id: string; - timestamp: string; - requestBody: unknown; - responseBody: unknown; -} - -/** - * open-sse/handlers/chatCore/logTruncation.ts::truncateForLog() replaces a - * request body over ~8KB with a bare summary — {_truncated, _originalBytes, - * messageCount, ...} — dropping `messages`/`input` entirely to bound - * in-memory logging cost. Any real conversation with substantial history - * hits this on nearly every row, so buildRequestTurns() legitimately returns - * zero turns for it: there is nothing left to parse. Without this check the - * transcript would silently render only the response for that row (looking - * exactly like "just the last line" of a long chain), and — worse — every - * SUBSEQUENT row's delta slicing would be computed against the wrong - * previousTotal (0 instead of the row's real turn count), corrupting the - * rest of the reconstruction too. - * - * `knownCount` is null when the summary carries no count at all — either - * older data logged before truncateForLog() learned to count Responses API - * `input[]` bodies, or some other body shape it doesn't recognize. In that - * case we can't safely diff against previousTotal, so the caller falls back - * to a single generic placeholder instead of a specific "N messages" one. - */ -function getTruncationInfo(body: unknown): { knownCount: number | null } | null { - if (!body || typeof body !== "object" || Array.isArray(body)) return null; - const record = body as Record; - if (record._truncated !== true) return null; - return { knownCount: typeof record.messageCount === "number" ? record.messageCount : null }; -} - -function placeholderTurn(text: string, row: LoadedCallLogRow): ConversationTurn { - return { - role: "system", - blocks: [{ type: "text", text }], - sourceCallLogId: row.id, - timestamp: row.timestamp, - }; -} - -function rowAsInterceptedRequest(row: LoadedCallLogRow): InterceptedRequest { - return { - id: row.id, - source: "custom-host", - timestamp: row.timestamp, - method: "POST", - host: "", - path: "", - requestHeaders: {}, - requestBody: row.requestBody != null ? JSON.stringify(row.requestBody) : null, - requestSize: 0, - responseHeaders: {}, - responseBody: row.responseBody != null ? JSON.stringify(row.responseBody) : null, - responseSize: 0, - status: 0, - detectedKind: "llm", - }; -} - -/** - * Build the full chronological turn list across every call_logs row of one - * conversation. `rows` must already be sorted ascending by timestamp. - */ -export function buildMultiRowConversation(rows: LoadedCallLogRow[]): ConversationTurn[] { - let previousTotal = 0; - const turns: ConversationTurn[] = []; - - for (const row of rows) { - const truncation = getTruncationInfo(row.requestBody); - const respTurns = buildResponseTurns(rowAsInterceptedRequest(row)); - - if (truncation === null) { - const reqTurns = buildRequestTurns(row.requestBody) ?? []; - const sliceStart = Math.max(0, Math.min(previousTotal, reqTurns.length)); - for (const turn of reqTurns.slice(sliceStart)) { - turns.push({ ...turn, sourceCallLogId: row.id, timestamp: row.timestamp }); - } - previousTotal = reqTurns.length + respTurns.length; - } else if (truncation.knownCount !== null) { - const effectiveReqTurnCount = truncation.knownCount; - const sliceStart = Math.max(0, Math.min(previousTotal, effectiveReqTurnCount)); - const newCount = Math.max(0, effectiveReqTurnCount - sliceStart); - if (newCount > 0) { - turns.push( - placeholderTurn( - `${newCount} message${newCount === 1 ? "" : "s"} not shown — the request body was too large to log.`, - row - ) - ); - } - previousTotal = effectiveReqTurnCount + respTurns.length; - } else { - // Count unknown (older data, or a body shape truncateForLog() doesn't - // recognize) — can't tell how many of this row's turns are genuinely - // new, so surface one generic placeholder rather than silently - // showing nothing. previousTotal is left as-is: we have no reliable - // new figure to add to it, and understating a later row's "new" count - // is a safer failure mode here than overstating it. - turns.push( - placeholderTurn("Earlier messages not shown — the request body was too large to log.", row) - ); - previousTotal = previousTotal + respTurns.length; - } - - for (const turn of respTurns) { - turns.push({ ...turn, sourceCallLogId: row.id, timestamp: row.timestamp }); - } - } - - return turns; -} diff --git a/src/mitm/inspector/types.ts b/src/mitm/inspector/types.ts index 81e1cb70d5..c74c694f82 100644 --- a/src/mitm/inspector/types.ts +++ b/src/mitm/inspector/types.ts @@ -1,23 +1,19 @@ import { z } from "zod"; export type CaptureSource = - | "agent-bridge" - | "custom-host" - | "http-proxy" - | "system-proxy" - | "tproxy"; + "agent-bridge" | "custom-host" | "http-proxy" | "system-proxy" | "tproxy"; export type DetectedKind = "llm" | "app" | "unknown"; export interface InterceptedRequest { - id: string; // uuid + id: string; // uuid source: CaptureSource; - agent?: import("../types").AgentId; // only when source === "agent-bridge" - timestamp: string; // ISO 8601 + agent?: import("../types").AgentId; // only when source === "agent-bridge" + timestamp: string; // ISO 8601 method: string; host: string; path: string; requestHeaders: Record; - requestBody: string | null; // masked + requestBody: string | null; // masked requestSize: number; responseHeaders: Record; responseBody: string | null; @@ -26,16 +22,16 @@ export interface InterceptedRequest { proxyLatencyMs?: number; upstreamLatencyMs?: number; totalLatencyMs?: number; - error?: string; // sanitized + error?: string; // sanitized sourceModel?: string | null; mappedModel?: string | null; detectedKind?: DetectedKind; - contextKey?: string; // 12-hex SHA-256 of system prompt + contextKey?: string; // 12-hex SHA-256 of system prompt annotation?: string; sessionId?: string; note?: string; - pid?: number; // originating process id (Linux only) - processName?: string; // originating process name (Linux only) + pid?: number; // originating process id (Linux only) + processName?: string; // originating process name (Linux only) } export const InterceptedRequestSchema = z.object({ @@ -76,9 +72,9 @@ export type NormalizedBlock = export interface NormalizedTurn { role: "system" | "user" | "assistant" | "tool"; blocks: NormalizedBlock[]; - /** call_logs.id that produced this turn — set only by the multi-row - * conversation transcript builder (src/mitm/inspector/multiRowConversation.ts), - * absent for the single-request traffic-inspector ConversationTab usage. */ + /** call_logs.id that produced this turn, when a caller has one to attach + * (e.g. linking a turn back to its source request) — absent for a plain + * single-request normalization. */ sourceCallLogId?: string; /** ISO timestamp of the call_logs row that produced this turn — same * scoping as sourceCallLogId. */ diff --git a/src/shared/components/OmniRouteLogo.tsx b/src/shared/components/OmniRouteLogo.tsx index 32a09cb6fa..1c1716da8b 100644 --- a/src/shared/components/OmniRouteLogo.tsx +++ b/src/shared/components/OmniRouteLogo.tsx @@ -7,6 +7,13 @@ type OmniRouteLogoProps = { className?: string; }; +// Dark Reader (and similar browser extensions) injects style/ +// data-darkreader-inline-stroke attributes onto elements with an inline +// `stroke` before React hydrates, causing a harmless but noisy +// hydration-mismatch warning on the elements below — not an app bug, +// see https://nextjs.org/docs/messages/react-hydration-error's own "browser +// extension" case. + export default function OmniRouteLogo({ size = 20, className = "" }: OmniRouteLogoProps) { return ( {/* Connection lines */} 0) return minutes * 60 * 1000; - } catch { - // localStorage unavailable — fall through to default - } - return DEFAULT_CONVERSATION_REUSE_WINDOW_MINUTES * 60 * 1000; +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", + }; } -function ConversationTranscriptSection({ - turns, - nextId, - isLatest, - lastSeenAt, - earlierTurnsOmitted, - currentLogId, - onNavigateToLog, -}) { +function ConversationContextSection({ log, detail }) { const [open, setOpen] = useState(true); - const [polling, setPolling] = useState(false); - const [liveTurns, setLiveTurns] = useState(turns); - const [autoFollow, setAutoFollow] = useState(() => { + const [liveDetail, setLiveDetail] = useState(detail); + const [liveRefresh, setLiveRefresh] = useState(() => { try { - const v = localStorage.getItem(CONVERSATION_AUTO_FOLLOW_STORAGE_KEY); + const v = localStorage.getItem("pref:conversationContext:liveRefresh"); 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(null); const turnsBoxRef = useRef(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; + 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 | 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 { - // ignore — best-effort UX only - } + } catch {} }); - }, [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 - } }; + 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(() => { - 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" }); - }, []); + if (!liveRefresh || !open) return; + scrollToBottom(); + }, [allTurns.length, liveDetail?.partialAssistantText, liveRefresh, open]); - useEffect(() => { - let cancelled = false; - let timeoutId: ReturnType | 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]); + if (allTurns.length === 0) return null; return ( -
-
+
+

- Full Conversation + Conversation Context

-
- - {/* Same setting Timeline's "Lane reuse" control persists under (shared key) — - changing it here also changes when Timeline treats a lane as reusable. */} - -
+ {open && ( +
+ {liveDetail?.active && ( + + )} + +
+ )}
{open && (
- {earlierTurnsOmitted && ( -
- Earlier turns not shown for this long-running conversation. -
- )} - {liveTurns.map((turn, i) => { - const isCurrent = turn.sourceCallLogId === currentLogId; - return ( - onNavigateToLog(turn.sourceCallLogId) - : undefined - } - /> - ); - })} - {nextId && ( - - )} - {polling && ( -
- {/* Same ring-spinner markup as the "in progress" status badge above - (not the shared icon glyph, which spins visibly off-axis). */} - - watching for new turns… -
- )} + {allTurns.map((turn, i) => ( + + ))}
)}
@@ -481,7 +379,6 @@ export default function RequestLoggerDetail({ onNext, relatedLogs = [], onSelectRelated, - onNavigateToLog, }) { // Close on Escape key useEffect(() => { @@ -696,22 +593,30 @@ export default function RequestLoggerDetail({ )}
- - + {/* 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) && ( + <> + + + + )}