源码
tests/desktop/message-actions.spec.ts
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ElectronApplication, Locator, Page } from "@playwright/test";
import { chunk, done, fakeServer, send } from "../fake-server.ts";
import { runCleanup } from "../helpers/cleanup.ts";
import { launchDesktop } from "../helpers/desktop.ts";
import { expect, test } from "../helpers/test.ts";
async function selectText(locator: Locator) {
await locator.click();
await locator.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});
}
test("scrolling then opening a conversation reference dismisses the old text selection", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-selection-dismiss-"));
const project = join(dir, "project");
await mkdir(project);
const server = await fakeServer((_, response, index) => {
send(
response,
chunk({
content:
index === 0
? Array.from({ length: 60 }, (_, i) => `Paragraph ${i + 1}: selected conversation text`).join(
"\n\n",
)
: "Reference received",
}),
);
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: server.url });
const page = await app.firstWindow();
await page.getByRole("button", { name: "添加项目", exact: true }).first().click();
await expect(page.getByRole("button", { name: "选择项目", exact: true })).toContainText("project");
const composer = page.locator(".composer-container").getByLabel("消息", { exact: true });
await composer.fill("first message");
await page.getByLabel("发送", { exact: true }).click();
await expect(page.getByTestId("run").first()).toHaveAttribute("data-status", "completed");
const paragraph = page.getByTestId("run").first().locator(".answer p").first();
const add = page.getByRole("button", { name: "添加到当前任务", exact: true });
await paragraph.scrollIntoViewIfNeeded();
await paragraph.click();
await selectText(paragraph);
await expect(add).toBeVisible();
await add.click();
await expect(composer).toBeFocused();
await composer.fill("explain the reference");
await page.getByLabel("发送", { exact: true }).click();
await expect(page.getByTestId("run")).toHaveCount(2);
await expect(page.getByTestId("run").last()).toHaveAttribute("data-status", "completed");
await paragraph.scrollIntoViewIfNeeded();
await paragraph.click();
await selectText(paragraph);
await expect(add).toBeVisible();
await page.locator(".conversation").evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect(add).toHaveCount(0);
expect(await page.evaluate(() => window.getSelection()?.toString())).toContain("Paragraph 1");
const reference = page.getByTestId("run").last().getByRole("button", { name: "1 条对话引用" });
await reference.click();
await expect(page.getByRole("dialog", { name: "引用内容" })).toContainText("Paragraph 1");
await expect.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed)).toBe(true);
await expect(add).toHaveCount(0);
await page.keyboard.press("Escape");
await page.mouse.move(0, 0);
// Focus changes without a pointer event also retire the previous selection.
await paragraph.scrollIntoViewIfNeeded();
await paragraph.click();
await selectText(paragraph);
await expect(add).toBeVisible();
await composer.focus();
await expect(add).toHaveCount(0);
expect(await page.evaluate(() => window.getSelection()?.isCollapsed)).toBe(true);
await paragraph.scrollIntoViewIfNeeded();
await paragraph.click();
await selectText(paragraph);
await expect(add).toBeVisible();
await page.keyboard.press("Escape");
await expect(add).toHaveCount(0);
await page.keyboard.press("Shift");
await expect(add).toHaveCount(0);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("selections inside or crossing Markdown file and web links can be added to the current task", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-link-selection-"));
const project = join(dir, "project");
await mkdir(project);
await writeFile(join(project, "notes.md"), "document");
const server = await fakeServer((_, response) => {
send(
response,
chunk({ content: "正文 [文件链接](./notes.md) 中间 [网页链接](https://example.com) 结束" }),
);
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: server.url });
await app.evaluate(({ shell }) => {
shell.openExternal = async () => {
throw Error("Selecting a link must not open a browser");
};
});
const page = await app.firstWindow();
await page.getByRole("button", { name: "添加项目", exact: true }).first().click();
await expect(page.getByRole("button", { name: "选择项目", exact: true })).toContainText("project");
await page.getByLabel("消息", { exact: true }).fill("链接划词");
await page.getByLabel("发送", { exact: true }).click();
const run = page.getByTestId("run");
await expect(run).toHaveAttribute("data-status", "completed");
const paragraph = run.locator(".answer p");
for (const selector of [".message-file-link .reference-label", ".message-web-link"]) {
const bounds = await paragraph.locator(selector).boundingBox();
if (!bounds) throw Error("Missing link layout");
await page.mouse.move(bounds.x + 1, bounds.y + bounds.height / 2);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width - 1, bounds.y + bounds.height / 2, { steps: 8 });
await page.mouse.up();
await expect(page.getByRole("button", { name: "添加到当前任务", exact: true })).toBeVisible();
await expect(page.getByRole("tab", { name: "notes.md", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
for (const [start, end, expected] of [
["file", "file", "文件链接"],
["web", "web", "网页链接"],
["before", "file", "正文 文件链接"],
["file", "after", "文件链接 中间 网页链接 结束"],
["file", "web", "文件链接 中间 网页链接"],
]) {
const selectedText = await paragraph.evaluate(
(element, { start, end }) => {
const nodes: Record<string, Node | null | undefined> = {
file: element.querySelector(".message-file-link .reference-label")?.firstChild,
web: element.querySelector(".message-web-link")?.firstChild,
before: element.firstChild,
after: element.lastChild,
};
const first = nodes[start],
last = nodes[end];
if (!first || !last) throw Error("Missing link selection endpoints");
const range = document.createRange();
range.setStart(first, 0);
range.setEnd(last, last.textContent?.length ?? 0);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
return window.getSelection()?.toString().trim() ?? "";
},
{ start, end },
);
expect(selectedText.replace(/\s+/g, " ")).toBe(expected);
const add = page.getByRole("button", { name: "添加到当前任务", exact: true });
await expect(add).toBeVisible();
await add.click();
await expect
.poll(async () => {
const result = await page.evaluate(async () => {
const id = localStorage.getItem("zpi.selectedSession") as string;
return window.zpi.getDraft(id);
});
return result.ok ? result.value.selections?.[0]?.text : undefined;
})
.toBe(selectedText);
await expect(page.getByRole("tab", { name: "notes.md", exact: true })).toHaveCount(0);
await page.locator(".composer-container").getByLabel("移除对话引用").click();
}
await selectText(paragraph.locator(".message-file-link"));
await expect(page.getByRole("button", { name: "添加到当前任务", exact: true })).toBeVisible();
await page.screenshot({ path: "test-results/markdown-link-selection.png" });
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
async function expectZCodeSurfaces(page: Page, bubble: Locator, reference: Locator) {
for (const theme of ["light", "dark"]) {
await page.evaluate((value) => document.documentElement.setAttribute("data-theme", value), theme);
await page.mouse.move(0, 0);
const fill = theme === "light" ? "rgba(13, 13, 13, 0.03)" : "rgba(255, 255, 255, 0.05)";
const hover = theme === "light" ? "rgba(13, 13, 13, 0.05)" : "rgba(255, 255, 255, 0.1)";
await expect(bubble).toHaveCSS("background-color", fill);
await expect(reference).toHaveCSS("background-color", fill);
await reference.hover();
await expect(reference).toHaveCSS("background-color", hover);
const popover = page.getByRole("dialog", { name: "引用内容" });
await expect(popover).toHaveCSS(
"background-color",
theme === "light" ? "rgb(240, 240, 240)" : "rgb(43, 43, 43)",
);
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
}
await page.evaluate(() => document.documentElement.setAttribute("data-theme", "light"));
await page.mouse.move(0, 0);
}
test("message hover actions, inline editing, selection wire format, draft restart and fork navigation", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-message-actions-")),
project = join(dir, "project");
await mkdir(project);
const server = await fakeServer((_, res, index) => {
send(res, chunk({ content: `**reply ${index + 1}**: selected text` }));
done(res);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: server.url });
let page = await app.firstWindow();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.getByRole("button", { name: "添加项目", exact: true }).first().click();
await expect(page.getByRole("button", { name: "选择项目", exact: true })).toContainText("project");
await page.getByLabel("消息", { exact: true }).fill("first user input");
await page.getByLabel("发送", { exact: true }).click();
const run = page.getByTestId("run");
await expect(run).toHaveAttribute("data-status", "completed");
await page.mouse.move(0, 0);
await expect(run.locator(".user-message-actions")).toHaveCSS("opacity", "0");
await run.locator(".user-message-text").hover();
await expect(run.locator(".user-message-actions")).toHaveCSS("opacity", "1");
const userCopy = run.locator(".user-message-actions").getByLabel("复制", { exact: true });
await expect(userCopy.locator("svg")).toHaveAttribute("stroke-width", "1.5");
await expect(run.getByLabel("编辑", { exact: true }).locator("svg")).toHaveAttribute(
"stroke-width",
"1.5",
);
await userCopy.click();
expect(await app.evaluate(({ clipboard }) => clipboard.readText())).toBe("first user input");
await expect(userCopy.locator(".lucide-check")).toHaveCount(1);
await expect(userCopy.locator(".lucide-copy")).toHaveCount(1);
await run.locator(".assistant-message-row").hover();
await expect(run.locator(".assistant-message-actions")).toHaveCSS("opacity", "1");
await expect(run.getByLabel("分叉", { exact: true }).locator("svg")).toHaveAttribute(
"stroke-width",
"1.5",
);
await expect(run.locator(".message-time")).toHaveText(/\d{2}:\d{2}/);
await run.locator(".assistant-message-actions").getByLabel("复制", { exact: true }).click();
expect(await app.evaluate(({ clipboard }) => clipboard.readText())).toBe("**reply 1**: selected text");
await page.getByLabel("消息", { exact: true }).fill("keep main draft");
await run.getByLabel("编辑", { exact: true }).click();
const editing = page.getByRole("region", { name: "编辑消息" });
await expect(editing.getByLabel("消息", { exact: true })).toBeFocused();
await expect(editing.getByLabel("消息", { exact: true })).toHaveText("first user input");
await editing.getByLabel("消息", { exact: true }).fill("cancel edit");
await editing.getByLabel("消息", { exact: true }).press("Escape");
await expect(editing).toHaveCount(0);
await expect(page.getByLabel("消息", { exact: true })).toHaveText("keep main draft");
await run.getByLabel("编辑", { exact: true }).click();
await editing.getByLabel("消息", { exact: true }).fill("edited user input");
await editing.getByLabel("发送", { exact: true }).click();
await expect(run).toHaveCount(1);
await expect(run).toHaveAttribute("data-status", "completed");
await expect(run.locator(".user-message-text")).toHaveText("edited user input");
await expect(run.locator(".answer")).toContainText("reply 2");
const composer = page.locator(".composer-container").getByLabel("消息", { exact: true });
await composer.fill("explain");
await composer.evaluate((element) => {
const range = document.createRange();
range.setStart(element.firstChild as Node, 1);
range.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
});
await composer.evaluate((element) => element.blur());
await selectText(run.locator(".answer p"));
const add = page.getByRole("button", { name: "添加到当前任务", exact: true });
await expect(add).toBeVisible();
await expect(page.getByRole("button", { name: "在辅助对话中提问", exact: true })).toBeVisible();
await add.click();
await expect(composer).toBeFocused();
await page.keyboard.insertText("X");
await expect(composer).toHaveText("eXxplain");
await composer.press("Meta+Z");
await expect(composer).toHaveText("explain");
await selectText(run.locator(".answer p"));
await add.click();
await expect(composer).toBeFocused();
await expect(
page.locator(".composer-container [data-conversation-selection-reference-count]"),
).toHaveAttribute("data-conversation-selection-reference-count", "1");
await expect(page.locator(".composer-container").getByLabel("消息", { exact: true })).toHaveText(
"explain",
);
await expectZCodeSurfaces(
page,
run.locator(".user-message"),
page.locator(".composer-container .selection-reference-chip"),
);
await page.screenshot({ path: "test-results/message-actions-light.png" });
await page.locator(".composer-container .selection-reference-chip").hover();
const popover = page.getByRole("dialog", { name: "引用内容" });
await expect(popover).toContainText("reply 2: selected text");
const bounds = await popover.boundingBox();
expect(bounds && bounds.y >= 12).toBeTruthy();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
// Wait for the canonical draft write before exercising process restart.
await expect
.poll(async () => {
const draft = await page.evaluate(async () => {
const sessions = await window.zpi.listRecentSessions();
return sessions.ok ? window.zpi.getDraft(sessions.value[0].id) : null;
});
return draft?.ok ? draft.value.selections?.length : 0;
})
.toBe(1);
await app.close();
app = await launchDesktop({ dir, project, url: server.url });
page = await app.firstWindow();
await expect(
page.locator(".composer-container [data-conversation-selection-reference-count]"),
).toHaveAttribute("data-conversation-selection-reference-count", "1");
await page.getByLabel("发送", { exact: true }).click();
await expect(page.getByTestId("run")).toHaveCount(2);
await expect(page.getByTestId("run").last()).toHaveAttribute("data-status", "completed");
const messages = server.requests.at(-1)?.messages as unknown as { role: string; content: string }[];
expect(messages.filter((message) => message.role === "user").at(-1)?.content).toContain(
'# userselect:\n```userselect\n[{"text":"reply 2: selected text"}]\n```',
);
await expect(page.getByTestId("run").last().locator(".user-message-text")).toHaveText("explain");
await expect(
page.getByTestId("run").last().locator("[data-conversation-selection-reference-count]"),
).toHaveCount(1);
await page
.getByTestId("run")
.last()
.locator(".user-message-actions")
.getByLabel("复制", { exact: true })
.click();
expect(await app.evaluate(({ clipboard }) => clipboard.readText())).toBe("explain");
await expectZCodeSurfaces(
page,
page.getByTestId("run").last().locator(".user-message"),
page.getByTestId("run").last().locator(".selection-reference-chip"),
);
const parent = await page.evaluate(async () => {
const sessions = await window.zpi.listRecentSessions();
return sessions.ok ? sessions.value[0].id : "";
});
await page.getByTestId("run").first().getByLabel("分叉", { exact: true }).click();
await expect(page.getByTestId("run")).toHaveCount(1);
await expect(page.getByTestId("run").first().locator(".user-message-text")).toHaveText(
"edited user input",
);
await expect(page.getByLabel("消息", { exact: true })).toBeFocused();
const sessions = await page.evaluate(() => window.zpi.listRecentSessions());
expect(sessions.ok && sessions.value.filter((session) => !session.draft).length).toBe(2);
const snapshot = await page.evaluate((id) => window.zpi.getSessionSnapshot(id), parent);
expect(snapshot.ok && snapshot.value.view.runs.length).toBe(2);
await page.evaluate(() => document.documentElement.setAttribute("data-theme", "dark"));
await page.getByTestId("run").first().locator(".assistant-message-row").hover();
await page.screenshot({ path: "test-results/message-actions-dark.png" });
await page.getByRole("button", { name: "从对话中派生", exact: true }).click();
await expect(page.getByTestId("run")).toHaveCount(2);
await expect(page.getByTestId("run").first().getByLabel("编辑", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("run").last().getByLabel("编辑", { exact: true })).toHaveCount(1);
expect(errors).toEqual([]);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("Markdown selections use source paths, cross-message selections are excluded and edit file conflicts retain the original turn", async () => {
const dir = await mkdtemp(join(tmpdir(), "zpi-message-conflict-")),
project = join(dir, "project");
await mkdir(project);
const documentPath = join(project, "notes.md"),
changed = join(project, "changed.txt");
await writeFile(documentPath, "# Notes\n\nselected document text\n");
await writeFile(changed, "original\n");
const server = await fakeServer((_, res, index) => {
if (index === 0) {
send(
res,
chunk({
tool_calls: [
{
index: 0,
id: "write-file",
type: "function",
function: {
name: "write",
arguments: JSON.stringify({ path: "changed.txt", content: "agent\n" }),
},
},
],
}),
);
done(res, "tool_calls");
} else {
send(res, chunk({ content: "done\n\n[预览文档](./notes.md)" }));
done(res);
}
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: server.url });
const page = await app.firstWindow();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.getByRole("button", { name: "添加项目", exact: true }).first().click();
await expect(page.getByRole("button", { name: "选择项目", exact: true })).toContainText("project");
await page.getByLabel("消息", { exact: true }).fill("change file");
await page.getByLabel("发送", { exact: true }).click();
const turn = page.getByTestId("run");
await expect(turn).toHaveAttribute("data-status", "completed");
await page.locator(".answer").getByRole("button", { name: "预览文档", exact: true }).click();
await expect(page.locator(".file-markdown-preview p")).toHaveText("selected document text");
await selectText(page.locator(".file-markdown-preview p"));
await page.getByRole("button", { name: "添加到当前任务", exact: true }).click();
await expect(page.locator(".composer-container").getByLabel("消息", { exact: true })).toBeFocused();
await expect(page.locator(".composer-container .selection-reference-chip")).toContainText(
"notes.md · 引用",
);
await expect
.poll(async () => {
const draft = await page.evaluate(async () => {
const id = localStorage.getItem("zpi.selectedSession") as string;
return window.zpi.getDraft(id);
});
return draft.ok ? draft.value.selections?.[0] : undefined;
})
.toMatchObject({
path: await realpath(documentPath),
text: "selected document text",
});
await page.locator(".composer-container").getByLabel("移除对话引用").click();
await expect(page.locator(".composer-container .selection-reference-chip")).toHaveCount(0);
await turn.evaluate((element) => {
const range = document.createRange();
const user = element.querySelector(".user-message-text"),
answer = element.querySelector(".answer p");
if (!user || !answer) throw new Error("missing content");
range.setStart(user, 0);
range.setEnd(answer, answer.childNodes.length);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});
await expect(page.getByRole("button", { name: "添加到当前任务", exact: true })).toHaveCount(0);
await writeFile(changed, "external\n");
await turn.getByLabel("编辑", { exact: true }).click();
const editing = page.getByRole("region", { name: "编辑消息" });
await editing.getByLabel("消息", { exact: true }).fill("edited after conflict");
await editing.getByLabel("对话 + 文件重置", { exact: true }).click();
const conflict = page.getByRole("dialog", { name: "文件无法安全重置" });
await expect(conflict).toBeVisible();
await expect(conflict).toContainText("当前文件已被外部修改");
expect(await readFile(changed, "utf8")).toBe("external\n");
await expect(turn.locator(".user-message-text")).toHaveCount(0);
await expect(turn).toHaveCount(1);
await page.screenshot({ path: "test-results/message-actions-file-conflict.png" });
await conflict.getByRole("button", { name: "取消", exact: true }).click();
await expect(editing.getByLabel("消息", { exact: true })).toHaveText("edited after conflict");
await editing.getByLabel("对话 + 文件重置", { exact: true }).click();
await conflict.getByRole("button", { name: "仅重置对话并发送", exact: true }).click();
await expect(conflict).toHaveCount(0);
await expect(turn).toHaveCount(1);
await expect(turn).toHaveAttribute("data-status", "completed");
await expect(turn.locator(".user-message-text")).toHaveText("edited after conflict");
expect(await readFile(changed, "utf8")).toBe("external\n");
expect(errors).toEqual([]);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});