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:
l0ng-ai authored and GitHub committed 2026-11-07 10:57:18 +08:00
1 parent 5804fcad16
commit ee6786d573
19 files changed
+997 -45

No files matched your search

+189 -1
View File
@@ -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);
});
+113
View File
@@ -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());
}
});
}
+74
View File
@@ -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();
});
+77
View File
@@ -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>
);
}
+19
View File
@@ -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>',
+14 -8
View File
@@ -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();
};
}
+98 -24
View File
@@ -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 {
+41 -8
View File
@@ -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>
)}
+2
View File
@@ -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",
+42 -1
View File
@@ -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 };
+43
View File
@@ -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));
+159
View File
@@ -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;
});
+101
View File
@@ -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));
},
},
];
+5 -2
View File
@@ -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;
}
+7
View File
@@ -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}'"
}
+8 -1
View File
@@ -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": "思考过程"
}
+1
View File
@@ -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;
+1
View File
@@ -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",
+3
View File
@@ -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