From 7a58695318fc97f0d80249e45226c4e42416a257 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 4 Jun 2026 17:08:10 +0800 Subject: [PATCH] fix(v02): unify web trace final response --- tools/src/hwlab-cli/trace-renderer.ts | 22 ++- web/hwlab-cloud-web/src/App.tsx | 27 +++- .../conversation/ConversationPanel.tsx | 19 ++- .../conversation/MessageActions.tsx | 6 +- .../conversation/MessageTracePanel.tsx | 76 ++------- .../src/state/runner-trace.test.ts | 145 ++++++++++++++++++ web/hwlab-cloud-web/src/state/runner-trace.ts | 69 ++++++++- .../src/state/trace-renderer.test.ts | 22 +++ web/hwlab-cloud-web/src/styles/workbench.css | 22 +-- 9 files changed, 313 insertions(+), 95 deletions(-) create mode 100644 web/hwlab-cloud-web/src/state/runner-trace.test.ts create mode 100644 web/hwlab-cloud-web/src/state/trace-renderer.test.ts diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index 75f9afd7..0fb8cdd2 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -15,6 +15,7 @@ export function traceDisplayRows(trace: Record = {}, events: Tr let requestRendered = false; let setupRendered = false; let completionRendered = false; + let pendingAssistantEvent: TraceEvent | null = null; for (const event of events) { if (!event || isNoisyTraceEvent(event)) continue; if (isRequestTraceEvent(event)) { @@ -32,19 +33,23 @@ export function traceDisplayRows(trace: Record = {}, events: Tr continue; } if (isTerminalAssistantTraceEvent(event)) { - rows.push(traceAssistantSummaryRow(trace, event)); - completionRendered = true; + pendingAssistantEvent = event; + continue; + } + if (isAssistantTraceEvent(event)) { + if (!completionRendered) pendingAssistantEvent = event; continue; } if (isCompletionTraceEvent(event)) { if (!completionRendered) { - rows.push(traceCompletionSummaryRow(trace, event)); + rows.push(pendingAssistantEvent ? traceAssistantSummaryRow(trace, pendingAssistantEvent) : traceCompletionSummaryRow(trace, event)); completionRendered = true; } continue; } rows.push(traceDisplayRow(trace, event)); } + if (!completionRendered && pendingAssistantEvent) rows.push(traceAssistantSummaryRow(trace, pendingAssistantEvent)); return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event)); } @@ -133,19 +138,24 @@ function isSetupTraceEvent(event: TraceEvent): boolean { function isCompletionTraceEvent(event: TraceEvent): boolean { const label = String(event.label ?? ""); - return label.startsWith("turn:completed") || label.startsWith("result:completed") || label === "agentrun:backend:turn/completed" || label === "agentrun:result:completed"; + return label.startsWith("turn:completed") || label.startsWith("result:completed") || label === "agentrun:backend:turn/completed" || label === "agentrun:terminal:completed" || label === "agentrun:result:completed"; +} + +function isAssistantTraceEvent(event: TraceEvent): boolean { + const label = String(event.label ?? ""); + return label === "agentrun:assistant:message" || label === "assistant:message" || event.type === "assistant_message" || event.type === "assistant"; } function isTerminalAssistantTraceEvent(event: TraceEvent): boolean { const label = String(event.label ?? ""); - if (event.terminal === true || label === "assistant:completed") return true; + if (label === "assistant:completed") return true; if (label === "agentrun:assistant:message") return event.replyAuthority === true || event.final === true; return event.type === "assistant_message" && (event.status === "completed" || event.final === true); } function isNoisyTraceEvent(event: TraceEvent): boolean { const label = String(event.label ?? ""); - if (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isCompletionTraceEvent(event) || isTerminalAssistantTraceEvent(event)) return false; + if (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isCompletionTraceEvent(event) || isTerminalAssistantTraceEvent(event) || isAssistantTraceEvent(event)) return false; if (/token_count|outputDelta:chunk/iu.test(label)) return true; if (/^agentrun:backend:(run-created|command-created|runner-job-created|thread\/status\/changed|thread\/tokenUsage\/updated|account\/rateLimits\/updated|remoteControl\/status\/changed|configWarning|codex-app-server-closed|session-updated|command-terminal|item\/agentMessage:(started|completed)|thread\/goal\/cleared)$/u.test(label)) return true; return event.type === "event" && !event.outputSummary && !event.message && !event.errorCode; diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 02bb2474..d864670b 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -17,6 +17,7 @@ import { SettingsView } from "./components/settings/SettingsView"; import { SkillsView } from "./components/skills/SkillsView"; import { GateView } from "./components/gate/GateView"; import { fetchText } from "./services/api/client"; +import { firstNonEmptyString } from "./utils"; const SESSION_SIDEBAR_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1"; const SESSION_SIDEBAR_DEFAULT_WIDTH = 330; @@ -157,7 +158,7 @@ export function App(): ReactElement { /> ) : null}
- {route === "workspace" ? void navigator.clipboard?.writeText(store.activeConversationId ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null} + {route === "workspace" ? copyTextToClipboard(firstNonEmptyString(store.composer.sessionId, store.state.workspace?.selectedAgentSessionId, store.state.workspace?.workspace?.selectedAgentSessionId, store.activeConversationId) ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} /> : null} {route === "skills" ? : null} {route === "gate" ? : null} {route === "help" ? : null} @@ -194,6 +195,30 @@ export function App(): ReactElement { ); } +function copyTextToClipboard(value: string): void { + if (!value) return; + const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : null; + if (clipboard?.writeText) { + void clipboard.writeText(value).catch(() => fallbackCopyText(value)); + return; + } + fallbackCopyText(value); +} + +function fallbackCopyText(value: string): void { + if (typeof document === "undefined") return; + const node = document.createElement("textarea"); + node.value = value; + node.setAttribute("readonly", ""); + node.style.position = "fixed"; + node.style.left = "-9999px"; + node.style.top = "0"; + document.body.appendChild(node); + node.select(); + try { document.execCommand("copy"); } catch { /* ignore clipboard fallback failures */ } + node.remove(); +} + function StatePill({ tone, children }: { tone: "pending" | "source"; children: string }): ReactElement { return {children}; } diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx index d0d558cf..87264228 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx @@ -19,10 +19,9 @@ interface ConversationPanelProps { onCopySession(): void; onCancelMessage(messageId: string): void; onRetryMessage(messageId: string): void; - onReplayTrace(messageId: string): void; } -export function ConversationPanel({ messages, availability, live, chatPending, codeAgentTimeoutMs, onCopySession, onCancelMessage, onRetryMessage, onReplayTrace }: ConversationPanelProps & { codeAgentTimeoutMs: number }): ReactElement { +export function ConversationPanel({ messages, availability, live, chatPending, codeAgentTimeoutMs, onCopySession, onCancelMessage, onRetryMessage }: ConversationPanelProps & { codeAgentTimeoutMs: number }): ReactElement { return (
@@ -31,10 +30,9 @@ export function ConversationPanel({ messages, availability, live, chatPending, c

Agent 工作区

Agent 对话

-

这里是用户工作台:可以整理任务、查看 Device Pod summary 和纯文本事件流。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。

- + {chatPending ? "处理中" : "等待输入"}
@@ -50,7 +48,6 @@ export function ConversationPanel({ messages, availability, live, chatPending, c codeAgentTimeoutMs={codeAgentTimeoutMs} onCancel={() => onCancelMessage(message.id)} onRetry={() => onRetryMessage(message.id)} - onReplayTrace={() => onReplayTrace(message.id)} /> ))}
@@ -72,6 +69,10 @@ function CodeAgentSummary({ availability, live, latestMessage }: { availability: const summary = useMemo(() => classifyCodeAgentStatusSummary({ availability, live, latestMessage }), [availability, live, latestMessage]); const rows = useMemo(() => codeAgentSummaryRows(summary).map((row) => ({ label: row.key, value: row.value, tone: row.tone as DebugRow["tone"] })), [summary]); const sections: DebugSection[] = [ + { title: "工作区", rows: [ + { label: "webEntry", value: "http://74.48.78.17:19666/" }, + { label: "traceRenderer", value: "hwlab-cli client agent trace --render web" } + ] }, { title: "Code Agent 详情", rows }, { title: "readiness blockers", rows: summary.readinessBlockers.length > 0 ? summary.readinessBlockers.map((blocker) => ({ label: blocker, value: blocker, tone: "blocked" as const })) @@ -95,7 +96,7 @@ function CodeAgentSummary({ availability, live, latestMessage }: { availability: ); } -function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement { +function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void }): ReactElement { const html = renderMessageMarkdown(message.text); const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`; if (message.role === "agent") { @@ -111,7 +112,6 @@ function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayT codeAgentTimeoutMs={codeAgentTimeoutMs} onCancel={onCancel} onRetry={onRetry} - onReplayTrace={onReplayTrace} /> ); @@ -124,13 +124,12 @@ function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayT codeAgentTimeoutMs={codeAgentTimeoutMs} onCancel={onCancel} onRetry={onRetry} - onReplayTrace={onReplayTrace} /> ); } -function MessageDebug({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement | null { +function MessageDebug({ message, codeAgentTimeoutMs, onCancel, onRetry }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void }): ReactElement | null { if (message.role === "user" || message.role === "system") { if (!message.traceId) return null; return ( @@ -151,7 +150,7 @@ function MessageDebug({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplay