fix: tighten agent trace workbench UI

This commit is contained in:
lyon
2026-06-15 16:44:03 +08:00
parent 6cf061cfcc
commit f24f39bb3e
6 changed files with 320 additions and 80 deletions
+3 -1
View File
@@ -357,7 +357,9 @@ function isSetupTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
return /^session:|^stdio:|^prompt:|^thread:|^turn:started|^turn:start|turn\/start|codex turn|tool:codex-app-server/iu.test(label) ||
/^agentrun:backend:(codex-app-server-starting|initialize:completed|thread\/start|thread\/resume|thread\/started|turn\/start|turn\/started)/iu.test(label) ||
/^agentrun:(run|command|runner-job):(reuse-skipped|created)$/iu.test(label) ||
/^agentrun:run:(reuse-skipped|reused)$/iu.test(label) ||
/^agentrun:command:created$/iu.test(label) ||
/^agentrun:runner-job:(created|ensured|reused)$/iu.test(label) ||
/^agentrun:session-storage-recover-warning$/iu.test(label);
}
@@ -73,6 +73,17 @@ test("R1 shared trace renderer starts at the first user-meaningful event", () =>
assert.doesNotMatch(rows[0]?.header ?? "", /runner-job|resource-bundle/u);
});
test("R1 shared trace renderer suppresses AgentRun reuse diagnostics", () => {
const rows = traceDisplayRows({ traceId: "trc_reuse", status: "running" }, [
{ label: "agentrun:run:reused", status: "reused", message: "run reused" },
{ label: "agentrun:runner-job:ensured", status: "runner-job-ensured", message: "runner ensured" },
{ label: "agentrun:assistant:message", type: "assistant", message: "OK", source: "agentrun", sourceSeq: 3 }
]);
assert.equal(rows.length, 1);
assert.equal(rows[0]?.body, "OK");
assert.doesNotMatch(rows[0]?.header ?? "", /reused|ensured|runner-job/u);
});
test("R1 trace API snapshots stay authoritative over compact result traces", () => {
const previous = {
traceId: "trc_merge",
@@ -69,6 +69,15 @@ function rowKey(row: TraceEventRow, index: number): string {
return `${row.rowId}-${row.seq ?? "no-seq"}-${index}`;
}
function onDisclosureToggle(event: Event): void {
const target = event.currentTarget;
if (target instanceof HTMLDetailsElement) expanded.value = target.open;
}
function rowIsTool(row: TraceEventRow): boolean {
return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header);
}
function traceRecord(trace: RunnerTrace | null | undefined): Record<string, unknown> {
return trace && typeof trace === "object" ? trace as Record<string, unknown> : {};
}
@@ -81,44 +90,61 @@ function recordValue(value: unknown): Record<string, unknown> | null {
<template>
<section v-if="trace" class="trace-timeline" :data-status="trace.status" :data-following="following ? 'true' : 'false'">
<header class="trace-toolbar">
<button class="trace-toggle" type="button" @click="expanded = !expanded">{{ expanded ? "收起" : "展开" }}</button>
<strong>Trace</strong>
</header>
<details class="trace-meta-details">
<summary>{{ detailTitle }}</summary>
<div class="trace-meta-panel">
<dl class="trace-meta-grid">
<div><dt>trace</dt><dd>{{ trace.traceId || "pending" }}</dd></div>
<div><dt>rows</dt><dd>{{ readableRows.length }} / {{ eventCount }}</dd></div>
<div v-if="noiseCount > 0"><dt>hidden</dt><dd>{{ noiseCount }}</dd></div>
<div><dt>last</dt><dd>{{ lastEventLabel }}</dd></div>
</dl>
<div v-if="summaryItems.length > 0" class="trace-summary-strip" aria-label="Trace identity summary">
<span v-for="item in summaryItems" :key="item.label" class="trace-summary-chip"><b>{{ item.label }}</b>{{ item.value }}</span>
</div>
<p v-if="trace.eventsCompacted" class="trace-warning">当前 result 返回被压缩已通过 trace replay 入口请求完整事件完成前不要把压缩窗口当完整 trace</p>
<p v-if="noiseCount > 0 && !rawMode" class="trace-warning">已聚合隐藏 {{ noiseCount }} backend/chunk/token 噪声事件原始 payload 仍可在原始事件中审计</p>
<div class="trace-meta-actions">
<button class="btn btn-secondary btn-sm" type="button" @click="following = !following">{{ following ? "跟随" : "暂停" }}</button>
<button class="btn btn-secondary btn-sm" type="button" @click="jumpToBottom">到底部</button>
<button class="btn btn-secondary btn-sm" type="button" @click="rawMode = !rawMode">{{ rawMode ? "可读视图" : "原始事件" }}</button>
</div>
<details class="trace-disclosure" :open="expanded" @toggle="onDisclosureToggle">
<summary class="trace-disclosure-summary" aria-label="折叠运行记录">
<span class="trace-disclosure-caret" aria-hidden="true" />
<span class="trace-disclosure-count">{{ readableRows.length }}/{{ eventCount }}</span>
<span v-if="noiseCount > 0" class="trace-disclosure-hidden">+{{ noiseCount }}</span>
</summary>
<div class="trace-disclosure-body">
<details class="trace-meta-details">
<summary :aria-label="detailTitle" :title="detailTitle">!</summary>
<div class="trace-meta-panel">
<dl class="trace-meta-grid">
<div><dt>trace</dt><dd>{{ trace.traceId || "pending" }}</dd></div>
<div><dt>rows</dt><dd>{{ readableRows.length }} / {{ eventCount }}</dd></div>
<div v-if="noiseCount > 0"><dt>hidden</dt><dd>{{ noiseCount }}</dd></div>
<div><dt>last</dt><dd>{{ lastEventLabel }}</dd></div>
</dl>
<div v-if="summaryItems.length > 0" class="trace-summary-strip" aria-label="Trace identity summary">
<span v-for="item in summaryItems" :key="item.label" class="trace-summary-chip"><b>{{ item.label }}</b>{{ item.value }}</span>
</div>
<p v-if="trace.eventsCompacted" class="trace-warning">当前 result 返回被压缩已通过 trace replay 入口请求完整事件完成前不要把压缩窗口当完整 trace</p>
<p v-if="noiseCount > 0 && !rawMode" class="trace-warning">已聚合隐藏 {{ noiseCount }} backend/chunk/token 噪声事件原始 payload 仍可在原始事件中审计</p>
<div class="trace-meta-actions">
<button class="btn btn-secondary btn-sm" type="button" @click="following = !following">{{ following ? "跟随" : "暂停" }}</button>
<button class="btn btn-secondary btn-sm" type="button" @click="jumpToBottom">到底部</button>
<button class="btn btn-secondary btn-sm" type="button" @click="rawMode = !rawMode">{{ rawMode ? "可读视图" : "原始事件" }}</button>
</div>
</div>
</details>
<ol ref="listRef" @scroll="onScroll">
<template v-if="!rawMode">
<li v-for="(row, index) in readableRows" :key="rowKey(row, index)" class="trace-render-row" :data-event-status="row.tone" :data-terminal="row.terminal ? 'true' : 'false'" :data-row-kind="rowIsTool(row) ? 'tool' : 'message'">
<details v-if="rowIsTool(row)" class="trace-tool-details">
<summary><code>{{ row.header }}</code></summary>
<MessageMarkdown v-if="row.body && row.bodyFormat === 'markdown'" class="trace-row-body trace-row-markdown" :source="row.body" />
<pre v-else-if="row.body" class="trace-row-body">{{ row.body }}</pre>
<span v-else class="trace-row-body muted">已记录</span>
</details>
<template v-else>
<code>{{ row.header }}</code>
<MessageMarkdown v-if="row.body && row.bodyFormat === 'markdown'" class="trace-row-body trace-row-markdown" :source="row.body" />
<pre v-else-if="row.body" class="trace-row-body">{{ row.body }}</pre>
<span v-else class="trace-row-body muted">已记录</span>
</template>
</li>
</template>
<template v-else>
<li v-for="(event, index) in events" :key="eventKey(event, index)" class="trace-raw-row" :data-event-status="event.status || 'source'">
<code>{{ traceEventLabel(event) }}</code>
<span>{{ traceEventBody(event) }}</span>
<small v-if="traceEventMeta(event)">{{ traceEventMeta(event) }}</small>
</li>
</template>
</ol>
<p v-if="readableRows.length === 0 && !rawMode" class="trace-empty">id={{ trace.traceId || "pending" }}等待后端事件</p>
</div>
</details>
<ol v-show="expanded" ref="listRef" @scroll="onScroll">
<li v-for="(row, index) in readableRows" v-if="!rawMode" :key="rowKey(row, index)" class="trace-render-row" :data-event-status="row.tone" :data-terminal="row.terminal ? 'true' : 'false'">
<code>{{ row.header }}</code>
<MessageMarkdown v-if="row.body && row.bodyFormat === 'markdown'" class="trace-row-body trace-row-markdown" :source="row.body" />
<pre v-else-if="row.body" class="trace-row-body">{{ row.body }}</pre>
<span v-else class="trace-row-body muted">已记录</span>
</li>
<li v-for="(event, index) in events" v-else :key="eventKey(event, index)" class="trace-raw-row" :data-event-status="event.status || 'source'">
<code>{{ traceEventLabel(event) }}</code>
<span>{{ traceEventBody(event) }}</span>
<small v-if="traceEventMeta(event)">{{ traceEventMeta(event) }}</small>
</li>
</ol>
<p v-if="expanded && readableRows.length === 0 && !rawMode" class="trace-empty">trace={{ trace.traceId || "pending" }}等待后端事件</p>
</section>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import type { ChatMessage } from "@/types";
import StatusBadge from "@/components/common/StatusBadge.vue";
import TraceTimeline from "@/components/agent/TraceTimeline.vue";
@@ -13,13 +13,54 @@ import { traceIdentityText } from "./message-rendering";
const workbench = useWorkbenchStore();
const clipboard = useClipboard();
const panelRef = ref<HTMLElement | null>(null);
const following = ref(true);
const detailMessageId = ref<string | null>(null);
const detailMessage = computed(() => workbench.messages.find((message) => message.id === detailMessageId.value) ?? null);
const scrollSignature = computed(() => workbench.messages.map((message) => [
message.id,
message.status,
message.text?.length ?? 0,
message.updatedAt ?? "",
message.runnerTrace?.eventCount ?? message.runnerTrace?.events?.length ?? 0,
message.runnerTrace?.status ?? ""
].join(":")));
watch(scrollSignature, async () => {
if (!following.value) return;
await nextTick();
scrollToBottom();
});
onMounted(async () => {
await nextTick();
scrollToBottom();
});
function openDetails(message: ChatMessage): void {
detailMessageId.value = message.id;
}
function isRunningMessage(message: ChatMessage): boolean {
return message.role === "agent" && String(message.status ?? "").toLowerCase() === "running";
}
function showStatusBadge(message: ChatMessage): boolean {
return message.role !== "agent" || !isRunningMessage(message);
}
function onPanelScroll(): void {
const panel = panelRef.value;
if (!panel) return;
following.value = panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 56;
}
function scrollToBottom(): void {
const panel = panelRef.value;
if (!panel) return;
panel.scrollTop = panel.scrollHeight;
}
function traceDefaultExpanded(message: ChatMessage): boolean {
return !isTerminalStatus(message.runnerTrace?.status ?? message.status);
}
@@ -30,13 +71,14 @@ function traceStorageKey(message: ChatMessage): string {
</script>
<template>
<section class="conversation-panel" id="conversation-list">
<section id="conversation-list" ref="panelRef" class="conversation-panel" :data-following="following ? 'true' : 'false'" @scroll="onPanelScroll">
<article v-for="message in workbench.messages" :key="message.id" class="message-card" :data-role="message.role" :data-status="message.status">
<header v-if="message.role !== 'user'">
<strong>{{ message.title }}</strong>
<div class="message-header-actions">
<StatusBadge :status="message.status" />
<button v-if="message.role === 'agent'" class="message-detail-button" type="button" aria-label="消息详情" title="消息详情" @click="openDetails(message)">i</button>
<span v-if="isRunningMessage(message)" class="agent-running-indicator" aria-label="运行中" role="status"><span /><span /><span /></span>
<StatusBadge v-if="showStatusBadge(message)" :status="message.status" />
<button v-if="message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(message)">!</button>
</div>
</header>
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :default-expanded="traceDefaultExpanded(message)" :storage-key="traceStorageKey(message)" />
+2 -2
View File
@@ -142,7 +142,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const sessionId = composer.value.sessionId;
const threadId = composer.value.threadId;
const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, conversationId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" });
const pending = makeMessage("agent", "正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent 处理中", retryInput: value });
const pending = makeMessage("agent", "", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent", retryInput: value });
messages.value.push(user, pending);
chatPending.value = true;
currentRequest.value = { traceId, conversationId, sessionId, threadId, status: "running" };
@@ -262,7 +262,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function reattachTrace(traceId: string): void {
const initial: AgentChatResponse = { status: "running", traceId, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}` };
if (!messages.value.some((message) => message.traceId === traceId)) messages.value.push(makeMessage("agent", "正在重新连接运行中的 trace。", "running", { traceId, title: "Trace reattach" }));
if (!messages.value.some((message) => message.traceId === traceId)) messages.value.push(makeMessage("agent", "", "running", { traceId, title: "Code Agent" }));
subscribe(traceId, initial);
}
+195 -36
View File
@@ -35,23 +35,23 @@
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: 58px minmax(0, 1fr);
grid-template-rows: 44px minmax(0, 1fr);
}
.platform-topbar {
display: flex;
align-items: center;
gap: 16px;
gap: 10px;
border-bottom: 1px solid #d8e1eb;
background: rgba(248, 250, 252, 0.92);
padding: 0 18px;
padding: 0 12px;
}
.platform-content {
min-height: 0;
min-width: 0;
overflow: auto;
padding: 22px;
padding: 12px;
}
.platform-content > * {
@@ -515,7 +515,7 @@
min-height: 0;
height: 100%;
grid-template-columns: auto minmax(0, 1fr) minmax(280px, 360px);
gap: 14px;
gap: 8px;
}
.workbench-center,
@@ -548,8 +548,8 @@
min-height: 0;
min-width: 0;
align-content: start;
gap: 12px;
padding: 14px;
gap: 8px;
padding: 10px;
overflow: hidden;
}
@@ -580,7 +580,7 @@
.conversation-panel {
display: grid;
min-height: 0;
gap: 10px;
gap: 6px;
overflow: auto;
}
@@ -623,7 +623,8 @@
.conversation-panel {
align-content: start;
padding: 14px;
padding: 8px;
scroll-behavior: smooth;
}
.conversation-empty-hint {
@@ -634,17 +635,36 @@
}
.message-card {
position: relative;
display: grid;
align-self: start;
gap: 6px;
gap: 5px;
border-radius: 4px;
padding: 8px;
overflow: hidden;
}
.message-card[data-role="agent"] {
width: 100%;
}
.message-card[data-role="agent"][data-status="running"] {
border-color: #7dd3fc;
background: linear-gradient(180deg, #ffffff 0%, #f8fdff 100%);
}
.message-card[data-role="agent"][data-status="running"]::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, #0891b2, #22c55e, transparent);
content: "";
transform: translateX(-100%);
animation: agent-run-scan 1.6s ease-in-out infinite;
}
.message-card[data-role="user"] {
justify-self: end;
width: max-content;
@@ -681,9 +701,45 @@
}
.message-detail-button {
border: 0;
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: white;
color: #475569;
cursor: pointer;
padding: 0;
font-size: 12px;
font-weight: 850;
line-height: 1;
}
.agent-running-indicator {
display: inline-flex;
width: 28px;
height: 18px;
align-items: center;
justify-content: center;
gap: 3px;
}
.agent-running-indicator span {
width: 5px;
height: 5px;
border-radius: 999px;
background: #0891b2;
animation: agent-run-dot 900ms ease-in-out infinite;
}
.agent-running-indicator span:nth-child(2) {
animation-delay: 120ms;
}
.agent-running-indicator span:nth-child(3) {
animation-delay: 240ms;
}
.message-text,
@@ -743,37 +799,105 @@
.trace-timeline {
display: grid;
gap: 6px;
gap: 4px;
border: 1px solid #e2e8f0;
border-radius: 3px;
background: #f8fafc;
padding: 6px;
padding: 4px;
}
.trace-toolbar {
justify-content: flex-start;
gap: 6px;
min-height: 26px;
.trace-disclosure {
display: grid;
gap: 5px;
}
.trace-disclosure summary::-webkit-details-marker,
.trace-meta-details summary::-webkit-details-marker,
.trace-tool-details summary::-webkit-details-marker {
display: none;
}
.trace-disclosure-summary {
display: inline-flex;
width: max-content;
min-height: 22px;
align-items: center;
gap: 5px;
border: 1px solid #d8e1eb;
border-radius: 3px;
background: white;
color: #475569;
cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
font-weight: 800;
list-style: none;
padding: 2px 6px;
}
.trace-disclosure-caret {
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid #0f766e;
transition: transform 120ms ease;
}
.trace-disclosure[open] > .trace-disclosure-summary .trace-disclosure-caret {
transform: rotate(90deg);
}
.trace-disclosure-count,
.trace-disclosure-hidden {
line-height: 1;
}
.trace-disclosure-hidden {
color: #92400e;
}
.trace-disclosure-body {
display: grid;
gap: 5px;
}
.trace-meta-details {
border: 1px solid #e2e8f0;
border-radius: 3px;
background: #ffffff;
display: grid;
justify-items: start;
}
.trace-meta-details summary {
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: white;
cursor: pointer;
padding: 5px 7px;
color: #475569;
font-size: 12px;
font-weight: 700;
font-weight: 850;
line-height: 1;
list-style: none;
padding: 0;
}
.trace-meta-details[open] summary {
border-color: #0891b2;
color: #0f766e;
}
.trace-meta-panel {
display: grid;
gap: 6px;
border-top: 1px solid #e2e8f0;
width: 100%;
margin-top: 5px;
border: 1px solid #e2e8f0;
border-radius: 3px;
background: white;
padding: 6px;
}
@@ -840,7 +964,6 @@
text-transform: uppercase;
}
.trace-toolbar strong,
.trace-id {
min-width: 0;
overflow: hidden;
@@ -855,17 +978,6 @@
font-size: 12px;
}
.trace-toggle {
min-height: 24px;
border: 1px solid #cbd5e1;
border-radius: 3px;
background: white;
color: #334155;
padding: 0 7px;
font-size: 12px;
font-weight: 700;
}
.trace-warning,
.trace-empty {
margin: 0;
@@ -897,6 +1009,43 @@
list-style: none;
}
.trace-tool-details {
display: grid;
min-width: 0;
gap: 5px;
}
.trace-tool-details > summary {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
cursor: pointer;
list-style: none;
}
.trace-tool-details > summary::before {
width: 0;
height: 0;
flex: 0 0 auto;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid #64748b;
content: "";
transition: transform 120ms ease;
}
.trace-tool-details[open] > summary::before {
transform: rotate(90deg);
}
.trace-tool-details > summary code {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trace-timeline li {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -1037,6 +1186,16 @@
overflow-wrap: anywhere;
}
@keyframes agent-run-scan {
0% { transform: translateX(-100%); }
55%, 100% { transform: translateX(100%); }
}
@keyframes agent-run-dot {
0%, 80%, 100% { opacity: 0.35; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-3px); }
}
.command-composer {
display: grid;
gap: 10px;
@@ -2104,7 +2263,7 @@
.platform-main {
height: 100dvh;
min-height: 0;
grid-template-rows: 58px minmax(0, 1fr);
grid-template-rows: 44px minmax(0, 1fr);
}
.platform-content {