+ // 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.
-
- {/* 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) && (
+ <>
+
+
+ >
+ )}