Merge pull request #2258 from pikasTech/fix/2255-workbench-render-budget

fix: bound workbench trace rendering
This commit is contained in:
Lyon
2026-06-29 23:29:23 +08:00
committed by GitHub
5 changed files with 62 additions and 15 deletions
@@ -9,14 +9,22 @@ import { traceDisplayRows, type TraceEventRow } from "../../../../../tools/src/h
const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string; autoExpanded?: boolean | null }>();
const TRACE_RENDER_ROW_WINDOW = 120;
const listRef = ref<HTMLOListElement | null>(null);
const expanded = ref(false);
const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useBottomFollowScroll(listRef, { threshold: 24 });
const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
const rawReadableRows = computed(() => traceDisplayRows(traceRecord(props.trace), events.value as Record<string, unknown>[], { formatClock: formatDisplayClock }));
const rawReadableRows = computed(() => expanded.value ? traceDisplayRows(traceRecord(props.trace), events.value as Record<string, unknown>[], { formatClock: formatDisplayClock }) : []);
const readableRows = computed(() => rawReadableRows.value);
const hiddenRowCount = computed(() => Math.max(0, readableRows.value.length - TRACE_RENDER_ROW_WINDOW));
const renderedRows = computed(() => hiddenRowCount.value > 0 ? readableRows.value.slice(-TRACE_RENDER_ROW_WINDOW) : readableRows.value);
const traceSummaryLabel = computed(() => {
const count = eventCount.value;
if (count <= 0) return String(props.trace?.status ?? "").trim() || "pending";
return `${count} events`;
});
const projectionDiagnosticLabel = computed(() => {
const projection = props.trace?.projection ?? null;
const health = projection?.projectionHealth ?? props.trace?.projectionHealth;
@@ -128,14 +136,16 @@ function firstNonEmptyString(...values: unknown[]): string | null {
<template>
<section v-if="trace" class="trace-timeline" :data-trace-id="trace.traceId || undefined" :data-status="trace.status" :data-projection-health="trace.projection?.projectionHealth || trace.projectionHealth || undefined" :data-following="following ? 'true' : 'false'">
<details class="trace-disclosure" :open="expanded" @toggle="onDisclosureToggle">
<details class="trace-disclosure" :open="expanded" :data-event-count="eventCount" :data-readable-row-count="readableRows.length" :data-rendered-row-count="renderedRows.length" :data-row-windowed="hiddenRowCount > 0 ? 'true' : 'false'" @toggle="onDisclosureToggle">
<summary class="trace-disclosure-summary" aria-label="折叠运行记录">
<span class="trace-disclosure-caret" aria-hidden="true" />
<span class="trace-disclosure-label">运行记录</span>
<span class="trace-disclosure-count">{{ traceSummaryLabel }}</span>
</summary>
<div class="trace-disclosure-body">
<ol ref="listRef" @scroll="onScroll">
<li v-for="(row, index) in readableRows" :key="rowKey(row, index)" class="trace-render-row" data-testid="trace-render-row" :data-trace-id="trace.traceId || undefined" :data-projected-seq="row.seq ?? undefined" :data-event-kind="row.rowId" :data-event-status="row.tone" :data-terminal="row.terminal ? 'true' : 'false'" :data-row-kind="rowIsTool(row) ? 'tool' : 'message'" :data-row-id="row.rowId" :aria-posinset="row.seq ?? index + 1">
<div v-if="expanded" class="trace-disclosure-body">
<p v-if="hiddenRowCount > 0" class="trace-window-notice">显示最近 {{ renderedRows.length }} / {{ readableRows.length }} 行</p>
<ol v-if="renderedRows.length > 0" ref="listRef" @scroll="onScroll">
<li v-for="(row, index) in renderedRows" :key="rowKey(row, index)" class="trace-render-row" data-testid="trace-render-row" :data-trace-id="trace.traceId || undefined" :data-projected-seq="row.seq ?? undefined" :data-event-kind="row.rowId" :data-event-status="row.tone" :data-terminal="row.terminal ? 'true' : 'false'" :data-row-kind="rowIsTool(row) ? 'tool' : 'message'" :data-row-id="row.rowId" :aria-posinset="row.seq ?? hiddenRowCount + index + 1">
<details v-if="rowIsTool(row)" class="trace-tool-details">
<summary>
<code>{{ row.header }}</code>
@@ -34,10 +34,8 @@ const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy());
const scrollSignature = computed(() => workbench.activeMessages.map((message) => [
message.id,
message.status,
visibleMessageText(message).length,
message.updatedAt ?? "",
message.runnerTrace?.eventCount ?? message.runnerTrace?.events?.length ?? 0
].join(":")));
visibleMessageText(message).length
].join(":")).join("|"));
watch(scrollSignature, async () => {
await keepBottomAfterUpdate();
@@ -5,14 +5,34 @@ import { firstNonEmptyString } from "@/utils";
marked.setOptions({ async: false, breaks: true, gfm: true });
const MARKDOWN_RENDER_CACHE_LIMIT = 160;
const markdownRenderCache = new Map<string, string>();
export function renderSafeMarkdown(source: string | null | undefined): string {
const text = source?.trim();
if (!text) return "";
const cached = markdownRenderCache.get(text);
if (cached !== undefined) {
markdownRenderCache.delete(text);
markdownRenderCache.set(text, cached);
return cached;
}
const rendered = marked.parse(text) as string;
return sanitizeHtml(rendered, {
const html = sanitizeHtml(rendered, {
ADD_ATTR: ["target", "rel"],
USE_PROFILES: { html: true }
});
rememberMarkdownRender(text, html);
return html;
}
function rememberMarkdownRender(source: string, html: string): void {
markdownRenderCache.set(source, html);
while (markdownRenderCache.size > MARKDOWN_RENDER_CACHE_LIMIT) {
const oldest = markdownRenderCache.keys().next().value;
if (typeof oldest !== "string") break;
markdownRenderCache.delete(oldest);
}
}
interface HtmlSanitizeConfig {
+5 -5
View File
@@ -15,13 +15,13 @@ import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMes
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
const TRACE_HYDRATION_PAGE_LIMIT = 100;
const TRACE_HYDRATION_MAX_PAGES = 60;
const TRACE_HYDRATION_PAGE_LIMIT = 50;
const TRACE_HYDRATION_MAX_PAGES = 4;
const TRACE_HYDRATION_MAX_ATTEMPTS = 3;
const TRACE_HYDRATION_RETRY_DELAY_MS = 700;
const TRACE_HYDRATION_AUTO_QUEUE_LIMIT = 12;
const TRACE_HYDRATION_BACKGROUND_CONCURRENCY = 2;
const TRACE_HYDRATION_BACKGROUND_DELAY_MS = 150;
const TRACE_HYDRATION_AUTO_QUEUE_LIMIT = 4;
const TRACE_HYDRATION_BACKGROUND_CONCURRENCY = 1;
const TRACE_HYDRATION_BACKGROUND_DELAY_MS = 400;
const SESSION_LIST_PAGE_LIMIT = 20;
const WORKBENCH_READ_HYDRATION_CONCURRENCY = 3;
const WORKBENCH_READ_FAILURE_COOLDOWN_MS = 5_000;
@@ -1391,6 +1391,13 @@
line-height: 1;
}
.trace-disclosure-count {
color: #64748b;
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.trace-disclosure-body {
display: grid;
gap: 5px;
@@ -1535,6 +1542,18 @@
color: #92400e;
}
.trace-window-notice {
margin: 0;
border: 1px solid #d8e1eb;
border-radius: 3px;
background: white;
color: #64748b;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
padding: 5px 7px;
}
.trace-empty {
border: 1px solid #d8e1eb;
background: white;