mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
fix(agents): improve chat copying, selection and scrolling (#1905)
* fix(agents): improve chat transcript interactions * fix(agents): recover images when regeneration fails * test(agents): use the native copy shortcut on every host
This commit is contained in:
1 parent
5804fcad16
commit
ee6786d573
19 files changed
+997
-45
No files matched your search
+189
-1
@@ -19,6 +19,7 @@ const TASK = {
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
capabilities: { fork: true, rollback: true, steer: true, images: true },
|
||||
};
|
||||
|
||||
const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
@@ -65,7 +66,15 @@ async function openReply(page: Page, text: string, imageData?: string) {
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, [{ type: "agent", text }]]]),
|
||||
entries: new Map([
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{ type: "user", text: "Show Markdown", attachments: [], sentMs: 0, doneMs: 1, steered: false },
|
||||
{ type: "agent", text },
|
||||
],
|
||||
],
|
||||
]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
@@ -149,3 +158,182 @@ test("loads local and web reply images", async ({ page }) => {
|
||||
.toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("copies reply Markdown with the keyboard and keeps code copying separate", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const reply = await openReply(page, REPLY);
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
const actions = copy.locator("..");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
await copy.focus();
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(REPLY);
|
||||
await reply.getByRole("button", { name: "Copy Code", exact: true }).click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
|
||||
});
|
||||
|
||||
test("reveals message actions on hover without shifting content and quotes into the draft", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
const actions = copy.locator("..");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
const before = await reply.boundingBox();
|
||||
await reply.hover();
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
expect(await reply.boundingBox()).toEqual(before);
|
||||
await expect(actions.getByRole("button")).toHaveCount(4);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
const userCopy = page.getByRole("button", { name: "Copy", exact: true });
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "0");
|
||||
await page.getByText("Show Markdown", { exact: true }).hover();
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await reply.hover();
|
||||
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
|
||||
const draft = page.getByRole("textbox", { name: "Message", exact: true });
|
||||
await expect(draft).toBeFocused();
|
||||
await expect(draft).toHaveValue(
|
||||
REPLY.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => (line ? `> ${line}` : ">"))
|
||||
.join("\n") + "\n\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps chat history readable and selections free of timestamps and status labels", async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 1800, height: 1000 });
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await openReply(page, "Hi! What would you like to work on?");
|
||||
await page.evaluate(async (task) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const user = (text: string) => ({ type: "user", text, attachments: [], sentMs: 1, doneMs: 5001, steered: false });
|
||||
store.useAgents.setState({
|
||||
entries: new Map([
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
user("hi"),
|
||||
{ type: "agent", text: "Hi! What would you like to work on?" },
|
||||
user("im bob"),
|
||||
{ type: "agent", text: "Hi Bob! What are we working on today?" },
|
||||
user("yes"),
|
||||
{ type: "thought", text: "**Requesting task context**" },
|
||||
{ type: "agent", text: "What would you like me to proceed with?" },
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
store.openTaskInEditorLocation(task.id);
|
||||
}, TASK);
|
||||
const stream = page.locator("[class*='_stream_']:visible");
|
||||
const replies = stream.locator("[class*='_reply_']");
|
||||
await expect(replies).toHaveCount(3);
|
||||
const geometry = await stream.evaluate((root) => {
|
||||
const reply = root.querySelector<HTMLElement>("[class*='_reply_']")!;
|
||||
const bubble = root.querySelector<HTMLElement>("[class*='_user_']")!;
|
||||
const composer = root
|
||||
.closest("[class*='_conversation_']")!
|
||||
.querySelector<HTMLElement>("[class*='_composerArea_']")!;
|
||||
const rr = reply.getBoundingClientRect();
|
||||
const br = bubble.getBoundingClientRect();
|
||||
const cr = composer.getBoundingClientRect();
|
||||
return {
|
||||
reply: rr.width,
|
||||
root: root.clientWidth,
|
||||
bubbleLeft: br.left,
|
||||
replyLeft: rr.left,
|
||||
bubbleRight: br.right,
|
||||
replyRight: rr.right,
|
||||
columnCenter: (rr.left + rr.right) / 2,
|
||||
composerCenter: (cr.left + cr.right) / 2,
|
||||
};
|
||||
});
|
||||
expect(geometry.reply).toBe(900);
|
||||
expect(geometry.reply).toBeLessThan(geometry.root);
|
||||
expect(Math.abs(geometry.bubbleRight - geometry.replyRight)).toBeLessThan(1);
|
||||
expect(Math.abs(geometry.bubbleLeft - geometry.replyLeft)).toBeLessThan(1);
|
||||
expect(Math.abs(geometry.columnCenter - geometry.composerCenter)).toBeLessThan(1);
|
||||
await stream.getByText("hi", { exact: true }).hover();
|
||||
await expect(stream.locator("[class*='_messageMeta_']").first()).toHaveCSS("opacity", "1");
|
||||
const selected = await stream.evaluate((root) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(root);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return selection.toString();
|
||||
});
|
||||
expect(selected).toContain("hi");
|
||||
expect(selected).toContain("Hi Bob!");
|
||||
expect(selected).toContain("What would you like me to proceed with?");
|
||||
expect(selected).not.toMatch(/AM|PM|5s|Reasoning|Requesting task context/);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
||||
await stream.locator("[class*='_thought_'] > summary").click();
|
||||
await expect(stream.getByText("Requesting task context", { exact: true })).toBeVisible();
|
||||
await expect(stream.locator("[class*='_thoughtBody_'] strong")).toHaveText("Requesting task context");
|
||||
await page.evaluate(async (id) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
store.dockTask(id);
|
||||
}, TASK.id);
|
||||
await expect(stream).toBeVisible();
|
||||
const narrow = await stream.evaluate((root) => ({
|
||||
width: root.clientWidth,
|
||||
content: root.scrollWidth,
|
||||
reply: root.querySelector<HTMLElement>("[class*='_reply_']")!.getBoundingClientRect().width,
|
||||
padding:
|
||||
Number.parseFloat(getComputedStyle(root).paddingLeft) + Number.parseFloat(getComputedStyle(root).paddingRight),
|
||||
}));
|
||||
expect(narrow.width).toBeGreaterThan(0);
|
||||
expect(narrow.content).toBeLessThanOrEqual(narrow.width);
|
||||
expect(Math.abs(narrow.reply - (narrow.width - narrow.padding))).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("keeps hover actions reachable when the pointer leaves the message body", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const reply = await openReply(page, "A short answer.");
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
await reply.hover();
|
||||
const box = await copy.boundingBox();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, { steps: 20 });
|
||||
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await copy.click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
|
||||
});
|
||||
|
||||
test("selects through code blocks and tables while keeping copy controls outside the code", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const tree = "rust/\n├── Cargo.toml\n└── src/\n └── main.rs";
|
||||
const flow = "内置文本 SAMPLE\n ↓ parser::parse\nVec<Item>\n ↓ Inventory::add\n终端输出";
|
||||
const text = `项目结构:\n\n\`\`\`text\n${tree}\n\`\`\`\n\n| 模块 | 职责 |\n| --- | --- |\n| main.rs | 程序入口 |\n\n数据流是:\n\n\`\`\`text\n${flow}\n\`\`\``;
|
||||
const reply = await openReply(page, text);
|
||||
await expect(reply.getByRole("button", { name: "Copy Code", exact: true })).toHaveCount(2);
|
||||
const selected = await reply.evaluate((root) => {
|
||||
const codes = [...root.querySelectorAll("pre > code")];
|
||||
if (codes.some((code) => code.parentElement!.querySelector("button"))) throw new Error("Control in code selection");
|
||||
const start = root.querySelector("p")!.firstChild!;
|
||||
const end = codes.at(-1)!.lastChild!;
|
||||
window.getSelection()!.setBaseAndExtent(start, 0, end, end.textContent!.length);
|
||||
return window.getSelection()!.toString();
|
||||
});
|
||||
expect(selected).toContain(tree);
|
||||
expect(selected).toContain("程序入口");
|
||||
expect(selected).toContain(flow);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
||||
await page.evaluate(() => window.getSelection()!.removeAllRanges());
|
||||
const copies = reply.getByRole("button", { name: "Copy Code", exact: true });
|
||||
await copies.first().click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(tree);
|
||||
await copies.last().click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(flow);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { AgentEntry, AgentTask } from "../src/bindings";
|
||||
|
||||
const task: AgentTask = {
|
||||
id: "long-chat",
|
||||
title: "Long conversation",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
provider: "codex",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
const entries: AgentEntry[] = Array.from({ length: 80 }, (_, turn): AgentEntry[] => [
|
||||
{ type: "user", text: `Request ${turn}`, attachments: [], steered: false },
|
||||
...["read", "execute", "edit"].map((kind): AgentEntry => ({
|
||||
type: "tool",
|
||||
id: `${turn}-${kind}`,
|
||||
kind,
|
||||
title: `${kind} src/module-${turn}.ts`,
|
||||
status: "completed",
|
||||
locations: [],
|
||||
output: Array.from({ length: 80 }, (_, line) => `output ${turn}:${line}`).join("\n"),
|
||||
diffs: [],
|
||||
})),
|
||||
{ type: "thought", text: `Checking module ${turn}.\n\n${"Details of the investigation. ".repeat(30)}` },
|
||||
{
|
||||
type: "agent",
|
||||
text: `Reply ${turn}\n\n- Checked the module.\n- Updated the implementation.\n\n\`\`\`text\nmodule-${turn}\n src/\n tests/\n\`\`\``,
|
||||
},
|
||||
]).flat();
|
||||
|
||||
async function openHistory(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.evaluate(
|
||||
async ({ task, entries }) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, entries]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
store.openTaskInEditorLocation(task.id);
|
||||
},
|
||||
{ task, entries },
|
||||
);
|
||||
const stream = page.locator("[class*='_stream_']:visible");
|
||||
await expect(stream.locator(":scope > *")).toHaveCount(entries.length);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
return stream;
|
||||
}
|
||||
|
||||
for (const selected of [false, true]) {
|
||||
test(`keeps long-chat scroll geometry stable while jumping through history (selected: ${selected})`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const stream = await openHistory(page);
|
||||
const result = await stream.evaluate(async (el, selected) => {
|
||||
let text = "";
|
||||
if (selected) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
window.getSelection()!.removeAllRanges();
|
||||
window.getSelection()!.addRange(range);
|
||||
text = window.getSelection()!.toString();
|
||||
}
|
||||
await new Promise(requestAnimationFrame);
|
||||
const initialHeight = el.scrollHeight;
|
||||
const samples: Array<{ height: number; drift: number }> = [];
|
||||
// A thumb can jump into previously unvisited rows in either direction.
|
||||
for (const fraction of [0, 0.75, 0.25, 1, 0.5, 0.1, 0.9, 0]) {
|
||||
el.scrollTop = fraction * (initialHeight - el.clientHeight);
|
||||
const target = el.scrollTop;
|
||||
await new Promise(requestAnimationFrame);
|
||||
await new Promise(requestAnimationFrame);
|
||||
samples.push({ height: el.scrollHeight, drift: Math.abs(el.scrollTop - target) });
|
||||
}
|
||||
return { initialHeight, samples, text, after: window.getSelection()!.toString() };
|
||||
}, selected);
|
||||
expect(result.initialHeight).toBeGreaterThan(10_000);
|
||||
for (const sample of result.samples) {
|
||||
expect(Math.abs(sample.height - result.initialHeight)).toBeLessThanOrEqual(1);
|
||||
expect(sample.drift).toBeLessThanOrEqual(1);
|
||||
}
|
||||
if (selected) {
|
||||
expect(result.text).toContain("Request 0");
|
||||
expect(result.text).toContain("Reply 79");
|
||||
expect(result.after).toBe(result.text);
|
||||
await stream.focus();
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(result.text);
|
||||
await page.evaluate(() => window.getSelection()!.removeAllRanges());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { registerAction } from "@/platform/actions";
|
||||
import { AgentReply } from "./AgentReply";
|
||||
import { copyReplyAction } from "./replyActions";
|
||||
|
||||
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
|
||||
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify }) }));
|
||||
|
||||
const writeText = vi.fn();
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
writeText.mockReset().mockResolvedValue(undefined);
|
||||
notify.mockReset();
|
||||
vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
|
||||
dispose = registerAction(copyReplyAction);
|
||||
});
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const reply = [
|
||||
"**OpenIDEA v0.1.38 is out!**",
|
||||
"",
|
||||
"## Highlights",
|
||||
"",
|
||||
"- **Docker & Compose** run targets.",
|
||||
"- [Release notes](https://example.com/releases)",
|
||||
"",
|
||||
"```sh",
|
||||
"pnpm tauri dev",
|
||||
"```",
|
||||
"",
|
||||
"| Feature | Status |",
|
||||
"| --- | --- |",
|
||||
"| 中文 | Ready |",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
it("copies the exact Markdown source and briefly confirms success", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<AgentReply text={reply} cwd="/repo" />);
|
||||
expect(screen.getByRole("heading", { name: "Highlights" })).toBeInTheDocument();
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" })));
|
||||
expect(writeText).toHaveBeenCalledExactlyOnceWith(reply);
|
||||
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(screen.getByRole("button", { name: "Copy Markdown" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies the latest streamed text and clears feedback when content changes", async () => {
|
||||
const { rerender } = render(<AgentReply text="**Partial**" cwd="/repo" />);
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" })));
|
||||
rerender(<AgentReply text={reply} cwd="/repo" />);
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" })));
|
||||
expect(writeText.mock.calls.map(([text]) => text)).toEqual(["**Partial**", reply]);
|
||||
});
|
||||
|
||||
it("reports a clipboard failure without claiming success and allows retry", async () => {
|
||||
writeText.mockRejectedValueOnce(new Error("denied"));
|
||||
render(<AgentReply text={reply} cwd="/repo" />);
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" })));
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", title: "Couldn't copy the reply" }));
|
||||
expect(screen.queryByRole("button", { name: "Copied" })).not.toBeInTheDocument();
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" })));
|
||||
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["", " \n\t"])("does not offer copying an empty reply (%j)", (text) => {
|
||||
render(<AgentReply text={text} cwd="/repo" />);
|
||||
expect(screen.queryByRole("button", { name: "Copy Markdown" })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { AgentTask } from "@/bindings";
|
||||
import { IconButton } from "@/ui";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { copyReplyAction, type CopyReply } from "./replyActions";
|
||||
import { replyOperations, type ReplyTurn } from "./model";
|
||||
import styles from "./Session.module.css";
|
||||
|
||||
export function AgentReply({
|
||||
text,
|
||||
cwd,
|
||||
task,
|
||||
reply,
|
||||
turnCount = 0,
|
||||
onDiagramDrawn,
|
||||
}: {
|
||||
text: string;
|
||||
cwd: string;
|
||||
task?: AgentTask;
|
||||
reply?: ReplyTurn;
|
||||
turnCount?: number;
|
||||
onDiagramDrawn?: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const operations = task ? replyOperations(task, reply, turnCount) : { regenerate: false, fork: false };
|
||||
useEffect(() => {
|
||||
if (copied === null) return;
|
||||
const timer = setTimeout(() => setCopied(null), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [copied]);
|
||||
return (
|
||||
<div className={styles.reply}>
|
||||
<Markdown text={text} cwd={cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
{text.trim() && (
|
||||
<div className={styles.replyFooter}>
|
||||
<span className={clsx(styles.messageActions, styles.replyActions)} data-copied={copied === text || undefined}>
|
||||
<IconButton
|
||||
icon={copied === text ? "check" : "copy"}
|
||||
title={copied === text ? "Copied" : "Copy Markdown"}
|
||||
size="small"
|
||||
action={copyReplyAction.id}
|
||||
actionArg={{ text, onCopied: () => setCopied(text) } satisfies CopyReply}
|
||||
/>
|
||||
{task && task.status !== "archived" && (
|
||||
<IconButton
|
||||
icon="quote"
|
||||
title="Quote Reply"
|
||||
size="small"
|
||||
action="agents.quoteReply"
|
||||
actionArg={{ task: task.id, text }}
|
||||
/>
|
||||
)}
|
||||
{task && reply && operations.regenerate && (
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Regenerate Response"
|
||||
size="small"
|
||||
action="agents.regenerateReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
/>
|
||||
)}
|
||||
{task && reply && operations.fork && (
|
||||
<IconButton
|
||||
icon="branch"
|
||||
title="Branch Conversation from Here"
|
||||
size="small"
|
||||
action="agents.forkReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
|
||||
import { resetMermaidForTests } from "../viewers/markdown/mermaid";
|
||||
import { StrictMode } from "react";
|
||||
import { Markdown } from "./Markdown";
|
||||
|
||||
const { openFile, openExternalUrl } = vi.hoisted(() => ({
|
||||
@@ -40,6 +41,24 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(clearMocks);
|
||||
|
||||
it("keeps one copy control outside each code block when effects restart", () => {
|
||||
const { container } = render(
|
||||
<StrictMode>
|
||||
<Markdown
|
||||
text={"Before\n\n```text\nrust/\n└── src/main.rs\n```\n\n```ts\nconst a = 1;\n```\n\nAfter"}
|
||||
cwd="/repo"
|
||||
/>
|
||||
</StrictMode>,
|
||||
);
|
||||
expect(screen.getAllByRole("button", { name: "Copy Code" })).toHaveLength(2);
|
||||
const blocks = [...container.querySelectorAll("pre")];
|
||||
expect(blocks.map((pre) => pre.textContent)).toEqual(["rust/\n└── src/main.rs", "const a = 1;"]);
|
||||
for (const pre of blocks) {
|
||||
expect(pre.querySelector("button")).toBeNull();
|
||||
expect(pre.parentElement!.querySelectorAll(":scope > button[data-copy]")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"<style>body{outline:7px solid red}</style>",
|
||||
'<form action="https://example.com/x"><button>go</button></form>',
|
||||
|
||||
@@ -84,6 +84,14 @@ function renderHtml(text: string): string {
|
||||
ADD_ATTR: ["data-mermaid"],
|
||||
}) as HTMLElement;
|
||||
root.querySelectorAll("li").forEach(wrapLooseText);
|
||||
// WebKit cuts controls inside <pre> out of a text selection, even when transparent.
|
||||
root.querySelectorAll("pre").forEach((pre) => {
|
||||
if (pre.parentElement?.hasAttribute("data-mermaid")) return;
|
||||
const block = document.createElement("div");
|
||||
block.className = styles.codeBlock!;
|
||||
pre.replaceWith(block);
|
||||
block.appendChild(pre);
|
||||
});
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
@@ -136,6 +144,7 @@ export function openPathLink(link: PathLink) {
|
||||
function enhance(root: HTMLElement, cwd: string, images: Map<string, Promise<string>>): () => void {
|
||||
let alive = true;
|
||||
const disposeImages = loadImages(root, cwd, images);
|
||||
const copies: HTMLButtonElement[] = [];
|
||||
root.querySelectorAll<HTMLElement>("pre > code").forEach((code) => {
|
||||
const pre = code.parentElement!;
|
||||
const lang = [...code.classList].find((c) => c.startsWith("language-"))?.slice("language-".length) ?? null;
|
||||
@@ -149,14 +158,10 @@ function enhance(root: HTMLElement, cwd: string, images: Map<string, Promise<str
|
||||
copy.setAttribute("aria-label", label);
|
||||
copy.appendChild(iconNode("copy", 14));
|
||||
copy.dataset.copy = text;
|
||||
if (pre.parentElement?.hasAttribute("data-mermaid")) {
|
||||
// Mermaid source: not highlighted (the diagram draws over it), and the Copy
|
||||
// button sits on the block — the drawn diagram hides the code block itself.
|
||||
pre.parentElement.appendChild(copy);
|
||||
return;
|
||||
}
|
||||
void highlightInto(code, text, lang);
|
||||
pre.appendChild(copy);
|
||||
const block = pre.parentElement!;
|
||||
block.appendChild(copy);
|
||||
copies.push(copy);
|
||||
if (!block.hasAttribute("data-mermaid")) void highlightInto(code, text, lang);
|
||||
});
|
||||
root.querySelectorAll<HTMLElement>("code").forEach((code) => {
|
||||
if (code.parentElement?.tagName === "PRE" || code.closest("a")) return;
|
||||
@@ -173,6 +178,7 @@ function enhance(root: HTMLElement, cwd: string, images: Map<string, Promise<str
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
copies.forEach((copy) => copy.remove());
|
||||
disposeImages();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* The conversation hugs its content; spare space stays below the composer
|
||||
instead of inside the chat. */
|
||||
.streamArea,
|
||||
.stream {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -149,33 +142,43 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px 16px 8px;
|
||||
padding: calc(var(--space-3) * 2) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: calc(var(--space-3) * 2);
|
||||
}
|
||||
/* Actual row heights keep WebKit's scrollbar and cross-message selections anchored. */
|
||||
.stream > * {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: var(--agent-conversation-width);
|
||||
margin-inline: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.stream:focus {
|
||||
outline: none;
|
||||
}
|
||||
/*
|
||||
* Skip off-screen output layout; editable messages must repaint immediately when focus/content changes.
|
||||
* Thinking and tool output sit in a <div>: on <details> itself WebKit wouldn't expand them.
|
||||
*/
|
||||
.stream > :not(.message):not(details) {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-block-size: auto 80px;
|
||||
}
|
||||
|
||||
/* Your message with its actions, shown on hover. */
|
||||
.message {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.messageMeta {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding-left: 12px;
|
||||
max-width: 100%;
|
||||
padding-top: 2px;
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.messageActions {
|
||||
display: inline-flex;
|
||||
@@ -187,11 +190,48 @@
|
||||
user-select: none;
|
||||
}
|
||||
.message:hover .messageActions,
|
||||
.message:hover .messageMeta,
|
||||
.reply:hover .messageActions,
|
||||
.message:focus-within .messageActions,
|
||||
.message:focus-within .messageMeta,
|
||||
.reply:focus-within .messageActions,
|
||||
.messageActions[data-copied],
|
||||
.messageActions:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.reply {
|
||||
position: relative;
|
||||
}
|
||||
.replyFooter {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
display: flex;
|
||||
min-height: 20px;
|
||||
padding-top: 2px;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.replyActions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.messageActions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.messageMeta,
|
||||
.replyFooter {
|
||||
position: static;
|
||||
margin-top: 4px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.findBar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -218,9 +258,13 @@
|
||||
}
|
||||
|
||||
.user {
|
||||
padding: 2px 0 2px 10px;
|
||||
border-left: 2px solid var(--accent);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg-strong);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -277,11 +321,16 @@
|
||||
overflow: auto;
|
||||
}
|
||||
.markdown :global(pre code) {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.markdown :global(pre) {
|
||||
.codeBlock {
|
||||
position: relative;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.codeBlock > :global(pre) {
|
||||
margin: 0;
|
||||
}
|
||||
.codeCopy {
|
||||
position: absolute;
|
||||
@@ -299,8 +348,10 @@
|
||||
color: var(--fg-secondary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.markdown :global(pre:hover) .codeCopy,
|
||||
.codeBlock:hover > .codeCopy,
|
||||
.markdown :global([data-mermaid]:hover) .codeCopy,
|
||||
.codeCopy:focus-visible {
|
||||
opacity: 1;
|
||||
@@ -418,8 +469,27 @@
|
||||
display: none;
|
||||
}
|
||||
.thought summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: fit-content;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.thought summary:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.thoughtChevron {
|
||||
flex: none;
|
||||
}
|
||||
.thought[open] .thoughtChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.thoughtBody {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.thoughtText {
|
||||
margin-top: 4px;
|
||||
@@ -563,7 +633,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px 12px 12px;
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
max-width: calc(var(--agent-conversation-width) + var(--space-4) * 2);
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2) var(--space-4) var(--space-4);
|
||||
}
|
||||
|
||||
.background {
|
||||
|
||||
@@ -56,6 +56,7 @@ import { canSend, costLabel, emptyDraft, limitLabel, retryParts, usageLabel, wor
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { findRanges, formatDuration, formatSentAt } from "./conversation";
|
||||
import { ImagePreview, imageSrc } from "./ImagePreview";
|
||||
import { AgentReply } from "./AgentReply";
|
||||
import { Markdown } from "./Markdown";
|
||||
import {
|
||||
branchLabel,
|
||||
@@ -68,6 +69,8 @@ import {
|
||||
subagentTitle,
|
||||
subagentWorking,
|
||||
turnNumbers,
|
||||
replyTurns,
|
||||
type ReplyTurn,
|
||||
type SubagentEntry,
|
||||
} from "./model";
|
||||
import {
|
||||
@@ -79,7 +82,6 @@ import {
|
||||
editQueued,
|
||||
forkTask,
|
||||
rerunFrom,
|
||||
quoteInComposer,
|
||||
removeQueued,
|
||||
resendMessage,
|
||||
respond,
|
||||
@@ -492,6 +494,7 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
|
||||
// Forks and undo apply to the task's own turns, not a subagent's conversation.
|
||||
const turns = useMemo(() => (entries && !subagent ? turnNumbers(entries) : null), [entries, subagent]);
|
||||
const replies = useMemo(() => (entries && !subagent ? replyTurns(entries) : null), [entries, subagent]);
|
||||
return (
|
||||
<div className={styles.streamArea}>
|
||||
{finding && <FindBar streamRef={ref} focus={findFocus} onClose={onCloseFind} />}
|
||||
@@ -510,7 +513,7 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
const text = selectionIn(e.currentTarget);
|
||||
const items = copyMenuFor(e.target) ?? [];
|
||||
if (text.trim() && task.status !== "archived") {
|
||||
items.push({ id: "quote", label: "Quote", icon: "edit", run: () => quoteInComposer(task.id, text) });
|
||||
items.push({ kind: "action", action: "agents.quoteReply", label: "Quote", arg: { task: task.id, text } });
|
||||
}
|
||||
if (!items.length) return;
|
||||
e.preventDefault();
|
||||
@@ -529,6 +532,11 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
entry={entry}
|
||||
turn={turns?.get(start + i) ?? null}
|
||||
turnCount={turns?.size ?? 0}
|
||||
reply={replies?.get(start + i)}
|
||||
working={
|
||||
start + i === entries.length - 1 &&
|
||||
(subagent ? subagentWorking(subagent.status) : isBusy(task.status))
|
||||
}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
/>
|
||||
@@ -761,6 +769,8 @@ const Entry = memo(function Entry({
|
||||
entry,
|
||||
turn,
|
||||
turnCount,
|
||||
reply,
|
||||
working,
|
||||
onOpenSubagent,
|
||||
onDiagramDrawn,
|
||||
}: {
|
||||
@@ -769,6 +779,8 @@ const Entry = memo(function Entry({
|
||||
/** Number of the turn this message started (from 0), when it started one. */
|
||||
turn: number | null;
|
||||
turnCount: number;
|
||||
reply?: ReplyTurn;
|
||||
working: boolean;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onDiagramDrawn: () => void;
|
||||
}) {
|
||||
@@ -776,15 +788,31 @@ const Entry = memo(function Entry({
|
||||
case "user":
|
||||
return <UserMessage task={task} entry={entry} turn={turn} turnCount={turnCount} />;
|
||||
case "agent":
|
||||
return <Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />;
|
||||
return (
|
||||
<AgentReply
|
||||
text={entry.text}
|
||||
cwd={task.cwd}
|
||||
task={task}
|
||||
reply={reply}
|
||||
turnCount={turnCount}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
/>
|
||||
);
|
||||
case "thought":
|
||||
return (
|
||||
<div>
|
||||
<details className={styles.thought}>
|
||||
<summary>
|
||||
<T>Thinking</T>
|
||||
{working ? (
|
||||
<Spinner size={12} />
|
||||
) : (
|
||||
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
|
||||
)}
|
||||
<T>{working ? "Thinking" : "Reasoning"}</T>
|
||||
</summary>
|
||||
<div className={styles.thoughtText}>{entry.text}</div>
|
||||
<div className={styles.thoughtBody}>
|
||||
<Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
@@ -829,7 +857,7 @@ const Entry = memo(function Entry({
|
||||
<div className={styles.messageFrom}>
|
||||
<T>Report to the parent agent</T>
|
||||
</div>
|
||||
<Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
<AgentReply text={entry.text} cwd={task.cwd} task={task} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
);
|
||||
case "permission":
|
||||
@@ -1088,7 +1116,12 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry
|
||||
{entry.status === "failed" && <Icon name="error" size={12} />}
|
||||
</>
|
||||
);
|
||||
if (!entry.output) return <div className={styles.tool}>{head}</div>;
|
||||
if (!entry.output)
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.tool}>{head}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<details className={styles.toolDetails}>
|
||||
@@ -1190,7 +1223,7 @@ function SubagentCard({
|
||||
<T>Result</T>
|
||||
</summary>
|
||||
<div className={styles.subagentReply}>
|
||||
<Markdown text={entry.reply} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
<AgentReply text={entry.reply} cwd={task.cwd} task={task} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { sendPaths, sendSelection } from "./references";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import { connect, disconnect, openHome, useAgents } from "./store";
|
||||
import { taskActions } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
/** Settings › Tools › Agents (also used by the Settings window). */
|
||||
export function activateSettingsPages(): () => void {
|
||||
@@ -100,6 +101,7 @@ export function activate(): () => void {
|
||||
}),
|
||||
registerActions([
|
||||
...taskActions,
|
||||
...replyActions,
|
||||
{
|
||||
id: "agents.findInConversation",
|
||||
title: "Find in Conversation",
|
||||
|
||||
@@ -25,7 +25,8 @@ vi.mock("@tauri-apps/api/webview", () => ({
|
||||
}));
|
||||
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { updateComposer, useAgents } from "./store";
|
||||
import { composerDraft, sendMessage, updateComposer, useAgents } from "./store";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
const task: AgentTask = {
|
||||
id: "editing",
|
||||
@@ -64,6 +65,46 @@ async function edit() {
|
||||
return { user, box, send: screen.getAllByRole("button", { name: "Send" })[0]! };
|
||||
}
|
||||
|
||||
it.each([false, true])("recovers images after failed regeneration (new draft: %s)", async (draft) => {
|
||||
const image = { mimeType: "image/png", data: "AAA", name: "shot.png" };
|
||||
const added = { mimeType: "image/png", data: "BBB", name: "added.png" };
|
||||
useAgents.setState({
|
||||
entries: new Map([
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{ type: "user", text: "Original message", attachments: [image.name], images: [image], steered: false },
|
||||
entries[1]!,
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
ipc.rollback.mockImplementation(async () => {
|
||||
useAgents.setState({ entries: new Map([[task.id, []]]) });
|
||||
return { status: "ok", data: null };
|
||||
});
|
||||
let finish!: (value: unknown) => void;
|
||||
ipc.prompt.mockReturnValueOnce(new Promise((resolve) => (finish = resolve)));
|
||||
const running = replyActions
|
||||
.find((a) => a.id === "agents.regenerateReply")!
|
||||
.run({ ctx: {}, arg: { task: task.id, turn: 0 } });
|
||||
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
|
||||
const files = draft ? [{ path: "/project/a.ts", rel: "a.ts" }] : [];
|
||||
if (draft) updateComposer(task.id, { text: "Another draft @a.ts", files, images: [added] });
|
||||
finish({ status: "error", error: { kind: "internal", message: "Send failed" } });
|
||||
await running;
|
||||
const expected = {
|
||||
text: draft ? "Original message\nAnother draft @a.ts" : "Original message",
|
||||
files,
|
||||
images: draft ? [image, added] : [image],
|
||||
};
|
||||
expect(composerDraft(task.id)).toEqual(expected);
|
||||
expect(useAgents.getState().focus?.key).toBe(task.id);
|
||||
await sendMessage(task.id, false);
|
||||
expect(ipc.prompt).toHaveBeenLastCalledWith(task.id, { ...expected, files: files.map((f) => f.path) }, false);
|
||||
expect(ipc.rollback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("a sent message with images", () => {
|
||||
afterEach(() => {
|
||||
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
|
||||
|
||||
@@ -126,6 +126,49 @@ export function turnNumbers(entries: AgentEntry[]): Map<number, number> {
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface ReplyTurn {
|
||||
turn: number;
|
||||
prompt: Extract<AgentEntry, { type: "user" }>;
|
||||
steered: boolean;
|
||||
}
|
||||
|
||||
/** Only the last reply of a turn offers actions that change the conversation. */
|
||||
export function replyTurns(entries: AgentEntry[]): Map<number, ReplyTurn> {
|
||||
const out = new Map<number, ReplyTurn>();
|
||||
let current: ReplyTurn | null = null;
|
||||
let previous: number | null = null;
|
||||
let turn = 0;
|
||||
entries.forEach((entry, i) => {
|
||||
if (entry.type === "user") {
|
||||
if (!entry.steered) {
|
||||
current = { turn: turn++, prompt: entry, steered: false };
|
||||
previous = null;
|
||||
} else if (current) current.steered = true;
|
||||
} else if (entry.type === "agent" && entry.text.trim() && current) {
|
||||
if (previous !== null) out.delete(previous);
|
||||
out.set(i, current);
|
||||
previous = i;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function replyOperations(task: AgentTask, reply: ReplyTurn | undefined, turnCount: number) {
|
||||
const idle = task.status !== "archived" && !isBusy(task.status) && !!reply;
|
||||
const latest = reply?.turn === turnCount - 1;
|
||||
const { fork = false, rollback = false } = task.capabilities ?? {};
|
||||
return {
|
||||
regenerate:
|
||||
idle &&
|
||||
latest &&
|
||||
rollback &&
|
||||
!reply.steered &&
|
||||
!!reply.prompt.text.trim() &&
|
||||
reply.prompt.attachments.length === (reply.prompt.images?.length ?? 0),
|
||||
fork: idle && fork && (rollback || latest),
|
||||
};
|
||||
}
|
||||
|
||||
/** "3m", "2h", "5d" since `ms`. */
|
||||
export function age(ms: number, now = Date.now()): string {
|
||||
const s = Math.max(0, Math.round((now - ms) / 1000));
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentEntry, AgentTask } from "@/bindings";
|
||||
import { replyOperations, replyTurns } from "./model";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
tasks: new Map<string, AgentTask>(),
|
||||
entries: new Map<string, AgentEntry[]>(),
|
||||
forkTask: vi.fn(),
|
||||
rerunFrom: vi.fn(),
|
||||
quoteInComposer: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
vi.mock("./store", () => ({
|
||||
useAgents: { getState: () => backend },
|
||||
forkTask: backend.forkTask,
|
||||
rerunFrom: backend.rerunFrom,
|
||||
quoteInComposer: backend.quoteInComposer,
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: backend.notify }) }));
|
||||
|
||||
const prompt: Extract<AgentEntry, { type: "user" }> = {
|
||||
type: "user",
|
||||
text: "Original request",
|
||||
attachments: [],
|
||||
sentMs: 0,
|
||||
doneMs: 1,
|
||||
steered: false,
|
||||
};
|
||||
const task: AgentTask = {
|
||||
id: "t1",
|
||||
title: "Task",
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
model: null,
|
||||
cwd: "/repo",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
capabilities: { fork: true, rollback: true, steer: true, images: true },
|
||||
};
|
||||
const entries: AgentEntry[] = [prompt, { type: "agent", text: "**Answer**" }];
|
||||
const run = (id: string, arg: unknown = { task: task.id, turn: 0 }) =>
|
||||
replyActions.find((action) => action.id === `agents.${id}`)!.run({ ctx: {}, arg });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
backend.forkTask.mockResolvedValue(undefined);
|
||||
backend.rerunFrom.mockResolvedValue(undefined);
|
||||
backend.tasks = new Map([[task.id, task]]);
|
||||
backend.entries = new Map([[task.id, entries]]);
|
||||
});
|
||||
|
||||
describe("reply turns", () => {
|
||||
it("offers conversation actions only on the final nonempty reply of each turn", () => {
|
||||
const result = replyTurns([
|
||||
{ type: "agent", text: "Unassociated reply" },
|
||||
prompt,
|
||||
{ type: "agent", text: "Progress" },
|
||||
{ ...prompt, text: "Additional instruction", steered: true },
|
||||
{ type: "agent", text: "Done" },
|
||||
{ ...prompt, text: "Next request" },
|
||||
{ type: "agent", text: "Next answer" },
|
||||
{ type: "agent", text: " " },
|
||||
]);
|
||||
expect([...result].map(([index, reply]) => [index, reply.turn, reply.steered])).toEqual([
|
||||
[4, 0, true],
|
||||
[6, 1, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ready", true],
|
||||
["running", false],
|
||||
["starting", false],
|
||||
["needsInput", false],
|
||||
["archived", false],
|
||||
] as const)("enables conversation actions when %s: %s", (status, enabled) => {
|
||||
expect(replyOperations({ ...task, status }, { turn: 0, prompt, steered: false }, 1)).toEqual({
|
||||
regenerate: enabled,
|
||||
fork: enabled,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires rollback for regeneration and forking earlier turns", () => {
|
||||
const older = { turn: 0, prompt, steered: false };
|
||||
expect(replyOperations(task, older, 2)).toEqual({ regenerate: false, fork: true });
|
||||
const noRollback = { ...task, capabilities: { ...task.capabilities!, rollback: false } };
|
||||
expect(replyOperations(noRollback, older, 2)).toEqual({ regenerate: false, fork: false });
|
||||
expect(replyOperations(noRollback, older, 1)).toEqual({ regenerate: false, fork: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("quotes raw Markdown without sending a prompt", async () => {
|
||||
await run("quoteReply", { task: task.id, text: "**Answer**" });
|
||||
expect(backend.quoteInComposer).toHaveBeenCalledWith(task.id, "**Answer**");
|
||||
expect(backend.rerunFrom).not.toHaveBeenCalled();
|
||||
backend.tasks.set(task.id, { ...task, status: "archived" });
|
||||
await run("quoteReply", { task: task.id, text: "More" });
|
||||
expect(backend.quoteInComposer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("regenerates using the original prompt and its images", async () => {
|
||||
const image = { mimeType: "image/png", data: "AAA", name: "shot.png" };
|
||||
backend.entries.set(task.id, [{ ...prompt, attachments: [image.name], images: [image] }, entries[1]!]);
|
||||
await run("regenerateReply");
|
||||
expect(backend.rerunFrom).toHaveBeenCalledWith(task.id, 0, prompt.text, [image]);
|
||||
});
|
||||
|
||||
it.each(["files", "steered", "earlier", "busy", "unsupported", "subagent"])(
|
||||
"does not replay an invalid request: %s",
|
||||
async (kind) => {
|
||||
if (kind === "files") backend.entries.set(task.id, [{ ...prompt, attachments: ["main.rs"] }, entries[1]!]);
|
||||
if (kind === "steered") backend.entries.set(task.id, [...entries, { ...prompt, steered: true }]);
|
||||
if (kind === "earlier") backend.entries.set(task.id, [...entries, prompt, entries[1]!]);
|
||||
if (kind === "busy") backend.tasks.set(task.id, { ...task, status: "running" });
|
||||
if (kind === "unsupported") backend.tasks.set(task.id, { ...task, capabilities: undefined });
|
||||
if (kind === "subagent") backend.entries.set(task.id, [{ type: "agent", text: "Child reply" }]);
|
||||
await run("regenerateReply");
|
||||
expect(backend.rerunFrom).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("branches after the reply's turn", async () => {
|
||||
await run("forkReply");
|
||||
expect(backend.forkTask).toHaveBeenCalledWith(task.id, 1);
|
||||
});
|
||||
|
||||
it("reports regeneration errors and allows retry", async () => {
|
||||
backend.rerunFrom.mockRejectedValueOnce(new Error("Unavailable"));
|
||||
await run("regenerateReply");
|
||||
expect(backend.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "Couldn't regenerate the response" }),
|
||||
);
|
||||
await run("regenerateReply");
|
||||
expect(backend.rerunFrom).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ignores duplicate clicks while regeneration is pending", async () => {
|
||||
let finish!: () => void;
|
||||
backend.rerunFrom.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const first = run("regenerateReply");
|
||||
await run("regenerateReply");
|
||||
expect(backend.rerunFrom).toHaveBeenCalledTimes(1);
|
||||
finish();
|
||||
await first;
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { replyOperations, replyTurns, turnNumbers } from "./model";
|
||||
import { forkTask, quoteInComposer, rerunFrom, useAgents } from "./store";
|
||||
|
||||
export interface CopyReply {
|
||||
text: string;
|
||||
onCopied?: () => void;
|
||||
}
|
||||
|
||||
export const copyReplyAction: Action = {
|
||||
id: "agents.copyReplyMarkdown",
|
||||
title: "Copy Markdown",
|
||||
category: "Agents",
|
||||
icon: "copy",
|
||||
hidden: true,
|
||||
run: async ({ arg }) => {
|
||||
const reply = arg as CopyReply | undefined;
|
||||
if (!reply || typeof reply.text !== "string" || !reply.text.trim()) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(reply.text);
|
||||
reply.onCopied?.();
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: "Couldn't copy the reply", body: String(e) });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
interface ReplyTarget {
|
||||
task: string;
|
||||
turn: number;
|
||||
}
|
||||
|
||||
function resolveReply(arg: unknown) {
|
||||
const target = arg as ReplyTarget | undefined;
|
||||
if (!target || typeof target.task !== "string" || !Number.isInteger(target.turn)) return null;
|
||||
const task = useAgents.getState().tasks.get(target.task);
|
||||
const entries = useAgents.getState().entries.get(target.task);
|
||||
if (!task || !entries) return null;
|
||||
const reply = [...replyTurns(entries).values()].find((r) => r.turn === target.turn);
|
||||
if (!reply) return null;
|
||||
return { task, reply, operations: replyOperations(task, reply, turnNumbers(entries).size) };
|
||||
}
|
||||
|
||||
const pending = new Set<string>();
|
||||
async function runReplyOperation(task: string, title: string, run: () => Promise<void>) {
|
||||
if (pending.has(task)) return;
|
||||
pending.add(task);
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title, body: String(e) });
|
||||
} finally {
|
||||
pending.delete(task);
|
||||
}
|
||||
}
|
||||
|
||||
export const replyActions: Action[] = [
|
||||
copyReplyAction,
|
||||
{
|
||||
id: "agents.quoteReply",
|
||||
title: "Quote Reply",
|
||||
category: "Agents",
|
||||
icon: "quote",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const target = arg as { task: string; text: string } | undefined;
|
||||
if (!target || typeof target.text !== "string" || !target.text.trim()) return;
|
||||
const task = useAgents.getState().tasks.get(target.task);
|
||||
if (task && task.status !== "archived") quoteInComposer(task.id, target.text);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.regenerateReply",
|
||||
title: "Regenerate Response",
|
||||
category: "Agents",
|
||||
icon: "refresh",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const resolved = resolveReply(arg);
|
||||
if (!resolved?.operations.regenerate) return;
|
||||
const { task, reply } = resolved;
|
||||
return runReplyOperation(task.id, "Couldn't regenerate the response", () =>
|
||||
rerunFrom(task.id, reply.turn, reply.prompt.text, reply.prompt.images ?? []),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.forkReply",
|
||||
title: "Branch Conversation from Here",
|
||||
category: "Agents",
|
||||
icon: "branch",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const resolved = resolveReply(arg);
|
||||
if (!resolved?.operations.fork) return;
|
||||
const { task, reply } = resolved;
|
||||
return runReplyOperation(task.id, "Couldn't copy the task", () => forkTask(task.id, reply.turn + 1));
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -555,9 +555,12 @@ export async function rerunFrom(task: string, turn: number, text: string, images
|
||||
try {
|
||||
await call(commands.agentPrompt(task, { text, files: [], images }, false));
|
||||
} catch (e) {
|
||||
// Rollback removes the inline editor; recover its text in the session composer.
|
||||
// Rollback removes the original turn; recover its prompt in the session composer.
|
||||
const current = composerDraft(task);
|
||||
updateComposer(task, { text: current.text ? `${text}\n${current.text}` : text });
|
||||
updateComposer(task, {
|
||||
text: current.text ? `${text}\n${current.text}` : text,
|
||||
images: [...images, ...current.images],
|
||||
});
|
||||
focusComposer(task);
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -58,5 +58,12 @@
|
||||
"No preview": "No preview",
|
||||
"Running in the background": "Running in the background",
|
||||
"Couldn't stop the background task": "Couldn't stop the background task",
|
||||
"Copy Markdown": "Copy Markdown",
|
||||
"Couldn't copy the reply": "Couldn't copy the reply",
|
||||
"Quote Reply": "Quote Reply",
|
||||
"Regenerate Response": "Regenerate Response",
|
||||
"Branch Conversation from Here": "Branch Conversation from Here",
|
||||
"Couldn't regenerate the response": "Couldn't regenerate the response",
|
||||
"Reasoning": "Reasoning",
|
||||
"Branch '{name}'": "Branch '{name}'"
|
||||
}
|
||||
@@ -6433,5 +6433,12 @@
|
||||
"Running in the background": "后台运行中",
|
||||
"Couldn't stop the background task": "无法停止后台任务",
|
||||
"Stage {count} files": "暂存 {count} 个文件",
|
||||
"Unstage {count} files": "取消暂存 {count} 个文件"
|
||||
"Unstage {count} files": "取消暂存 {count} 个文件",
|
||||
"Copy Markdown": "复制 Markdown",
|
||||
"Couldn't copy the reply": "无法复制回复",
|
||||
"Quote Reply": "引用回复",
|
||||
"Regenerate Response": "重新生成回复",
|
||||
"Branch Conversation from Here": "从此处创建分支对话",
|
||||
"Couldn't regenerate the response": "无法重新生成回复",
|
||||
"Reasoning": "思考过程"
|
||||
}
|
||||
@@ -18,6 +18,7 @@ html {
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--agent-conversation-width: 900px;
|
||||
--splash-logo-size: 56px;
|
||||
--splash-bar-width: 120px;
|
||||
--splash-bar-height: 2px;
|
||||
|
||||
@@ -224,6 +224,7 @@ const ICONS = {
|
||||
"search-history": "inline/searchHistory",
|
||||
filter: "general/filter",
|
||||
refresh: "general/refresh",
|
||||
quote: "openidea/general/quote",
|
||||
settings: "general/settings",
|
||||
help: "openidea/general/help",
|
||||
"page-first": "openidea/general/pageFirst",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 7H6V11H2.5V7.5C2.5 5.5 3.5 4 5.5 3.5M10 7H13V11H9.5V7.5C9.5 5.5 10.5 4 12.5 3.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 262 B |
Reference in new issue
Block a user