fix(v02): improve trace and sidebar web ui (#854)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 20:56:43 +08:00
committed by GitHub
parent 758c26a448
commit 2129cebfef
9 changed files with 219 additions and 43 deletions
+47 -3
View File
@@ -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 () => {
+61 -3
View File
@@ -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 "";
+27 -14
View File
@@ -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;
}
+5
View File
@@ -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");
+3 -3
View File
@@ -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, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/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);
});
+24 -15
View File
@@ -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; }