fix(v02): improve trace and sidebar web ui (#854)
Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -14,8 +14,8 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, 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<string, unknown> = {}, 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<string, unknown>, event: TraceEvent): TraceEventRow {
|
||||
function traceAssistantMessageRow(trace: Record<string, unknown>, 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<string, unknown>, row: TraceEventRow, completionEvent: TraceEvent): TraceEventRow {
|
||||
return {
|
||||
...row,
|
||||
tone: "ok",
|
||||
terminal: true,
|
||||
header: `${row.header},轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, completionEvent))})`
|
||||
};
|
||||
}
|
||||
|
||||
function traceCompletionSummaryRow(trace: Record<string, unknown>, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<RouteId, string> = {
|
||||
workspace: "工作台",
|
||||
|
||||
@@ -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<boolean>(storedOpen);
|
||||
const [pulseKey, setPulseKey] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement | null>(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<TraceEventRow[]>(() => traceDisplayRows(trace, events), [trace, events]);
|
||||
const noiseCount = useMemo<number>(() => traceNoiseEventCount(events), [events]);
|
||||
const lastUpdatedAt = traceUpdatedAt(trace, events, last.ts);
|
||||
|
||||
return (
|
||||
<details className={`message-trace tone-border-${toneClass(last.tone)}`} data-trace-ui-key={trace.traceId ?? "trace"} data-trace-mode="all" open={open} onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
||||
<details className={`message-trace tone-border-${toneClass(last.tone)}`} data-trace-ui-key={trace.traceId ?? "trace"} data-trace-mode="all" data-trace-update-key={pulseKey} open={open} onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
||||
<summary className="message-trace-summary">
|
||||
<span>Trace · {trace.status ?? "unknown"} · {traceCountText(trace, events.length, rows.length, noiseCount)}</span>
|
||||
<span className={`message-trace-last tone-${toneClass(last.tone)}`}>last · {last.label}</span>
|
||||
{last.ts ? <span className="message-trace-time">{formatBeijingTime(last.ts, { withSeconds: true })}</span> : null}
|
||||
<span className="message-trace-storage-key" title={storageKey ?? "no storage key"}>{shortToken(trace.traceId, 10)}</span>
|
||||
<span className={`message-trace-last tone-${toneClass(last.tone)}`}>最近更新</span>
|
||||
{lastUpdatedAt ? <span className="message-trace-time">{formatBeijingTime(lastUpdatedAt, { withSeconds: true })}</span> : null}
|
||||
<span className="message-trace-update-dot" aria-hidden="true" />
|
||||
</summary>
|
||||
<div
|
||||
className="message-trace-events"
|
||||
@@ -78,6 +85,15 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra
|
||||
);
|
||||
}
|
||||
|
||||
function traceUpdateKey(trace: RunnerTrace): string {
|
||||
const eventCount = Number.isInteger(trace.eventCount) ? String(trace.eventCount) : String(trace.events?.length ?? 0);
|
||||
return [trace.traceId ?? "trace", trace.status ?? "unknown", trace.updatedAt ?? "", trace.finishedAt ?? "", eventCount].join(":");
|
||||
}
|
||||
|
||||
function traceUpdatedAt(trace: RunnerTrace, events: TraceEvent[], fallback: unknown): unknown {
|
||||
return trace.updatedAt ?? trace.finishedAt ?? events.at(-1)?.createdAt ?? events.at(-1)?.ts ?? fallback;
|
||||
}
|
||||
|
||||
function traceCountText(trace: RunnerTrace, events: number, rows: number, noiseCount: number): string {
|
||||
const rawTotal = Number.isInteger(trace.eventCount) ? trace.eventCount : events;
|
||||
const noise = noiseCount > 0 ? `,去噪 ${noiseCount}` : "";
|
||||
|
||||
@@ -20,3 +20,10 @@ test("message markdown escapes raw HTML instead of injecting handwritten HTML",
|
||||
assert.doesNotMatch(html, /<script>/u);
|
||||
assert.match(html, /<script>alert\(1\)<\/script>/u);
|
||||
});
|
||||
|
||||
test("message markdown preserves source newlines for final response display", () => {
|
||||
const html = renderToStaticMarkup(<MessageMarkdown>{"第一行\n第二行"}</MessageMarkdown>);
|
||||
|
||||
assert.match(html, /class="message-body"/u);
|
||||
assert.match(html, /第一行\n第二行/u);
|
||||
});
|
||||
|
||||
@@ -20,3 +20,27 @@ test("web trace rows use assistant content instead of terminal/result boilerplat
|
||||
assert.equal(terminalRows[0]?.body, "你好,真实回复");
|
||||
assert.doesNotMatch(rows.map((row) => row.body ?? "").join("\n"), /AgentRun terminal status completed|short-connection polling/u);
|
||||
});
|
||||
|
||||
test("web trace rows keep every assistant message and mark only the last one terminal", () => {
|
||||
const events: Record<string, unknown>[] = [
|
||||
{ seq: 1, label: "agentrun:request:accepted", status: "accepted", createdAt: "2026-06-04T12:13:58.212Z" },
|
||||
{ seq: 42, label: "agentrun:assistant:message", type: "assistant", status: "running", messageIndex: 1, messageCount: 3, message: "我先读取 device-pod-cli 技能文档。", createdAt: "2026-06-04T12:14:55.199Z" },
|
||||
{ seq: 43, label: "agentrun:assistant:message", type: "assistant", status: "running", messageIndex: 2, messageCount: 3, message: "`hwpod` 已就位,runtime 已锁定到 `hwlab-v02`。", createdAt: "2026-06-04T12:14:55.203Z" },
|
||||
{ seq: 44, label: "agentrun:assistant:message", type: "assistant", status: "running", messageIndex: 3, messageCount: 3, message: "**可用 Device Pod 列表**\n\n- **D601-71-FREQ**\n- **D601-F103-V2**", createdAt: "2026-06-04T12:14:55.208Z" },
|
||||
{ seq: 49, label: "agentrun:backend:backend-turn-finished", status: "running", message: "backend-turn-finished", createdAt: "2026-06-04T12:14:55.231Z" },
|
||||
{ seq: 50, label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, message: "AgentRun result is ready for HWLAB short-connection polling.", createdAt: "2026-06-04T12:14:55.231Z" }
|
||||
];
|
||||
|
||||
const rows = traceDisplayRows({ startedAt: "2026-06-04T12:13:58.212Z" }, events);
|
||||
const assistantRows = rows.filter((row) => row.bodyFormat === "markdown");
|
||||
const terminalRows = assistantRows.filter((row) => row.terminal === true);
|
||||
const text = assistantRows.map((row) => row.body ?? "").join("\n");
|
||||
|
||||
assert.equal(assistantRows.length, 3);
|
||||
assert.equal(terminalRows.length, 1);
|
||||
assert.match(text, /我先读取 device-pod-cli/u);
|
||||
assert.match(text, /runtime 已锁定到 `hwlab-v02`/u);
|
||||
assert.match(text, /D601-F103-V2/u);
|
||||
assert.match(terminalRows[0]?.header ?? "", /助手消息 3\/3,轮次完成/u);
|
||||
assert.doesNotMatch(JSON.stringify(rows), /backend-turn-finished|short-connection polling/u);
|
||||
});
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
--rail-width: 92px;
|
||||
--rail-collapsed-width: 44px;
|
||||
--session-sidebar-width: 330px;
|
||||
--right-sidebar-width: 728px;
|
||||
--right-sidebar-min-width: 560px;
|
||||
--right-sidebar-max-width: 740px;
|
||||
--right-sidebar-width: 620px;
|
||||
--right-sidebar-min-width: 420px;
|
||||
--right-sidebar-max-width: 680px;
|
||||
--right-collapsed-width: 46px;
|
||||
--resize-handle-width: 8px;
|
||||
}
|
||||
@@ -107,7 +107,7 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.code-agent-debug-summary { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; flex-wrap: wrap; }
|
||||
.code-agent-debug-summary .code-agent-summary-label, .code-agent-debug-summary .code-agent-summary-capability { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.message-trace pre, .skill-preview, .help-content, #device-detail-body { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.message-trace-count, .message-trace-storage-key { color: var(--muted); font-size: 12px; }
|
||||
.message-trace-count { color: var(--muted); font-size: 12px; }
|
||||
.message-trace-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; padding: 8px 10px; }
|
||||
.message-trace-row { border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: #fff; }
|
||||
.message-trace-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
@@ -117,7 +117,9 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.message-trace-body { margin: 6px 0 0; max-height: 220px; overflow: auto; background: #f3f5f1; padding: 6px 8px; border-radius: 6px; font-size: 12px; }
|
||||
.message-trace-events { min-height: 0; max-height: min(312px, 32dvh); overflow: auto; width: 100%; }
|
||||
.message-trace-empty { color: var(--muted); font-size: 12px; padding: 10px; }
|
||||
.message-trace-last { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.message-trace-last { color: var(--muted); font-size: 12px; font-weight: 800; }
|
||||
.message-trace-update-dot { width: 7px; height: 7px; border-radius: 999px; background: var(--accent); opacity: 0; transform: scale(0.75); }
|
||||
.message-trace[data-trace-update-key] .message-trace-update-dot { animation: trace-update-pulse 900ms ease-out; }
|
||||
.message-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 6px 8px; border-top: 1px dashed var(--line); flex-wrap: wrap; }
|
||||
.message-action { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 4px 10px; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.message-action:hover, .message-action:focus-visible { border-color: var(--accent); outline: 0; }
|
||||
@@ -193,6 +195,7 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.message-card.message-agent { display: grid; gap: 6px; width: 100%; }
|
||||
.message-head { display: none; }
|
||||
.message-body { line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.message-body p { white-space: pre-wrap; }
|
||||
.message-user .message-body { padding: 7px 10px; }
|
||||
.message-body p, .message-body ul, .message-body ol, .message-body blockquote, .message-body table { margin: 0 0 8px; }
|
||||
.message-body p:last-child, .message-body ul:last-child, .message-body ol:last-child, .message-body blockquote:last-child, .message-body table:last-child { margin-bottom: 0; }
|
||||
@@ -238,6 +241,12 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.message-trace { width: 100%; }
|
||||
.message-trace-events { width: 100%; }
|
||||
|
||||
@keyframes trace-update-pulse {
|
||||
0% { opacity: 0.95; transform: scale(1.35); box-shadow: 0 0 0 0 rgba(31, 122, 107, 0.36); }
|
||||
70% { opacity: 0.55; transform: scale(1); box-shadow: 0 0 0 8px rgba(31, 122, 107, 0); }
|
||||
100% { opacity: 0; transform: scale(0.75); box-shadow: 0 0 0 0 rgba(31, 122, 107, 0); }
|
||||
}
|
||||
|
||||
.command-bar { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-areas: "profile drafts send clear" "input input input input"; gap: 6px; align-items: end; padding: 8px 10px; border-top: 1px solid var(--line); background: #f7f8f4; }
|
||||
.command-bar .input-shell { grid-area: input; }
|
||||
.command-bar #command-drafts-toggle { grid-area: drafts; }
|
||||
@@ -254,24 +263,24 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.command-drafts-toggle { white-space: nowrap; }
|
||||
|
||||
.right-sidebar { min-width: 0; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr); gap: 0; padding: 0; background: #fbfcf8; border-left: 1px solid var(--line); overflow: hidden; }
|
||||
.right-sidebar-content { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 10px; padding: 14px; overflow-x: hidden; overflow-y: auto; scrollbar-gutter: stable; }
|
||||
.right-sidebar-content { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 8px; padding: 10px; overflow-x: hidden; overflow-y: auto; scrollbar-gutter: stable; }
|
||||
.right-sidebar-content > * { flex: 0 0 auto; min-width: 0; }
|
||||
.is-right-sidebar-collapsed .right-sidebar-content { display: none; }
|
||||
.is-right-sidebar-collapsed .right-sidebar-resize { display: none; }
|
||||
.is-right-sidebar-collapsed .right-sidebar { display: none; }
|
||||
.device-pod-picker { display: grid; gap: 6px; font-weight: 800; }
|
||||
.device-pod-status { display: grid; gap: 10px; }
|
||||
.device-pod-summary, .device-pod-interfaces { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
||||
.summary-tile { min-width: 0; min-height: 70px; display: grid; gap: 4px; text-align: left; border: 1px solid var(--line); border-radius: 6px; background: #fff; padding: 10px; }
|
||||
.device-pod-status { display: grid; gap: 6px; }
|
||||
.device-pod-summary, .device-pod-interfaces { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||
.summary-tile { min-width: 0; min-height: 52px; display: grid; gap: 2px; text-align: left; border: 1px solid var(--line); border-radius: 6px; background: #fff; padding: 7px 8px; }
|
||||
.summary-tile span { color: var(--muted); font-size: 12px; }
|
||||
.summary-tile strong { overflow-wrap: anywhere; }
|
||||
.device-pod-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin: 0; }
|
||||
.device-pod-meta div { border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; }
|
||||
.summary-tile strong { overflow-wrap: anywhere; font-size: 12px; line-height: 1.2; }
|
||||
.device-pod-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; margin: 0; }
|
||||
.device-pod-meta div { border: 1px solid var(--line); border-radius: 6px; padding: 6px; background: #fff; }
|
||||
.device-pod-meta dt { font-weight: 800; }
|
||||
.device-pod-meta dd { margin: 3px 0 0; overflow-wrap: anywhere; }
|
||||
.device-pod-workspace { min-height: 360px; display: grid; grid-template-rows: auto minmax(220px, 1fr); gap: 10px; overflow: visible; }
|
||||
.device-event-panel { min-height: 240px; display: grid; grid-template-rows: auto minmax(132px, 1fr); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #fff; }
|
||||
.device-event-panel header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--line); font-weight: 800; }
|
||||
.device-pod-workspace { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 8px; overflow: hidden; }
|
||||
.device-event-panel { min-height: 0; max-height: min(360px, 38dvh); display: grid; grid-template-rows: auto minmax(0, 1fr); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #fff; }
|
||||
.device-event-panel header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border-bottom: 1px solid var(--line); font-weight: 800; }
|
||||
.device-event-scroll { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 10px; background: #17201c; color: #e9f5e8; }
|
||||
#device-event-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
||||
.device-detail-dialog { max-width: min(720px, 92vw); border: 1px solid var(--line); border-radius: 8px; padding: 0; }
|
||||
|
||||
Reference in New Issue
Block a user