feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
+112 -42
View File
@@ -1,7 +1,29 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Locator } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
async function expectCenteredStart(panel: Locator) {
await expect(panel).toHaveAttribute("data-agent-centered", "true");
await expect.poll(async () => {
const body = (await panel.locator(".agent-panel-body").boundingBox())!;
const group = (await panel.locator(".agent-panel-band").boundingBox())!;
return Math.abs(group.y + group.height / 2 - body.y - body.height / 2);
}).toBeLessThan(2);
const body = (await panel.locator(".agent-panel-body").boundingBox())!;
const form = (await panel.locator("form").boundingBox())!;
expect(Math.abs(form.x + form.width / 2 - body.x - body.width / 2)).toBeLessThan(1);
expect(form.width).toBeLessThanOrEqual(552);
await expect(panel.locator("form")).toHaveCount(1);
}
async function preparePreview(page: import("@playwright/test").Page) {
await page.evaluate(() => document.fonts.ready);
// Close this snapshot together; positional locators shift as earlier notices exit.
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => {
for (const button of buttons) (button as HTMLButtonElement).click();
});
}
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
@@ -10,7 +32,7 @@ test.beforeEach(async ({ page }) => {
}] });
});
test("empty conversation centers one composer and directly sends an Agent-generated recent activity question", async ({ page }) => {
test("empty conversation centers the first question and sends an Agent-generated recent activity question", async ({ page }) => {
let requests = 0;
await page.route("**/api/v1/suggestions", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
@@ -27,15 +49,17 @@ test("empty conversation centers one composer and directly sends an Agent-genera
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel).toHaveAttribute("data-agent-starting", "true");
await expect(panel.getByRole("heading", { name: "我能帮你处理什么?" })).toBeVisible();
await expect(panel.getByRole("heading", { name: "有什么可以帮你?" })).toBeVisible();
const question = panel.getByRole("button", { name: /重新核对城南供水压力复核涉及的监测证据/ });
await expect(question).toBeVisible();
await expect(question).toContainText("近期会话");
const composer = (await panel.locator(".agent-panel-start-composer").boundingBox())!;
const panelBox = (await panel.boundingBox())!;
expect(Math.abs(composer.y + composer.height / 2 - (panelBox.y + 56 + (panelBox.height - 56) / 2))).toBeLessThan(1);
const composer = (await panel.locator("form").boundingBox())!;
await expectCenteredStart(panel);
const suggestions = (await panel.getByRole("group", { name: "推荐问题" }).boundingBox())!;
expect(suggestions.y).toBeGreaterThan(composer.y + composer.height);
expect(composer.height).toBeLessThan(150);
await expect(panel.locator("form")).toHaveCount(1);
await panel.screenshot({ path: "test-results/agent-history-ui/agent-start-generated.png" });
await panel.screenshot({ path: "/tmp/nexttjwater-agent-start-preview/centered-desktop.png" });
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
for (const width of [400, 640]) {
const box = (await handle.boundingBox())!;
@@ -43,14 +67,15 @@ test("empty conversation centers one composer and directly sends an Agent-genera
await page.mouse.down();
await page.mouse.move(width, box.y + box.height / 2);
await page.mouse.up();
await expect(panel).toHaveCSS("width", `${width}px`);
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width);
await expectCenteredStart(panel);
}
expect(requests).toBe(1);
await question.click();
await expect(panel).toHaveAttribute("data-agent-starting", "false");
await expect(panel.locator(".agent-panel-composer-motion")).toHaveCSS("transform", "none");
await expect(panel.locator("textarea")).toHaveCSS("min-height", "96px");
expect((await panel.locator(".agent-panel-band").last().boundingBox())!.height).toBeLessThanOrEqual(160);
await expect(panel.locator("textarea")).toHaveCSS("min-height", "64px");
expect((await panel.locator(".agent-panel-band").last().boundingBox())!.height).toBeLessThanOrEqual(180);
await expect(panel.locator(".agent-panel-user-message")).toHaveText("重新核对城南供水压力复核涉及的监测证据");
await expect(panel.locator("form")).toHaveCount(1);
await expect.poll(async () => {
@@ -60,6 +85,16 @@ test("empty conversation centers one composer and directly sends an Agent-genera
}).toBeLessThan(1);
await panel.screenshot({ path: "test-results/agent-history-ui/agent-conversation-compact.png" });
});
test("multiline follow-up stays reachable in a short desktop viewport", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.locator("textarea").fill("核对供水压力");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(panel).toHaveAttribute("data-agent-starting", "false");
const prompt = panel.locator("textarea");
await prompt.focus();
await page.keyboard.type("核对监测点");
@@ -67,13 +102,14 @@ test("empty conversation centers one composer and directly sends an Agent-genera
await page.keyboard.type("补充现场记录");
await expect(prompt).toHaveValue("核对监测点\n补充现场记录");
const initialHeight = (await prompt.boundingBox())!.height;
expect(initialHeight).toBe(96);
expect(initialHeight).toBeGreaterThanOrEqual(64);
expect(initialHeight).toBeLessThanOrEqual(70);
await prompt.fill("监测点、异常时间及现场核验记录。\n".repeat(30));
for (const height of [900, 640]) {
await page.setViewportSize({ width: 1280, height });
const promptBox = (await prompt.boundingBox())!;
expect(promptBox.height).toBeGreaterThan(initialHeight);
expect(promptBox.height).toBeLessThanOrEqual(Math.min(height * 0.24, 192));
expect(promptBox.height).toBeLessThanOrEqual(Math.min(height * 0.24, 160));
expect(promptBox.x).toBeGreaterThanOrEqual(0);
await expect.poll(() => prompt.evaluate((element) => {
const input = element.getBoundingClientRect();
@@ -88,11 +124,11 @@ test("empty conversation centers one composer and directly sends an Agent-genera
expect((await panel.getByRole("log").boundingBox())!.height).toBeGreaterThan(100);
}
await prompt.fill("");
await expect(prompt).toHaveCSS("height", "96px");
await expect(prompt).toHaveCSS("height", "64px");
await panel.screenshot({ path: "test-results/agent-history-ui/agent-conversation-short-viewport.png" });
});
test("failed recommendation generation leaves the centered input ready for a normal conversation", async ({ page }) => {
test("failed recommendation generation leaves the input ready for a normal conversation", async ({ page }) => {
await page.route("**/api/v1/suggestions", (route) => route.request().method() === "OPTIONS" ? route.fallback() : route.fulfill({
status: 503, headers: { "Access-Control-Allow-Origin": "*" }, json: { message: "unavailable" },
}));
@@ -107,47 +143,81 @@ test("failed recommendation generation leaves the centered input ready for a nor
await expect(panel).toHaveAttribute("data-agent-starting", "false");
});
test("first send smoothly moves the existing composer to the bottom", async ({ page }) => {
test("sending moves the same centered composer to the bottom and new conversation restores the start", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await mockAgentApi(page, { sessions: [] });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel.getByRole("group", { name: "推荐问题" }).getByRole("button")).toHaveCount(3);
await panel.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity)
.map((animation) => animation.finished.catch(() => undefined)));
});
const form = panel.locator("form");
await preparePreview(page);
await panel.screenshot({ path: "/tmp/nexttjwater-agent-start-preview/centered-desktop.png" });
await form.locator("textarea").fill("核对近期供水压力变化");
const beforeTop = (await form.boundingBox())!.y;
await page.evaluate(() => {
const panel = document.querySelector('aside[aria-label="Agent 命令面板"]')!;
const form = panel.querySelector("form")!;
const state = { form, positions: [] as Array<{ active: boolean; y: number }>, done: false };
Object.assign(window, { __agentComposerTransition: state });
await panel.evaluate(async (element) => {
await Promise.allSettled(element.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getComputedTiming().iterations !== Infinity)
.map((animation) => animation.finished));
});
await page.evaluate(() => { Object.assign(window, { __agentComposerBefore: document.querySelector('aside[aria-label="Agent 命令面板"] form') }); });
const before = (await form.boundingBox())!;
await form.evaluate((element) => {
const positions: number[] = [];
Object.assign(window, { composerMotionPositions: positions });
const start = performance.now();
const sample = () => {
state.positions.push({ active: panel.getAttribute("data-agent-starting") === "false", y: form.getBoundingClientRect().y });
if (performance.now() - start < 1000) requestAnimationFrame(sample);
else state.done = true;
positions.push(element.getBoundingClientRect().y);
if (performance.now() - start < 700) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(panel).toHaveAttribute("data-agent-starting", "false");
await expect.poll(() => page.evaluate(() => (window as typeof window & {
__agentComposerTransition: { done: boolean }
}).__agentComposerTransition.done)).toBe(true);
const afterTop = (await form.boundingBox())!.y;
const result = await page.evaluate(() => {
const state = (window as typeof window & { __agentComposerTransition: {
form: HTMLFormElement; positions: Array<{ active: boolean; y: number }>
} }).__agentComposerTransition;
return { sameForm: state.form === document.querySelector('aside[aria-label="Agent 命令面板"] form'), positions: state.positions };
});
expect(result.sameForm).toBe(true);
expect(afterTop).toBeGreaterThan(beforeTop);
expect(result.positions.some((position) => position.active && position.y > beforeTop + 4 && position.y < afterTop - 4)).toBe(true);
await expect(panel.locator(".agent-panel-user-message")).toBeVisible();
await expect(panel.locator(".agent-panel-composer-motion")).toHaveCSS("transform", "none");
const after = (await form.boundingBox())!;
expect(after.y).toBeGreaterThan(before.y + 100);
await expect(panel).toHaveAttribute("data-agent-centered", "false");
const panelBox = (await panel.boundingBox())!;
expect(Math.abs(after.y + after.height - (panelBox.y + panelBox.height - 8))).toBeLessThan(1);
expect(Math.abs(after.height - before.height)).toBeLessThan(1);
await expect.poll(() => page.evaluate(({ start, end }) =>
(window as unknown as { composerMotionPositions: number[] }).composerMotionPositions
.some((y) => y > start + 2 && y < end - 2),
{ start: before.y, end: after.y })).toBe(true);
expect(await page.evaluate(() => (window as typeof window & { __agentComposerBefore: Element }).__agentComposerBefore === document.querySelector('aside[aria-label="Agent 命令面板"] form'))).toBe(true);
await expect(panel.locator("form")).toHaveCount(1);
await panel.getByRole("button", { name: "新建 Agent 对话" }).click();
await expect(panel.locator(".agent-panel-composer-motion")).toHaveCSS("transform", "none");
await expectCenteredStart(panel);
await expect(form.locator("textarea")).toHaveValue("");
});
for (const width of [320, 375]) {
test(`centered first question and restored persona fit at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel.getByRole("heading", { name: "有什么可以帮你?" })).toBeVisible();
await expect(panel.getByRole("group", { name: "推荐问题" }).getByRole("button")).toHaveCount(3);
const form = (await panel.locator("form").boundingBox())!;
expect(form.x).toBeGreaterThanOrEqual(0);
expect(form.x + form.width).toBeLessThanOrEqual(width);
expect(form.height).toBeLessThan(150);
await expectCenteredStart(panel);
await expect(panel.locator("header").getByLabel("Agent 状态")).toBeVisible();
await preparePreview(page);
await panel.screenshot({ path: `/tmp/nexttjwater-agent-start-preview/centered-${width}.png` });
await panel.locator("textarea").fill("短屏下也能完成首次提问");
await page.setViewportSize({ width, height: 480 });
const send = panel.getByRole("button", { name: "发送 Agent 指令" });
await send.scrollIntoViewIfNeeded();
expect(await panel.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
const sendBox = (await send.boundingBox())!;
expect(sendBox.y + sendBox.height).toBeLessThanOrEqual(480);
await send.click();
await expect(panel.locator(".agent-panel-user-message")).toHaveText("短屏下也能完成首次提问");
await expect(panel).toHaveAttribute("data-agent-centered", "false");
});
}