diff --git a/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts b/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts new file mode 100644 index 00000000..ecaa7976 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/workbench-trace-lifecycle.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ChatMessage } from "../src/types/index.ts"; +import { traceLifecycleExpanded } from "../src/components/workbench/trace-lifecycle.ts"; + +test("Trace lifecycle expands only active agent turns", () => { + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running" } })), true); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "pending" } })), true); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "completed", runnerTrace: { status: "running" } })), false); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "completed" } })), false); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "running", runnerTrace: { status: "running", finalResponse: "ok" } })), false); + assert.equal(traceLifecycleExpanded(agentMessage({ status: "failed", runnerTrace: { status: "failed" } })), false); + assert.equal(traceLifecycleExpanded({ ...agentMessage({ status: "running" }), role: "user", title: "用户" }), false); +}); + +function agentMessage(patch: Partial): ChatMessage { + return { id: "msg_trace_lifecycle", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-15T00:00:00.000Z", ...patch }; +} diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index f789fe50..93b759d9 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -6,7 +6,7 @@ import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue"; import { traceEventBody, traceEventLabel, traceEventMeta } from "@/components/workbench/message-rendering"; import { traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts"; -const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string }>(); +const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string; autoExpanded?: boolean | null }>(); const listRef = ref(null); const following = ref(true); @@ -40,9 +40,15 @@ watch(() => [eventCount.value, props.trace?.status, following.value, expanded.va }); watch(() => [props.trace?.traceId, props.storageKey, props.defaultExpanded] as const, () => { + if (typeof props.autoExpanded === "boolean") return; expanded.value = readStoredExpanded() ?? props.defaultExpanded ?? false; }, { immediate: true }); +watch(() => [props.trace?.traceId, props.autoExpanded] as const, () => { + if (typeof props.autoExpanded !== "boolean") return; + expanded.value = props.autoExpanded; +}, { immediate: true }); + watch(expanded, (value) => { if (!props.storageKey) return; try { window.localStorage.setItem(props.storageKey, value ? "1" : "0"); } catch { /* ignore */ } diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 99d9764e..daf49fe4 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -9,6 +9,7 @@ import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue"; import { useClipboard } from "@/composables/useClipboard"; import { useWorkbenchStore } from "@/stores/workbench"; import { traceIdentityText } from "./message-rendering"; +import { traceLifecycleExpanded } from "./trace-lifecycle"; const workbench = useWorkbenchStore(); const clipboard = useClipboard(); @@ -60,11 +61,6 @@ function scrollToBottom(): void { panel.scrollTop = panel.scrollHeight; } -function traceDefaultExpanded(message: ChatMessage): boolean { - void message; - return false; -} - function traceStorageKey(message: ChatMessage): string { return `hwlab.workbench.trace-open.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`; } @@ -81,7 +77,7 @@ function traceStorageKey(message: ChatMessage): string { - +
发起对话,或从左侧选择 session。
diff --git a/web/hwlab-cloud-web/src/components/workbench/trace-lifecycle.ts b/web/hwlab-cloud-web/src/components/workbench/trace-lifecycle.ts new file mode 100644 index 00000000..b0df7218 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/workbench/trace-lifecycle.ts @@ -0,0 +1,40 @@ +import type { ChatMessage } from "@/types"; + +export function traceLifecycleExpanded(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + if (hasTraceTerminalSignal(message)) return false; + return traceStatusValues(message).some(isTraceActiveStatus); +} + +function hasTraceTerminalSignal(message: ChatMessage): boolean { + if (hasFinalResponseValue(message.finalResponse) || hasFinalResponseValue(message.runnerTrace?.finalResponse)) return true; + return traceStatusValues(message).some(isTraceTerminalStatus); +} + +function traceStatusValues(message: ChatMessage): string[] { + return [ + message.status, + message.runnerTrace?.status, + message.runnerTrace?.traceStatus + ].map(normalizeTraceStatus).filter((value): value is string => Boolean(value)); +} + +function normalizeTraceStatus(value: unknown): string | null { + const text = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"); + return text || null; +} + +function isTraceActiveStatus(status: string): boolean { + return ["accepted", "pending", "processing", "running", "busy", "creating", "queued"].includes(status); +} + +function isTraceTerminalStatus(status: string): boolean { + return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "aborted", "error", "done"].includes(status); +} + +function hasFinalResponseValue(value: unknown): boolean { + if (value == null) return false; + if (typeof value === "string") return value.trim().length > 0; + if (typeof value === "object") return Object.keys(value as Record).length > 0; + return true; +}