diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 04be1e62..3a450f86 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -166,7 +166,7 @@ export function App(): ReactElement { /> ) : 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 === "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)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null} {route === "skills" ? : null} {route === "gate" ? : null} {route === "help" ? : null} diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts new file mode 100644 index 00000000..4500b386 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { shouldAutoReplayTrace } from "./ConversationPanel"; +import type { ChatMessage } from "../../types/domain"; + +const baseAgentMessage: ChatMessage = { + id: "msg_agent", + role: "agent", + title: "Code Agent 回复", + text: "done", + status: "idle" as ChatMessage["status"], + createdAt: "2026-06-04T12:13:54.661Z", + traceId: "trc_issue849", + runnerTrace: { + traceId: "trc_issue849", + status: "completed", + eventCount: 49, + eventsCompacted: false, + fullTraceLoaded: false + } +}; + +test("conversation panel auto replays compact historical traces", () => { + assert.equal(shouldAutoReplayTrace(baseAgentMessage), true); +}); + +test("conversation panel does not auto replay loaded, empty, or running traces", () => { + assert.equal(shouldAutoReplayTrace({ ...baseAgentMessage, runnerTrace: { ...baseAgentMessage.runnerTrace, events: [{ seq: 1, label: "assistant:message" }] } }), false); + assert.equal(shouldAutoReplayTrace({ ...baseAgentMessage, runnerTrace: { ...baseAgentMessage.runnerTrace, eventCount: 0 } }), false); + assert.equal(shouldAutoReplayTrace({ ...baseAgentMessage, status: "running" }), false); + assert.equal(shouldAutoReplayTrace({ ...baseAgentMessage, traceReplayStatus: "完整 trace 已回放:49 个原始 event" }), false); + assert.equal(shouldAutoReplayTrace({ ...baseAgentMessage, runnerTrace: { ...baseAgentMessage.runnerTrace, fullTraceLoaded: true } }), false); +}); diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx index 64a8d257..d57b5d64 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from "react"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useScrollFollow } from "../../hooks/useScrollFollow"; import type { ChatMessage, CodeAgentAvailability, LiveSurface } from "../../types/domain"; @@ -20,9 +20,10 @@ 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 }: ConversationPanelProps & { codeAgentTimeoutMs: number }): ReactElement { +export function ConversationPanel({ messages, availability, live, chatPending, codeAgentTimeoutMs, onCopySession, onCancelMessage, onRetryMessage, onReplayTrace }: ConversationPanelProps & { codeAgentTimeoutMs: number }): ReactElement { const scrollFollow = useScrollFollow([messages]); return (
@@ -50,6 +51,7 @@ export function ConversationPanel({ messages, availability, live, chatPending, c codeAgentTimeoutMs={codeAgentTimeoutMs} onCancel={() => onCancelMessage(message.id)} onRetry={() => onRetryMessage(message.id)} + onReplayTrace={() => onReplayTrace(message.id)} /> ))} @@ -98,8 +100,28 @@ function CodeAgentSummary({ availability, live, latestMessage }: { availability: ); } -function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void }): ReactElement { +export function shouldAutoReplayTrace(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + const trace = message.runnerTrace; + const traceId = message.traceId ?? trace?.traceId; + if (!traceId) return false; + if (message.status === "running" || trace?.status === "running") return false; + if (message.traceReplayStatus) return false; + if (trace?.fullTraceLoaded === true) return false; + const events = Array.isArray(trace?.events) ? trace.events : []; + const declaredCount = Number(trace?.eventCount ?? 0); + return events.length === 0 && declaredCount > 0; +} + +function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement { const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`; + const replayRequestedRef = useRef(false); + useEffect(() => { + if (replayRequestedRef.current) return; + if (!shouldAutoReplayTrace(message)) return; + replayRequestedRef.current = true; + onReplayTrace(); + }, [message, onReplayTrace]); if (message.role === "agent") { const hasFinalResponse = message.status !== "running" && message.text.trim().length > 0; return (