From 2129cebfeff104fb27ab8dbd65dc708f038f2308 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:56:43 +0800 Subject: [PATCH] fix(v02): improve trace and sidebar web ui (#854) Co-authored-by: Codex Agent --- tools/hwlab-cli/client.test.ts | 50 ++++++++++++++- tools/src/hwlab-cli-lib.ts | 64 ++++++++++++++++++- tools/src/hwlab-cli/trace-renderer.ts | 41 ++++++++---- web/hwlab-cloud-web/scripts/check.ts | 5 ++ web/hwlab-cloud-web/src/App.tsx | 6 +- .../conversation/MessageTracePanel.tsx | 26 ++++++-- .../shared/MessageMarkdown.test.tsx | 7 ++ .../src/state/trace-renderer.test.ts | 24 +++++++ web/hwlab-cloud-web/src/styles/workbench.css | 39 ++++++----- 9 files changed, 219 insertions(+), 43 deletions(-) diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index a4958a82..3918afd7 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -326,6 +326,49 @@ test("hwlab-cli client session final-response preserves inspect auth failure vis assert.equal(result.payload.error?.code, undefined); }); +test("hwlab-cli client agent inspect can resolve a session id through the conversation list", async () => { + const calls: string[] = []; + const conversation = { + conversationId: "cnv_issue849", + sessionId: "ses_issue849", + threadId: "thread_issue849", + status: "idle", + lastTraceId: "trc_issue849", + messages: [{ role: "agent", traceId: "trc_issue849", text: "final response" }] + }; + const fetchImpl = async (url: string | URL) => { + calls.push(String(url)); + if (String(url).endsWith("/v1/agent/chat/inspect?sessionId=ses_issue849")) { + return new Response(JSON.stringify({ ok: false, status: "not_found", session: null }), { status: 404 }); + } + if (String(url).endsWith("/v1/agent/conversations?projectId=prj_device_pod_workbench&limit=100")) { + return new Response(JSON.stringify({ ok: true, conversations: [conversation] }), { status: 200 }); + } + if (String(url).endsWith("/v1/agent/conversations/cnv_issue849")) { + return new Response(JSON.stringify({ ok: true, status: "found", conversation }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_route" } }), { status: 404 }); + }; + + const result = await runHwlabCli(["client", "agent", "inspect", "--session-id", "ses_issue849", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { fetchImpl }); + + assert.equal(result.exitCode, 0); + assert.equal(result.payload.action, "client.agent.inspect"); + assert.equal(result.payload.fallbackLookup.attempted, true); + assert.equal(result.payload.fallbackLookup.source, "client.session.list"); + assert.equal(result.payload.fallbackLookup.conversationId, "cnv_issue849"); + assert.equal(result.payload.fallbackLookup.traceId, "trc_issue849"); + assert.equal(result.payload.route.path, "/v1/agent/conversations/cnv_issue849"); + assert.equal(result.payload.conversation.conversationId, "cnv_issue849"); + assert.equal(result.payload.conversation.sessionId, "ses_issue849"); + assert.equal(result.payload.body.status, "found"); + assert.deepEqual(calls, [ + "http://web.test/v1/agent/chat/inspect?sessionId=ses_issue849", + "http://web.test/v1/agent/conversations?projectId=prj_device_pod_workbench&limit=100", + "http://web.test/v1/agent/conversations/cnv_issue849" + ]); +}); + test("hwlab-cli rejects manual endpoint override in locked assembled runtime", async () => { const result = await runHwlabCli(["client", "auth", "status", "--base-url", "http://74.48.78.17:17666"], { env: { @@ -1492,7 +1535,7 @@ test("hwlab-cli client agent trace can render with the Web trace row path", asyn assert.equal(result.payload.body.renderer, "tools/src/hwlab-cli/trace-renderer:traceDisplayRows"); assert.equal(result.payload.body.sourceEventCount, 3); assert.ok(result.payload.body.renderedRowCount >= 1); - assert.ok(result.payload.body.rows.some((row: any) => /助手最后一条消息/u.test(row.header))); + assert.ok(result.payload.body.rows.some((row: any) => /助手最终消息/u.test(row.header))); assert.equal(JSON.stringify(result.payload.body.rows).includes("session:reused"), false); assert.equal(result.payload.traceStatus, "completed"); assert.equal(result.payload.rendered.traceStatus, "completed"); @@ -1584,7 +1627,8 @@ test("hwlab-cli Web trace render does not treat AgentRun result-ready as final a assert.equal(result.exitCode, 0); const text = JSON.stringify(result.payload.body.rows); - assert.equal(text.includes("助手最后一条消息"), false); + assert.equal(text.includes("助手最终消息"), false); + assert.equal(text.includes("助手消息"), false); assert.match(text, /轮次完成/u); assert.equal(text.includes("AgentRun result is ready for HWLAB short-connection polling"), false); }); @@ -1638,7 +1682,7 @@ test("hwlab-cli Web trace render keeps AgentRun middle assistant message", async const text = JSON.stringify(result.payload.body.rows); assert.match(text, /我先检查当前 device-pod 列表/u); assert.match(text, /当前有 2 个 device-pod 可用/u); - assert.match(text, /助手最后一条消息/u); + assert.match(text, /助手最终消息/u); }); test("hwlab-cli Web trace render keeps the full final-response body without truncating long agent summaries", async () => { diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index d1d5dbf4..791d3a89 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -633,9 +633,7 @@ async function agentCommand(context: any) { return responsePayload("client.agent.trace", response, context, { route: route("GET", pathName), traceId, body: traceBody, ...traceResponseAliases(traceBody, context.parsed) }); } if (subcommand === "inspect") { - const pathName = agentInspectPath(context); - const response = await requestJson({ ...context, method: "GET", path: pathName }); - return responsePayload("client.agent.inspect", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) }); + return agentInspect(context); } if (subcommand === "steer") return agentSteer(context); if (subcommand === "cancel") { @@ -646,6 +644,58 @@ async function agentCommand(context: any) { throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand }); } +async function agentInspect(context: any) { + const pathName = agentInspectPath(context); + const response = await requestJson({ ...context, method: "GET", path: pathName }); + if (responseSucceeded(response) || !shouldFallbackInspectBySession(context, response)) { + return responsePayload("client.agent.inspect", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) }); + } + + const fallback = await inspectConversationBySessionId(context, requiredSessionId(context.parsed.sessionId)); + if (!fallback) { + return responsePayload("client.agent.inspect", response, context, { + route: route("GET", pathName), + fallbackLookup: { attempted: true, source: "client.session.list", found: false }, + body: responseBodyForCli(response.body, context.parsed) + }); + } + return fallback; +} + +function shouldFallbackInspectBySession(context: any, response: any): boolean { + if (!text(context.parsed.sessionId)) return false; + if (text(context.parsed.traceId) || text(context.parsed.conversationId) || text(context.parsed.threadId)) return false; + return response.status === 404 || response.body?.status === "not_found" || response.body?.ok === false; +} + +async function inspectConversationBySessionId(context: any, sessionId: string) { + const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID; + const limit = numberOption(context.parsed.limit) ?? 100; + const listPath = `/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}&limit=${encodeURIComponent(String(limit))}`; + const listResponse = await requestJson({ ...context, method: "GET", path: listPath }); + if (!responseSucceeded(listResponse)) return null; + const match = Array.isArray(listResponse.body?.conversations) + ? listResponse.body.conversations.find((item: any) => text(item?.sessionId ?? item?.session?.sessionId) === sessionId) ?? null + : null; + const conversationId = text(match?.conversationId); + if (!conversationId) return null; + const inspectPath = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`; + const inspectResponse = await requestJson({ ...context, method: "GET", path: inspectPath, extraHeaders: text(match?.lastTraceId) ? { "x-trace-id": text(match.lastTraceId) } : undefined }); + return responsePayload("client.agent.inspect", inspectResponse, context, { + route: route("GET", inspectPath), + conversation: sessionSummary(inspectResponse.body?.conversation ?? match), + fallbackLookup: { + attempted: true, + source: "client.session.list", + listRoute: route("GET", listPath), + matchedSessionId: sessionId, + conversationId, + traceId: text(match?.lastTraceId) + }, + body: responseBodyForCli(inspectResponse.body, context.parsed) + }); +} + function agentHelp() { return ok("client.agent.help", { serviceRuntime: false, @@ -3070,6 +3120,14 @@ function requiredConversationId(value: unknown) { return result; } +function requiredSessionId(value: unknown) { + const result = requiredText(value, "sessionId"); + if (!/^ses_[A-Za-z0-9_.:-]+$/u.test(result)) { + throw cliError("invalid_session_id", "sessionId must start with ses_", { sessionId: result }); + } + return result; +} + function safeOptionalConversationId(value: unknown) { const result = text(value); if (!result) return ""; diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index f57f833c..2c06ff6d 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -14,8 +14,8 @@ export function traceDisplayRows(trace: Record = {}, events: Tr const rows: TraceEventRow[] = []; let requestRendered = false; let setupRendered = false; - let completionRendered = false; - let pendingAssistantEvent: TraceEvent | null = null; + let lastAssistantRowIndex = -1; + let completionEvent: TraceEvent | null = null; for (const event of events) { if (!event || isNoisyTraceEvent(event)) continue; if (isRequestTraceEvent(event)) { @@ -33,24 +33,26 @@ export function traceDisplayRows(trace: Record = {}, events: Tr continue; } if (isTerminalAssistantTraceEvent(event)) { - if (pendingAssistantEvent && pendingAssistantEvent !== event) rows.push(traceDisplayRow(trace, pendingAssistantEvent)); - pendingAssistantEvent = event; + rows.push(traceAssistantMessageRow(trace, event, { terminal: true })); + lastAssistantRowIndex = rows.length - 1; continue; } if (isAssistantTraceEvent(event)) { - if (!completionRendered) pendingAssistantEvent = event; + rows.push(traceAssistantMessageRow(trace, event, { terminal: false })); + lastAssistantRowIndex = rows.length - 1; continue; } if (isCompletionTraceEvent(event)) { - if (!completionRendered) { - rows.push(pendingAssistantEvent ? traceAssistantSummaryRow(trace, pendingAssistantEvent) : traceCompletionSummaryRow(trace, event)); - completionRendered = true; - } + completionEvent = event; continue; } rows.push(traceDisplayRow(trace, event)); } - if (!completionRendered && pendingAssistantEvent) rows.push(traceAssistantSummaryRow(trace, pendingAssistantEvent)); + if (completionEvent) { + const lastAssistantRow = lastAssistantRowIndex >= 0 ? rows[lastAssistantRowIndex] : undefined; + if (lastAssistantRow) rows[lastAssistantRowIndex] = markAssistantRowTerminal(trace, lastAssistantRow, completionEvent); + else rows.push(traceCompletionSummaryRow(trace, completionEvent)); + } return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event)); } @@ -85,19 +87,30 @@ function traceSetupSummaryRow(event: TraceEvent): TraceEventRow { }; } -function traceAssistantSummaryRow(trace: Record, event: TraceEvent): TraceEventRow { +function traceAssistantMessageRow(trace: Record, event: TraceEvent, { terminal }: { terminal: boolean }): TraceEventRow { const text = cleanTraceText(event.message ?? event.outputSummary ?? assistantStreamText(trace, event) ?? ""); + const index = Number.isInteger(event.messageIndex) ? Number(event.messageIndex) : null; + const count = Number.isInteger(event.messageCount) ? Number(event.messageCount) : null; return { rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`, seq: numberOrNull(event.seq), tone: "ok", - header: `${traceClock(event.createdAt)} 助手最后一条消息,轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))})`, - terminal: true, + header: `${traceClock(event.createdAt)} ${terminal ? "助手最终消息" : "助手消息"}${index ? ` ${index}${count ? `/${count}` : ""}` : ""}`, + terminal: terminal ? true : undefined, body: text || null, bodyFormat: "markdown" }; } +function markAssistantRowTerminal(trace: Record, row: TraceEventRow, completionEvent: TraceEvent): TraceEventRow { + return { + ...row, + tone: "ok", + terminal: true, + header: `${row.header},轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, completionEvent))})` + }; +} + function traceCompletionSummaryRow(trace: Record, event: TraceEvent): TraceEventRow { return { rowId: `trace-completion:${event.seq ?? "turn"}`, @@ -158,7 +171,7 @@ function isNoisyTraceEvent(event: TraceEvent): boolean { const label = String(event.label ?? ""); 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; + 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|codex-app-server-notifications-suppressed|session-updated|command-terminal|backend-turn-finished|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/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 9f62ddf2..d310bc01 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -82,7 +82,12 @@ for (const directory of ["components/layout", "components/auth", "components/ses assert.match(css, /@media \(max-width: 720px\)/u, "mobile layout contract must be explicit"); assert.match(css, /grid-template-columns:\s*56px\s+minmax\(0,\s*1fr\)/u, "390px mobile layout must not keep three squeezed columns"); assert.match(css, /\.device-event-scroll\s*\{[\s\S]*?overscroll-behavior:\s*contain;/u, "event stream scroll must be contained"); +assert.match(css, /\.device-event-panel\s*\{[\s\S]*?max-height:\s*min\(360px,\s*38dvh\);/u, "event stream panel must have a bounded own scroll area"); assert.match(css, /\.session-tabs\s*\{[\s\S]*?overflow-x:\s*hidden;/u, "session sidebar must not horizontally scroll"); +assert.match(css, /\.message-body p\s*\{\s*white-space:\s*pre-wrap;\s*\}/u, "final response markdown paragraphs must preserve soft line breaks"); +assert.match(css, /@keyframes trace-update-pulse/u, "trace updates must have an explicit visual pulse"); +assert.doesNotMatch(appSource, /message-trace-storage-key/u, "trace summary must not expose raw trace ids as visible chips"); +assert.doesNotMatch(appSource, /last ·/u, "trace summary must not show raw last-event labels"); const cloudApiServer = readRepo("internal/cloud/server.ts"); const deployJson = readRepo("deploy/deploy.json"); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index b718d56f..04be1e62 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -26,9 +26,9 @@ const SESSION_SIDEBAR_DEFAULT_WIDTH = 330; const SESSION_SIDEBAR_MIN_WIDTH = 180; const SESSION_SIDEBAR_MAX_WIDTH = 640; const RIGHT_SIDEBAR_STORAGE_KEY = "hwlab.workbench.rightSidebarWidth.v1"; -const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728; -const RIGHT_SIDEBAR_MIN_WIDTH = 560; -const RIGHT_SIDEBAR_MAX_WIDTH = 900; +const RIGHT_SIDEBAR_DEFAULT_WIDTH = 620; +const RIGHT_SIDEBAR_MIN_WIDTH = 420; +const RIGHT_SIDEBAR_MAX_WIDTH = 680; const routeLabels: Record = { workspace: "工作台", diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx index 786acd2b..c3dc7019 100644 --- a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import type { RunnerTrace, TraceEvent } from "../../types/domain"; -import { formatBeijingTime, formatDuration, shortToken, toneClass } from "../../utils"; +import { formatBeijingTime, formatDuration, toneClass } from "../../utils"; import { summarizeLastEvent } from "../../state/code-agent-status"; import { traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer"; @@ -16,7 +16,9 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra const traceRunning = isRunningTrace(trace); const storedOpen = readStoredOpen(storageKey, defaultOpen ?? traceRunning); const [open, setOpen] = useState(storedOpen); + const [pulseKey, setPulseKey] = useState(0); const listRef = useRef(null); + const updateKey = traceUpdateKey(trace); useEffect(() => { if (traceRunning) { @@ -38,18 +40,23 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra } }, [trace, traceRunning, open]); + useEffect(() => { + setPulseKey((value) => value + 1); + }, [updateKey]); + const events = Array.isArray(trace.events) ? trace.events : []; const last = summarizeLastEvent(trace); const rows = useMemo(() => traceDisplayRows(trace, events), [trace, events]); const noiseCount = useMemo(() => traceNoiseEventCount(events), [events]); + const lastUpdatedAt = traceUpdatedAt(trace, events, last.ts); return ( -
setOpen((event.currentTarget as HTMLDetailsElement).open)}> +
setOpen((event.currentTarget as HTMLDetailsElement).open)}> Trace · {trace.status ?? "unknown"} · {traceCountText(trace, events.length, rows.length, noiseCount)} - last · {last.label} - {last.ts ? {formatBeijingTime(last.ts, { withSeconds: true })} : null} - {shortToken(trace.traceId, 10)} + 最近更新 + {lastUpdatedAt ? {formatBeijingTime(lastUpdatedAt, { withSeconds: true })} : null} +
0 ? `,去噪 ${noiseCount}` : ""; diff --git a/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx b/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx index 08eb4b77..c9bc05b7 100644 --- a/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx +++ b/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx @@ -20,3 +20,10 @@ test("message markdown escapes raw HTML instead of injecting handwritten HTML", assert.doesNotMatch(html, /