diff --git a/src/app/(dashboard)/dashboard/conversations/page.tsx b/src/app/(dashboard)/dashboard/conversations/page.tsx index f9fb3b1848..14b8bdf4d7 100644 --- a/src/app/(dashboard)/dashboard/conversations/page.tsx +++ b/src/app/(dashboard)/dashboard/conversations/page.tsx @@ -26,6 +26,11 @@ interface ConversationRow { // streaming (call_logs only gets its row on completion). Used to poll // /api/logs/[id] for this conversation's live partial assistant text. activeCallLogId: string | null; + // Whether the latest turn actually used previous_response_id and it + // resolved server-side — distinct from this row existing at all, which + // only means the client-side content-hash tracker saw >= 2 turns + // regardless of transport (see isGenuineContinuationTurn). + isGenuineContinuation: boolean; } // Same spinner used for an in-flight request on /dashboard/logs @@ -101,6 +106,23 @@ function StatusBadge({ status }: { status: number | null }) { ); } +// Distinguishes a conversation whose latest turn actually used +// previous_response_id (server-verified — see isGenuineContinuationTurn) +// from one the content-hash tracker merely counts as multi-turn while still +// resending full history each request. +function ContinuationBadge({ isGenuine }: { isGenuine: boolean }) { + if (!isGenuine) return null; + return ( + + bolt + continuation + + ); +} + /** * Builds the exact NormalizedBlock (src/mitm/inspector/types.ts) the * request-detail panel already builds from buildRequestTurns/ @@ -268,13 +290,15 @@ function ConversationsPageContent() { // itself in the poll effect's dependency array (which would tear down and // restart the interval on every single appended turn). const newestSeqRef = useRef(null); + // Tracks the PREVIOUS render's activeCallLogId truthiness, so the + // reply-just-finished effect below can detect the true->false transition + // specifically (not "is currently falsy", which would also fire on mount + // / switching conversations). + const wasReplyActiveRef = useRef(false); - // Extracted so openConversation can force an immediate refresh instead of - // waiting for the next scheduled tick — see its call site for why: a - // conversation opened right after a new reply starts streaming otherwise - // shows no live text until this poll's own interval happens to land, - // because activeCallLogId only updates via the resync effect below, which - // depends on this list actually having been refetched. + // The background list poll below only runs this while no conversation + // modal is open — see loadActiveConversationSummary and the poll effect + // for the lighter single-row path used while one is open. const loadConversations = useCallback(() => { if (document.visibilityState !== "visible") return; return fetch("/api/conversations?limit=100", { cache: "no-store" }) @@ -290,22 +314,51 @@ function ConversationsPageContent() { }); }, []); + // While the modal is open, only the one open conversation's summary needs + // to stay live (see the resync effect below) — refetching and + // re-annotating the whole up-to-100-row list every poll tick just to pluck + // that one row back out is pure waste, and at a 1s poll interval it's + // waste on every tick. Patches the row in place so the existing resync + // effect (keyed on `conversations`) picks it up unchanged. + const loadActiveConversationSummary = useCallback((id: string) => { + if (document.visibilityState !== "visible") return; + return fetch(`/api/conversations/${id}`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + const fresh = data?.conversation; + if (!fresh) return; + setConversations((prev) => { + const idx = prev.findIndex((c) => c.id === fresh.id); + if (idx === -1) return prev; + const next = prev.slice(); + next[idx] = fresh; + return next; + }); + }) + .catch(() => {}); + }, []); + useEffect(() => { - loadConversations(); - const interval = setInterval(loadConversations, pollSeconds * 1000); + const poll = () => + activeConversationId + ? loadActiveConversationSummary(activeConversationId) + : loadConversations(); + poll(); + const interval = setInterval(poll, pollSeconds * 1000); return () => { clearInterval(interval); }; - }, [pollSeconds, loadConversations]); + }, [pollSeconds, loadConversations, loadActiveConversationSummary, activeConversationId]); // activeConversation is a snapshot taken once at openConversation() time — // it's never touched again while the modal stays open (the turns-poll // effect below only appends conversationNodes). Without this, "Goto latest // request" and any other displayed summary field (lastModel/lastStatus/ // turnCount) go stale the moment a new request lands in this conversation - // while you're still reading it, even though the list poll above (which - // runs regardless of whether the modal is open) already has the fresh - // row. Re-sync from it whenever the list refreshes. + // while you're still reading it. Re-synced from `conversations` whenever + // that refreshes — the effect above keeps it fresh whether the modal is + // closed (full list poll) or open (single-conversation poll patches this + // same row in place). useEffect(() => { if (!activeConversationId) return; const fresh = conversations.find((c) => c.id === activeConversationId); @@ -461,13 +514,13 @@ function ConversationsPageContent() { // ignore navigation errors } // `row` is a snapshot from whenever the list last polled — if a reply - // started streaming after that tick, row.activeCallLogId is still - // null and the live-text poll effect never starts until the next - // scheduled list refresh happens to land (the exact "opened it and - // saw nothing, closed and reopened and saw it live" report). Force - // one now so activeConversation resyncs with the current isActive/ - // activeCallLogId immediately instead of waiting on pollSeconds. - loadConversations(); + // started streaming after that tick, row.activeCallLogId is still null + // and the live-text poll effect never starts until a fresh summary + // lands (the exact "opened it and saw nothing, closed and reopened and + // saw it live" report). setActiveConversation above already changes + // activeConversationId, which is a dependency of the poll effect below + // — it tears down and re-fires immediately on that change, forcing the + // single-row resync here for free without a second explicit call. fetchConversationPage(row.id, `limit=${CONVERSATION_PAGE_SIZE}`) .then((page) => { setConversationNodes(page?.nodes ?? []); @@ -480,7 +533,7 @@ function ConversationsPageContent() { scrollToBottom(); }); }, - [router, fetchConversationPage, scrollToBottom, loadConversations] + [router, fetchConversationPage, scrollToBottom] ); const closeConversation = useCallback(() => { @@ -583,6 +636,35 @@ function ConversationsPageContent() { return () => clearInterval(interval); }, [activeConversationId, pollSeconds, fetchConversationPage]); + // Live incident (2026-09-02): resolveConversationId reassigns a node's + // last_correlation_id to the CURRENT request at request-START (before its + // reply streams), but that request's call-log artifact -- what + // resolveTurnDisplayContent needs to show real text -- is only written at + // completion. A node touched by a still-in-flight request therefore + // legitimately resolves empty if fetched during that window; the afterSeq + // poll above only ever APPENDS strictly newer nodes, so one already + // rendered empty stays empty in local state forever, even once its + // artifact exists moments later -- the exact "empty until you close and + // reopen the conversation" symptom. Once a reply that was streaming + // finishes (activeCallLogId's true -> false transition -- see the + // wasReplyActiveRef doc comment), re-fetch the recent page and merge it in + // by id (never drop older "Load more" history) so any node that resolved + // empty during the race gets its real content without a manual reopen. + useEffect(() => { + const wasActive = wasReplyActiveRef.current; + wasReplyActiveRef.current = Boolean(activeCallLogId); + if (!wasActive || activeCallLogId || !activeConversationId) return; + + fetchConversationPage(activeConversationId, `limit=${CONVERSATION_PAGE_SIZE}`).then((page) => { + if (!page || page.nodes.length === 0) return; + setConversationNodes((prev) => { + const byId = new Map(prev.map((n) => [n.id, n] as const)); + for (const n of page.nodes) byId.set(n.id, n); + return [...byId.values()].sort((a, b) => a.seq - b.seq); + }); + }); + }, [activeCallLogId, activeConversationId, fetchConversationPage]); + // Live preview of the CURRENTLY streaming reply, if any: conversation_turn_nodes // only gains a node for an assistant turn once the client resends it as // history on its NEXT request (resolveConversationId reads only the request @@ -655,6 +737,7 @@ function ConversationsPageContent() { lastStatus: null, isActive: false, activeCallLogId: null, + isGenuineContinuation: false, } ); }, [initialConversationParam, loading, conversations, openConversation]); @@ -759,6 +842,7 @@ function ConversationsPageContent() { > {row.id.slice(0, 16)}… + {row.turnCount} turns @@ -785,6 +869,7 @@ function ConversationsPageContent() { Conversation Turns + Continuation Last Model Provider Status @@ -816,6 +901,9 @@ function ConversationsPageContent() { {row.turnCount} + + + {row.lastModel ?? "—"} diff --git a/src/app/api/conversations/[id]/route.ts b/src/app/api/conversations/[id]/route.ts new file mode 100644 index 0000000000..999e2147df --- /dev/null +++ b/src/app/api/conversations/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getMultiTurnConversationById } from "@/lib/db/agenticConversations"; +import { annotateConversationRow, buildActiveCallLogIdByConversation } from "../route"; + +export const dynamic = "force-dynamic"; + +/** + * Single-conversation summary — used by the dashboard's conversation modal + * to keep lastModel/lastStatus/isActive/activeCallLogId fresh on the auto- + * refresh interval while it's open, instead of the list route re-fetching + * and re-annotating up to 200 rows just to pluck one back out. The turns + * themselves live-update through the separate .../tree poll; this only + * covers the summary fields the modal header and "Goto latest request" + * read off the row. + */ +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(req); + if (authError) return authError; + + try { + const { id } = await params; + if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); + + const row = getMultiTurnConversationById(id); + if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const activeCallLogIdByConversation = buildActiveCallLogIdByConversation(); + const conversation = annotateConversationRow(row, activeCallLogIdByConversation); + + return NextResponse.json({ conversation }); + } catch (err) { + console.error("[API ERROR] /api/conversations/[id] failed:", err); + return NextResponse.json({ error: "Failed to fetch conversation" }, { status: 500 }); + } +} diff --git a/src/app/api/conversations/route.ts b/src/app/api/conversations/route.ts index 9b5ebc3746..e603460dfb 100644 --- a/src/app/api/conversations/route.ts +++ b/src/app/api/conversations/route.ts @@ -1,10 +1,53 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import type { MultiTurnConversationRow } from "@/lib/db/agenticConversations"; import { listMultiTurnConversations } from "@/lib/db/agenticConversations"; import { getPendingById } from "@/lib/usage/usageHistory"; +import { isGenuineContinuationTurn } from "@/lib/db/responsesContinuationStore"; export const dynamic = "force-dynamic"; +/** + * Shared row -> API-shape annotation for both the list route and the + * single-conversation route below: isActive/activeCallLogId (pending-request + * cross reference) and isGenuineContinuation (artifact-backed, cached — see + * isGenuineContinuationTurn) need the exact same computation regardless of + * whether the caller asked for one row or many. Strips the internal-only + * lastArtifactRelPath/lastApiKeyId fields before they reach the client. + */ +export function annotateConversationRow( + row: MultiTurnConversationRow, + activeCallLogIdByConversation: ReadonlyMap +) { + const { lastArtifactRelPath, lastApiKeyId, ...rest } = row; + return { + ...rest, + isActive: activeCallLogIdByConversation.has(row.id), + activeCallLogId: activeCallLogIdByConversation.get(row.id) ?? null, + isGenuineContinuation: isGenuineContinuationTurn(lastArtifactRelPath, lastApiKeyId), + }; +} + +/** + * A pending (still-streaming) request's sessionTag is the conversation's own + * id (agentic_conversations.id === call_logs.session_tag) — cross reference + * so a conversation row can show "in progress" without a separate poll. + * `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's + * INSERT needs duration/status/tokens, none of which exist yet), so + * lastCallLogId always lags one request behind while a reply is still + * streaming — it can't be used to fetch the in-flight response. Surfacing + * the pending request's own id separately lets the conversation panel poll + * /api/logs/[id] for it directly (same live-partial-text path + * RequestLoggerDetail already uses). + */ +export function buildActiveCallLogIdByConversation(): Map { + const map = new Map(); + for (const pending of getPendingById().values()) { + if (pending.sessionTag) map.set(pending.sessionTag, pending.id); + } + return map; +} + export async function GET(req: Request) { const authError = await requireManagementAuth(req); if (authError) return authError; @@ -19,25 +62,10 @@ export async function GET(req: Request) { offset: Number.isFinite(offset) ? offset : undefined, }); - // A pending (still-streaming) request's sessionTag is the conversation's - // own id (agentic_conversations.id === call_logs.session_tag) — cross - // reference so the list can show "in progress" without a separate poll. - // `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's - // INSERT needs duration/status/tokens, none of which exist yet), so - // `lastCallLogId` from listMultiTurnConversations always lags one request - // behind while a reply is still streaming — it can't be used to fetch the - // in-flight response. Surface the pending request's own id separately so - // the conversation panel can poll /api/logs/[id] for it directly (same - // live-partial-text path RequestLoggerDetail already uses). - const activeCallLogIdByConversation = new Map(); - for (const pending of getPendingById().values()) { - if (pending.sessionTag) activeCallLogIdByConversation.set(pending.sessionTag, pending.id); - } - const conversations = rows.map((row) => ({ - ...row, - isActive: activeCallLogIdByConversation.has(row.id), - activeCallLogId: activeCallLogIdByConversation.get(row.id) ?? null, - })); + const activeCallLogIdByConversation = buildActiveCallLogIdByConversation(); + const conversations = rows.map((row) => + annotateConversationRow(row, activeCallLogIdByConversation) + ); return NextResponse.json({ conversations, total }); } catch (err) { diff --git a/src/app/api/logs/[id]/route.ts b/src/app/api/logs/[id]/route.ts index 7c20cfb819..afdb7d2432 100644 --- a/src/app/api/logs/[id]/route.ts +++ b/src/app/api/logs/[id]/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getCallLogById } from "@/lib/usageDb"; import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory"; +import { + extractPreviousResponseId, + resolveCallLogIdByResponseId, +} from "@/lib/db/responsesContinuationStore"; // Each logged chunk-array element is one raw network read, timestamp-prefixed // for the debug display — NOT one complete SSE `data:` line. A single JSON @@ -159,7 +163,20 @@ export async function GET( if (!persistedRequest) return NextResponse.json({ error: "Not found" }, { status: 404 }); - return NextResponse.json(persistedRequest); + // "Continues from" link for the dashboard's conversation panel: resolve + // this entry's own previous_response_id back to the call-log row that + // produced it. Persisted-only (apiKeyId isn't plumbed onto the + // pending/in-memory branches above) -- required for the same tenant + // scoping resolveCallLogIdByResponseId enforces, so an active/in-memory + // entry simply renders no parent link rather than resolving unscoped. + const previousResponseId = extractPreviousResponseId( + persistedRequest.pipelinePayloads as Record | null | undefined + ); + const parentLogId = previousResponseId + ? resolveCallLogIdByResponseId(previousResponseId, persistedRequest.apiKeyId ?? null) + : null; + + return NextResponse.json({ ...persistedRequest, previousResponseId, parentLogId }); } catch (err) { console.error("[API ERROR] /api/logs/[id] failed:", err); return NextResponse.json({ error: "Failed to fetch log" }, { status: 500 }); diff --git a/src/lib/db/agenticConversations.ts b/src/lib/db/agenticConversations.ts index 646fce57a7..0bdb9ff4e2 100644 --- a/src/lib/db/agenticConversations.ts +++ b/src/lib/db/agenticConversations.ts @@ -357,6 +357,12 @@ export interface MultiTurnConversationRow extends AgenticConversationRow { lastModel: string | null; lastProvider: string | null; lastStatus: number | null; + // Exposed so the API layer can check whether the latest turn genuinely + // used HTTP continuation (see isGenuineContinuationTurn in + // responsesContinuationStore.ts) without a second query — this row's own + // artifact/tenant already identify it, no separate lookup needed. + lastArtifactRelPath: string | null; + lastApiKeyId: string | null; } /** @@ -400,16 +406,7 @@ export function listMultiTurnConversations( const rows = db .prepare( - `SELECT ac.*, latest.id as last_call_log_id, latest.model as last_model, - latest.provider as last_provider, latest.status as last_status - FROM agentic_conversations ac - LEFT JOIN ( - SELECT cl1.id, cl1.session_tag, cl1.model, cl1.provider, cl1.status - FROM call_logs cl1 - WHERE cl1.timestamp = ( - SELECT MAX(cl2.timestamp) FROM call_logs cl2 WHERE cl2.session_tag = cl1.session_tag - ) - ) latest ON latest.session_tag = ac.id + `${MULTI_TURN_CONVERSATION_SELECT} WHERE (SELECT COUNT(*) FROM conversation_turn_nodes n WHERE n.conversation_id = ac.id) >= 2 ORDER BY ac.last_seen_at DESC LIMIT ? OFFSET ?` @@ -418,15 +415,52 @@ export function listMultiTurnConversations( return { total: Number(total ?? 0), - rows: rows.map((r) => { - const rec = asRecord(r); - return { - ...toRow(rec), - lastCallLogId: typeof rec.last_call_log_id === "string" ? rec.last_call_log_id : null, - lastModel: typeof rec.last_model === "string" ? rec.last_model : null, - lastProvider: typeof rec.last_provider === "string" ? rec.last_provider : null, - lastStatus: typeof rec.last_status === "number" ? rec.last_status : null, - }; - }), + rows: rows.map(toMultiTurnConversationRow), }; } + +function toMultiTurnConversationRow(value: unknown): MultiTurnConversationRow { + const rec = asRecord(value); + return { + ...toRow(rec), + lastCallLogId: typeof rec.last_call_log_id === "string" ? rec.last_call_log_id : null, + lastModel: typeof rec.last_model === "string" ? rec.last_model : null, + lastProvider: typeof rec.last_provider === "string" ? rec.last_provider : null, + lastStatus: typeof rec.last_status === "number" ? rec.last_status : null, + lastArtifactRelPath: + typeof rec.last_artifact_relpath === "string" ? rec.last_artifact_relpath : null, + lastApiKeyId: typeof rec.last_api_key_id === "string" ? rec.last_api_key_id : null, + }; +} + +const MULTI_TURN_CONVERSATION_SELECT = ` + SELECT ac.*, latest.id as last_call_log_id, latest.model as last_model, + latest.provider as last_provider, latest.status as last_status, + latest.artifact_relpath as last_artifact_relpath, + latest.api_key_id as last_api_key_id + FROM agentic_conversations ac + LEFT JOIN ( + SELECT cl1.id, cl1.session_tag, cl1.model, cl1.provider, cl1.status, + cl1.artifact_relpath, cl1.api_key_id + FROM call_logs cl1 + WHERE cl1.timestamp = ( + SELECT MAX(cl2.timestamp) FROM call_logs cl2 WHERE cl2.session_tag = cl1.session_tag + ) + ) latest ON latest.session_tag = ac.id +`; + +/** + * Single-conversation equivalent of listMultiTurnConversations, for the + * dashboard's conversation modal: while it's open, polling this one row on + * the refresh interval (instead of the whole up-to-200-row list just to + * pluck one row back out of it) is what actually needs to stay live — + * lastCallLogId/lastStatus for "Goto latest request" and isActive detection. + * Unlike the list, this intentionally has no turn-count floor: a + * specifically-requested conversation should resolve even if it hasn't (yet) + * reached 2 turn nodes. + */ +export function getMultiTurnConversationById(id: string): MultiTurnConversationRow | null { + const db = getDbInstance(); + const row = db.prepare(`${MULTI_TURN_CONVERSATION_SELECT} WHERE ac.id = ?`).get(id); + return row ? toMultiTurnConversationRow(row) : null; +} diff --git a/src/lib/db/responsesContinuationStore.ts b/src/lib/db/responsesContinuationStore.ts index 3e0f79b7ca..9c55d8edff 100644 --- a/src/lib/db/responsesContinuationStore.ts +++ b/src/lib/db/responsesContinuationStore.ts @@ -129,3 +129,107 @@ export function resolvePreviousResponseState( return { input, output }; } + +/** + * Resolve the call-log id that produced `responseId`, for the dashboard's + * "continues from" link. Reuses the same `call_logs.response_id` index and + * `api_key_id` tenant scoping as `resolvePreviousResponseState` above -- a + * parent link must never point across API keys, even just to surface its id. + * Returns null on any lookup miss so the caller renders no link rather than + * a broken one. + */ +export function resolveCallLogIdByResponseId( + responseId: string, + apiKeyId: string | null | undefined +): string | null { + if (!responseId || !apiKeyId) return null; + + const db = getDbInstance(); + const row = db + .prepare( + `SELECT id FROM call_logs + WHERE response_id = ? AND api_key_id = ? + ORDER BY timestamp DESC LIMIT 1` + ) + .get(responseId, apiKeyId) as { id: string } | undefined; + + return row?.id ?? null; +} + +/** + * Extract `previous_response_id` from a call-log's own pipeline payload. + * Persisted artifacts key the client's own request `clientRawRequest`; the + * pending/in-flight in-memory shape keys the same thing `clientRequest` + * instead (RequestLoggerDetail.tsx's payloadSections list carries both keys + * for the same reason) -- check both so callers get the same answer + * regardless of which shape the payload came back as. + */ +export function extractPreviousResponseId( + pipelinePayloads: Record | null | undefined +): string | null { + if (!pipelinePayloads) return null; + for (const key of ["clientRawRequest", "clientRequest"]) { + const envelope = pipelinePayloads[key]; + const body = isPlainRecord(envelope) && "body" in envelope ? envelope.body : envelope; + if (isPlainRecord(body) && typeof body.previous_response_id === "string") { + return body.previous_response_id; + } + } + return null; +} + +// isGenuineContinuationTurn is a pure function of one call-log's own +// artifact, which is immutable once written (see callLogs.ts -- detailState +// only flips to "ready" after the artifact is fully persisted) -- the same +// artifactRelPath always answers the same way, forever. Without this cache, +// the dashboard's own default auto-refresh polls the whole conversation list +// on an interval the operator controls (down to 1s), so every tick re-reads +// and re-parses one artifact per visible row for an answer that can never +// change once computed. Keyed on artifactRelPath alone (1:1 with the owning +// call-log row, so apiKeyId never varies for a given key) with simple FIFO +// eviction -- correctness never depends on which entries survive, only on +// staying bounded. +const GENUINE_CONTINUATION_CACHE_MAX = 5000; +const genuineContinuationCache = new Map(); + +function cacheGenuineContinuation(key: string, value: boolean): boolean { + genuineContinuationCache.set(key, value); + if (genuineContinuationCache.size > GENUINE_CONTINUATION_CACHE_MAX) { + const oldest = genuineContinuationCache.keys().next().value; + if (oldest !== undefined) genuineContinuationCache.delete(oldest); + } + return value; +} + +/** + * Whether a call-log's own request genuinely continued a prior response + * server-side: it carried `previous_response_id` AND that id resolved to a + * real, same-tenant prior call-log row. Backs the /dashboard/conversations + * "genuine continuation" badge -- a conversation the client-side turn + * tracker counts as multi-turn (conversationTracker.ts's content-hash chain, + * independent of transport) is not necessarily one actually running on the + * `previous_response_id` wire optimization; this checks the transport fact, + * not the content-hash one. + */ +export function isGenuineContinuationTurn( + artifactRelPath: string | null | undefined, + apiKeyId: string | null | undefined +): boolean { + if (!artifactRelPath) return false; + const cached = genuineContinuationCache.get(artifactRelPath); + if (cached !== undefined) return cached; + + const { artifact, state } = readCallArtifact(artifactRelPath); + if (state !== "ready" || !artifact?.pipeline) { + return cacheGenuineContinuation(artifactRelPath, false); + } + const previousResponseId = extractPreviousResponseId( + artifact.pipeline as Record + ); + if (!previousResponseId) return cacheGenuineContinuation(artifactRelPath, false); + + return cacheGenuineContinuation( + artifactRelPath, + resolveCallLogIdByResponseId(previousResponseId, apiKeyId) !== null + ); +} diff --git a/src/shared/components/RequestLoggerDetail.sections.tsx b/src/shared/components/RequestLoggerDetail.sections.tsx index e7a145638d..78527ab495 100644 --- a/src/shared/components/RequestLoggerDetail.sections.tsx +++ b/src/shared/components/RequestLoggerDetail.sections.tsx @@ -259,6 +259,20 @@ export function ConversationContextSection({ log, detail }) { {open ? "expand_less" : "expand_more"} + {liveDetail?.parentLogId && ( + // Full navigation, not client-side routing: the logs page only reads + // ?id from a fresh mount (useState(() => searchParams.get("id")) in + // dashboard/logs/page.tsx), so an in-page route change wouldn't load + // the parent entry if the user is already on this page. + + reply + continues from parent + + )} {open && (