feat(dashboard): parent-link, genuine-continuation badge, and modal perf fixes (#12448)

Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem.

O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.

Obrigado, @hartmark.
This commit is contained in:
Markus Hartung
2026-09-03 18:02:43 +02:00
committed by GitHub
parent 4866f927ad
commit ffdc736060
7 changed files with 381 additions and 60 deletions

View File

@@ -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 (
<span
title="Latest turn used previous_response_id and it resolved server-side"
className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full text-[9px] font-bold bg-emerald-500/15 text-emerald-500 border border-emerald-500/25"
>
<span className="material-symbols-outlined text-[11px] leading-none">bolt</span>
continuation
</span>
);
}
/**
* 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<number | null>(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)}
</span>
<ContinuationBadge isGenuine={row.isGenuineContinuation} />
</span>
<span className="font-mono text-xs text-text-muted shrink-0">
{row.turnCount} turns
@@ -785,6 +869,7 @@ function ConversationsPageContent() {
<tr className="border-b border-border bg-bg-subtle text-left text-[11px] uppercase tracking-wider text-text-muted">
<th className="px-3 py-2">Conversation</th>
<th className="px-3 py-2 text-right">Turns</th>
<th className="px-3 py-2">Continuation</th>
<th className="px-3 py-2">Last Model</th>
<th className="px-3 py-2">Provider</th>
<th className="px-3 py-2">Status</th>
@@ -816,6 +901,9 @@ function ConversationsPageContent() {
<td className="px-3 py-2 text-right font-mono text-text-main">
{row.turnCount}
</td>
<td className="px-3 py-2">
<ContinuationBadge isGenuine={row.isGenuineContinuation} />
</td>
<td className="px-3 py-2 text-text-main">{row.lastModel ?? "—"}</td>
<td className="px-3 py-2">
<ProviderBadge provider={row.lastProvider} />

View File

@@ -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 });
}
}

View File

@@ -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<string, string>
) {
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<string, string> {
const map = new Map<string, string>();
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<string, string>();
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) {

View File

@@ -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<string, unknown> | 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 });

View File

@@ -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;
}

View File

@@ -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<string, unknown> | 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<string, boolean>();
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<string, unknown>
);
if (!previousResponseId) return cacheGenuineContinuation(artifactRelPath, false);
return cacheGenuineContinuation(
artifactRelPath,
resolveCallLogIdByResponseId(previousResponseId, apiKeyId) !== null
);
}

View File

@@ -259,6 +259,20 @@ export function ConversationContextSection({ log, detail }) {
{open ? "expand_less" : "expand_more"}
</span>
</button>
{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.
<a
href={`/dashboard/logs?id=${encodeURIComponent(liveDetail.parentLogId)}`}
className="flex items-center gap-1 text-[11px] text-text-muted hover:text-primary transition-colors"
title={`Continues from ${liveDetail.parentLogId}`}
>
<span className="material-symbols-outlined text-[14px]">reply</span>
continues from parent
</a>
)}
</div>
{open && (
<div className="flex items-center gap-1">