+
+
+
{{ title }}
-
{{ message }}
+
{{ summaryText }}
-
+
-
-
-
- {{ item.label }}
- {{ item.value }}
-
-
+
+
+
+
+
- {{ item.label }}
+ {{ item.value }}
+
+
+
diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css
index 59c6481f..b4283365 100644
--- a/web/hwlab-cloud-web/src/styles/workbench.css
+++ b/web/hwlab-cloud-web/src/styles/workbench.css
@@ -289,12 +289,13 @@
}
.api-error-diagnostic {
- display: grid;
- width: 100%;
- gap: 10px;
+ display: inline-grid;
+ max-width: min(720px, 100%);
+ width: fit-content;
+ gap: 8px;
border-left: 3px solid #b91c1c;
background: #f8fafc;
- padding: 10px 12px;
+ padding: 8px 10px;
text-align: left;
}
@@ -303,11 +304,20 @@
margin: 4px auto 0;
}
-.api-error-diagnostic header {
- display: flex;
- align-items: flex-start;
+.api-error-diagnostic-summary {
+ display: inline-flex;
+ min-width: 0;
+ align-items: center;
justify-content: space-between;
- gap: 12px;
+ gap: 8px;
+}
+
+.api-error-diagnostic-summary-text {
+ display: inline-flex;
+ min-width: 0;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 8px;
}
.api-error-diagnostic strong {
@@ -317,10 +327,41 @@
}
.api-error-diagnostic p {
- margin: 2px 0 0;
+ min-width: 0;
+ margin: 0;
+ overflow-wrap: anywhere;
color: #334155;
font-size: 13px;
- line-height: 1.5;
+ line-height: 1.35;
+}
+
+.api-error-diagnostic-toggle {
+ display: inline-flex;
+ width: 22px;
+ height: 22px;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #f59e0b;
+ border-radius: 999px;
+ background: #fff7ed;
+ color: #92400e;
+ cursor: pointer;
+ padding: 0;
+ font-size: 12px;
+ font-weight: 850;
+ line-height: 1;
+}
+
+.api-error-diagnostic-toggle[aria-expanded="true"] {
+ background: #fffbeb;
+ color: #78350f;
+}
+
+.api-error-diagnostic-details {
+ display: grid;
+ gap: 8px;
+ justify-items: start;
}
.api-error-diagnostic-grid {
diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts
index cecce3ee..eb558759 100644
--- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts
+++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts
@@ -1,4 +1,4 @@
-import type { Page } from "@playwright/test";
+import type { Locator, Page } from "@playwright/test";
import { expect, fakeServerState, test } from "../fixtures/test";
@@ -34,7 +34,7 @@ test("admin HWPOD groups renders node-ops as structured UI before raw JSON is re
test("performance dashboard renders Chinese charts and keeps drill-down reachable", async ({ page }) => {
await page.goto("/performance");
- await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible();
await expect(page.locator(".performance-contract-item")).toHaveCount(5);
await expect(page.locator(".performance-contract-item").filter({ hasText: "观测窗口" })).toContainText("最近 15 分钟");
await expect(page.locator(".performance-contract-item").filter({ hasText: "生成时间" })).toContainText("fake-server");
@@ -83,7 +83,7 @@ test("performance dashboard renders Chinese charts and keeps drill-down reachabl
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/performance");
- await expect(page.getByText("性能明细")).toBeVisible();
+ await expect(page.getByRole("heading", { name: "性能明细" })).toBeVisible();
await expect(page.locator("svg.performance-line-chart")).toHaveCount(3);
await page.screenshot({ path: ".state/workbench-e2e/performance-dashboard-mobile.png", fullPage: true });
@@ -107,7 +107,7 @@ test.describe("performance dashboard empty collection", () => {
test("renders the empty summary contract without invented rows", async ({ page }) => {
await page.goto("/performance");
- await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible();
await expect(page.locator(".performance-contract-item")).toHaveCount(5);
await expect(page.locator(".performance-contract-item").filter({ hasText: "样本覆盖" })).toContainText("0 样本");
await expect(page.locator(".performance-collection-item")).toHaveCount(4);
@@ -126,7 +126,7 @@ test.describe("performance dashboard stale current window", () => {
test("keeps retained samples separate from the empty current window", async ({ page }) => {
await page.goto("/performance");
- await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible();
await expect(page.locator(".performance-contract-item").filter({ hasText: "样本覆盖" })).toContainText("0 样本");
await expect(page.locator(".performance-collection-item").filter({ hasText: "最近样本" })).toContainText("当前窗口暂无样本");
await expect(page.locator(".performance-collection-item").filter({ hasText: "当前窗口样本" })).toContainText("0 样本");
@@ -147,8 +147,9 @@ test.describe("performance dashboard summary error", () => {
await expect(page.getByText("性能监控加载失败")).toBeVisible();
await expect(page.getByText("暂时无法连接上游。")).toBeVisible();
const diagnostic = page.locator(".api-error-diagnostic");
- await expect(diagnostic).toContainText("trace_id");
- await expect(diagnostic).toContainText("11111111111111111111111111111111");
+ await expectDiagnosticSummary(diagnostic, "11111111111111111111111111111111");
+ await expect(diagnostic).not.toContainText("requestId");
+ await expandDiagnostic(diagnostic);
await expect(diagnostic).toContainText("requestId");
await expect(diagnostic).toContainText("req_e2e_api_error_diagnostic");
await expect(diagnostic).toContainText("upstream_unavailable");
@@ -168,11 +169,12 @@ for (const status of performanceErrorStatuses) {
test(`surfaces HTTP ${status} trace diagnostics without stale dashboard data`, async ({ page }) => {
await page.goto("/performance");
- await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible();
await expect(page.getByText("性能监控加载失败")).toBeVisible();
const diagnostic = page.locator(".api-error-diagnostic");
- await expect(diagnostic).toContainText("trace_id");
- await expect(diagnostic).toContainText(statusTraceId(status));
+ await expectDiagnosticSummary(diagnostic, statusTraceId(status));
+ await expect(diagnostic).not.toContainText("requestId");
+ await expandDiagnostic(diagnostic);
await expect(diagnostic).toContainText("requestId");
await expect(diagnostic).toContainText(`req_e2e_api_status_${status}`);
await expect(diagnostic).toContainText(`e2e_http_${status}`);
@@ -192,10 +194,13 @@ test.describe("performance dashboard browser network diagnostic", () => {
test("surfaces browser generated trace diagnostics when the request has no response", async ({ page }) => {
await page.goto("/performance");
- await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible();
await expect(page.getByText("性能监控加载失败")).toBeVisible();
const diagnostic = page.locator(".api-error-diagnostic");
await expect(diagnostic).toContainText("trace_id");
+ await expect(diagnostic.getByRole("button", { name: "诊断详情" })).toHaveAttribute("aria-expanded", "false");
+ await expect(diagnostic).not.toContainText("requestId");
+ await expandDiagnostic(diagnostic);
await expect(diagnostic).toContainText("requestId");
await expect(diagnostic).toContainText("browser_network_error");
await expect(diagnostic).toContainText("web / network");
@@ -210,3 +215,16 @@ test.describe("performance dashboard browser network diagnostic", () => {
function statusTraceId(status: number): string {
return String(status).repeat(11).slice(0, 32);
}
+
+async function expectDiagnosticSummary(diagnostic: Locator, traceId: string): Promise
{
+ await expect(diagnostic).toContainText(`trace_id=${traceId}`);
+ const toggle = diagnostic.getByRole("button", { name: "诊断详情" });
+ await expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await expect(toggle).toBeVisible();
+}
+
+async function expandDiagnostic(diagnostic: Locator): Promise {
+ const toggle = diagnostic.getByRole("button", { name: "诊断详情" });
+ await toggle.click();
+ await expect(toggle).toHaveAttribute("aria-expanded", "true");
+}
diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts
index c19503ed..31cf9122 100644
--- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts
+++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts
@@ -1,6 +1,7 @@
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response.
// Responsibility: Workbench diagnostic visibility regression, including separation from sealed final response.
+import type { Locator } from "@playwright/test";
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
@@ -13,11 +14,15 @@ test.describe("projection diagnostics visibility", () => {
await expect(page.locator(sessionTab("ses_projection_degraded"))).toHaveAttribute("data-projection-health", "degraded");
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last();
await expect(card).toContainText("状态更新超时");
- await expect(card.locator(".projection-diagnostic")).toContainText("agentrun_result_timeout");
- await expect(card.locator(".projection-diagnostic")).toContainText("trace_id");
- await expect(card.locator(".projection-diagnostic")).toContainText("trc_projection_degraded");
- await expect(card.locator(".projection-diagnostic")).toContainText("valuesPrinted");
- await expect(card.locator(".projection-diagnostic")).toContainText("false");
+ const diagnostic = card.locator(".projection-diagnostic");
+ await expectCompactDiagnostic(diagnostic, card, "trc_projection_degraded");
+ await expect(diagnostic).not.toContainText("valuesPrinted");
+ await expandDiagnostic(diagnostic);
+ await expect(diagnostic).toContainText("agentrun_result_timeout");
+ await expect(diagnostic).toContainText("trace_id");
+ await expect(diagnostic).toContainText("trc_projection_degraded");
+ await expect(diagnostic).toContainText("valuesPrinted");
+ await expect(diagnostic).toContainText("false");
await expect(card.locator(selectors.traceTimeline)).toHaveAttribute("data-projection-health", "degraded");
await expect(card.locator(".trace-empty")).toContainText("状态更新超时");
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel");
@@ -32,9 +37,13 @@ test.describe("SSE projection errors", () => {
await gotoWorkbench(page, "/workbench/sessions/ses_projection_sse_error");
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last();
await expect(card).toContainText("SSE 投影事件异常");
- await expect(card.locator(".projection-diagnostic")).toContainText("workbench_sse_projection_error");
- await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_workbench_sse_projection_error");
- await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/events");
+ const diagnostic = card.locator(".projection-diagnostic");
+ await expectCompactDiagnostic(diagnostic, card, "trc_projection_sse_error");
+ await expect(diagnostic).not.toContainText("req_e2e_workbench_sse_projection_error");
+ await expandDiagnostic(diagnostic);
+ await expect(diagnostic).toContainText("workbench_sse_projection_error");
+ await expect(diagnostic).toContainText("req_e2e_workbench_sse_projection_error");
+ await expect(diagnostic).toContainText("/v1/workbench/events");
await expect(page.locator(sessionTab("ses_projection_sse_error"))).toHaveAttribute("data-projection-health", "degraded");
await saveScreenshot(page, testInfo, "projection-sse-error-visible");
});
@@ -47,10 +56,15 @@ test.describe("REST gap hydration errors", () => {
await gotoWorkbench(page, "/workbench/sessions/ses_trace_hydration_timeout");
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last();
await expect(card).toContainText("Trace 更新超时");
- await expect(card.locator(".projection-diagnostic")).toContainText("trace_hydration_timeout");
- await expect(card.locator(".projection-diagnostic")).toContainText("33333333333333333333333333333333");
- await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_trace_hydration_timeout");
- await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events");
+ const diagnostic = card.locator(".projection-diagnostic");
+ await expectCompactDiagnostic(diagnostic, card, "33333333333333333333333333333333");
+ await expect(diagnostic).not.toContainText("req_e2e_trace_hydration_timeout");
+ await expect(diagnostic).not.toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events");
+ await expandDiagnostic(diagnostic);
+ await expect(diagnostic).toContainText("trace_hydration_timeout");
+ await expect(diagnostic).toContainText("33333333333333333333333333333333");
+ await expect(diagnostic).toContainText("req_e2e_trace_hydration_timeout");
+ await expect(diagnostic).toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events");
await expect(card).toHaveAttribute("data-status", "running");
await expect(card.locator(selectors.traceTimeline)).toHaveAttribute("data-projection-health", "degraded");
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel");
@@ -64,9 +78,13 @@ test.describe("sealed final response diagnostics separation", () => {
test("keeps completed final response visible while showing diagnostics separately", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/ses_sealed_final_diag");
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last();
- await expect(card.locator(".projection-diagnostic")).toContainText("Trace 更新超时");
- await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_sealed_final_trace_timeout");
- await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/traces/trc_sealed_final_diag/events");
+ const diagnostic = card.locator(".projection-diagnostic");
+ await expectCompactDiagnostic(diagnostic, card, "55555555555555555555555555555555");
+ await expect(diagnostic).not.toContainText("req_e2e_sealed_final_trace_timeout");
+ await expandDiagnostic(diagnostic);
+ await expect(diagnostic).toContainText("Trace 更新超时");
+ await expect(diagnostic).toContainText("req_e2e_sealed_final_trace_timeout");
+ await expect(diagnostic).toContainText("/v1/workbench/traces/trc_sealed_final_diag/events");
const finalResponse = card.locator(".message-text").filter({ hasText: "已完成的 sealed final response 不应被诊断覆盖。" });
await expect(finalResponse).toBeVisible();
await expect(finalResponse).not.toContainText("Trace 更新超时");
@@ -74,6 +92,23 @@ test.describe("sealed final response diagnostics separation", () => {
});
});
+async function expectCompactDiagnostic(diagnostic: Locator, container: Locator, traceId: string): Promise {
+ await expect(diagnostic).toContainText(`trace_id=${traceId}`);
+ const toggle = diagnostic.getByRole("button", { name: "诊断详情" });
+ await expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await expect(toggle).toBeVisible();
+ const diagnosticBox = await diagnostic.boundingBox();
+ const containerBox = await container.boundingBox();
+ expect(diagnosticBox?.width ?? 0).toBeGreaterThan(0);
+ if (containerBox && diagnosticBox) expect(diagnosticBox.width).toBeLessThan(containerBox.width - 12);
+}
+
+async function expandDiagnostic(diagnostic: Locator): Promise {
+ const toggle = diagnostic.getByRole("button", { name: "诊断详情" });
+ await toggle.click();
+ await expect(toggle).toHaveAttribute("aria-expanded", "true");
+}
+
test.describe("trace detail final response separation", () => {
test.use({ scenarioId: "trace-final-response-not-message-authority" });