feat: refine workbench visuals and map controls

Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling.

Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
This commit is contained in:
2026-07-28 16:39:36 +08:00
parent ade88b4fbf
commit f4318a9b9c
96 changed files with 5480 additions and 1875 deletions
+153 -36
View File
@@ -1,6 +1,6 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { expect, test } from "playwright/test";
import { expect, test } from "@playwright/test";
const STREAM_CHUNK_SIZE = 18;
const STREAM_CHUNK_DELAY_MS = 20;
@@ -276,6 +276,48 @@ test("stream completion and the plan card remain positionally stable", async ({
});
await sendButton.click();
const userMessage = page.locator(".agent-panel-user-message");
const assistantMessage = page.locator(".agent-panel-message").first();
await expect(userMessage).toHaveCSS("background-color", "rgba(224, 229, 249, 0.92)");
expect(
await userMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
).not.toBe(
await assistantMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
);
await expect
.poll(() => userMessage.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
await expect
.poll(() => assistantMessage.evaluate((element) => getComputedStyle(element).boxShadow))
.toBe(await userMessage.evaluate((element) => getComputedStyle(element).boxShadow));
const operationalBrief = page.locator(".agent-panel-operational-float");
await expect(operationalBrief).toBeVisible();
await expect(operationalBrief).toHaveCSS("position", "absolute");
await expect(operationalBrief).not.toHaveClass(/agent-panel-floating-acrylic/);
await expect(operationalBrief).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
await expect(operationalBrief).toHaveCSS("border-radius", "16px");
await expect(operationalBrief).toHaveCSS("background-image", "none");
await expect(operationalBrief).toHaveCSS("border-top-width", "0px");
await expect(operationalBrief).toHaveCSS(
"box-shadow",
"rgba(15, 33, 55, 0.14) 0px 12px 32px 0px, rgba(15, 33, 55, 0.08) 0px 3px 10px 0px"
);
await expect
.poll(() => operationalBrief.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toContain("inset");
await expect
.poll(() => operationalBrief.evaluate((element) => getComputedStyle(element).backdropFilter))
.toContain("blur(");
const operationalBriefBox = await operationalBrief.boundingBox();
const conversationBox = await page.locator(".agent-panel-conversation-canvas").boundingBox();
expect(operationalBriefBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(operationalBriefBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
await expect(page.locator(".agent-panel-conversation-content-has-brief")).toHaveCSS(
"padding-top",
"208px"
);
const liveProgress = page.getByRole("list", { name: "最近执行进度" });
await expect(liveProgress.locator("li")).toHaveCount(3);
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
@@ -393,9 +435,19 @@ test("stream completion and the plan card remain positionally stable", async ({
.filter({ visible: true })
.first();
await expect(progressToggle).toBeEnabled();
await expect(progressToggle).toHaveAttribute("data-state", "closed");
await expect
.poll(async () => (await progressToggle.boundingBox())?.height ?? 0)
.toBeGreaterThanOrEqual(40);
await progressToggle.click();
await expect(progressToggle).toHaveAttribute("data-state", "open");
const expandedLiveProgress = page.getByRole("list", { name: "全部执行进度" });
await expect(expandedLiveProgress.locator("li")).toHaveCount(4);
await expect(expandedLiveProgress.locator(".status-badge")).toHaveCount(0);
await expect(expandedLiveProgress.locator('[data-progress-status="running"]').first()).toHaveCSS(
"color",
"oklch(0.546 0.245 262.881)"
);
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await expect(
expandedLiveProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true })
@@ -403,34 +455,9 @@ test("stream completion and the plan card remain positionally stable", async ({
await progressToggle.click();
await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3);
await page.evaluate(() => {
const state = window as typeof window & { __agentFailureMotionObserved?: boolean };
const startedAt = performance.now();
state.__agentFailureMotionObserved = false;
const sample = () => {
const badge = document.querySelector<HTMLElement>('[data-progress-status="error"]');
if (badge && getComputedStyle(badge).transform !== "none") {
state.__agentFailureMotionObserved = true;
}
if (!state.__agentFailureMotionObserved && performance.now() - startedAt < 1_000) {
requestAnimationFrame(sample);
}
};
requestAnimationFrame(sample);
});
triggerProgressFailure();
await expect(liveProgress.getByText("液位连续性核对失败", { exact: true })).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window as typeof window & { __agentFailureMotionObserved?: boolean })
.__agentFailureMotionObserved ?? false
)
)
.toBe(true);
await expect(page.locator('[data-progress-status="error"]')).toHaveCSS("transform", "none");
resumeStream();
await expect(
@@ -438,7 +465,7 @@ test("stream completion and the plan card remain positionally stable", async ({
).toBeVisible({
timeout: 10_000
});
await expect(progressToggle).toContainText("共 4 条");
await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
await expect(
page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true })
@@ -528,24 +555,114 @@ test("stream completion and the plan card remain positionally stable", async ({
.getByRole("button", { name: /执行进度/ })
.filter({ visible: true })
.last();
const latestProgressRegion = latestProgressToggle.locator("..");
const latestProgressRegion = latestProgressToggle.locator("../..");
const latestExpandedProgress = latestProgressRegion.getByRole("list", {
name: "全部执行进度",
name: "全部执行进度"
});
await expect(latestProgressToggle).toContainText("最近 3 条");
await expect(latestProgressRegion).toContainText("最近3/3");
triggerProgressAppend();
await expect(
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true })
).toBeVisible();
await latestProgressToggle.click();
await expect(latestProgressToggle).toContainText("全部 4 条");
await expect(latestProgressRegion).toContainText("全部4/4");
resumeStream();
await expect(latestProgressToggle).toContainText("全部 4 条", { timeout: 10_000 });
await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 });
await expect(latestExpandedProgress).toBeVisible();
await expect(
latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })
).toBeVisible();
await expect(latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })).toBeVisible();
const conversationScroll = page.locator(".agent-conversation-scroll");
await conversationScroll.hover();
await page.mouse.wheel(0, -2400);
const scrollToLatestButton = page.getByRole("button", { name: "滚动到最新消息" });
await expect(scrollToLatestButton).toBeVisible();
await expect(scrollToLatestButton).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
await expect
.poll(() =>
scrollToLatestButton.evaluate((element) => getComputedStyle(element).backdropFilter)
)
.toContain("blur(");
await expect
.poll(() => scrollToLatestButton.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
const readAcrylicMaterial = (element: HTMLElement) => {
const style = getComputedStyle(element);
return {
backdropFilter: style.backdropFilter,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
boxShadow: style.boxShadow
};
};
expect(await scrollToLatestButton.evaluate(readAcrylicMaterial)).toEqual(
await page.locator(".agent-panel-composer").evaluate(readAcrylicMaterial)
);
const selectableAssistantMessage = page.getByRole("heading", { name: "诊断结果" }).first();
await selectableAssistantMessage.evaluate((element) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let textNode = walker.nextNode();
while (textNode && !textNode.textContent?.trim()) {
textNode = walker.nextNode();
}
if (!textNode?.textContent) {
throw new Error("Assistant message does not contain selectable text");
}
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, Math.min(8, textNode.textContent.length));
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
});
const speechSelectionIconMotion = await page.evaluate(async () => {
const waitForAnimationFrame = () =>
new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
const deadline = performance.now() + 1_000;
let button: HTMLButtonElement | undefined;
while (!button && performance.now() < deadline) {
button = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((element) =>
element.textContent?.includes("从这里开始朗读")
);
if (!button) await waitForAnimationFrame();
}
const icon = button?.querySelector("svg");
if (!button || !icon) {
throw new Error("Speech selection action did not render with an icon");
}
const samples: Array<{ x: number; y: number }> = [];
const startedAt = performance.now();
while (performance.now() - startedAt < 220) {
const rect = icon.getBoundingClientRect();
samples.push({
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2
});
await waitForAnimationFrame();
}
const xValues = samples.map((sample) => sample.x);
const yValues = samples.map((sample) => sample.y);
return {
xSpread: Math.max(...xValues) - Math.min(...xValues),
ySpread: Math.max(...yValues) - Math.min(...yValues)
};
});
expect(speechSelectionIconMotion.xSpread).toBeLessThanOrEqual(0.1);
expect(speechSelectionIconMotion.ySpread).toBeLessThanOrEqual(0.1);
const speechSelectionButton = page.getByRole("button", { name: "从这里开始朗读" });
await expect(speechSelectionButton).toBeVisible();
await expect(speechSelectionButton).toHaveCSS("border-top-width", "0px");
expect(await speechSelectionButton.evaluate(readAcrylicMaterial)).toEqual(
await scrollToLatestButton.evaluate(readAcrylicMaterial)
);
});
function createLongMarkdownResponse() {