diff --git a/e2e/agentMarkdown.spec.ts b/e2e/agentMarkdown.spec.ts index f46d96936..e1bd4c9e3 100644 --- a/e2e/agentMarkdown.spec.ts +++ b/e2e/agentMarkdown.spec.ts @@ -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("[class*='_reply_']")!; + const bubble = root.querySelector("[class*='_user_']")!; + const composer = root + .closest("[class*='_conversation_']")! + .querySelector("[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("[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\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); +}); diff --git a/e2e/agentScroll.spec.ts b/e2e/agentScroll.spec.ts new file mode 100644 index 000000000..8b18591de --- /dev/null +++ b/e2e/agentScroll.spec.ts @@ -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()); + } + }); +} diff --git a/src/features/agents/AgentReply.test.tsx b/src/features/agents/AgentReply.test.tsx new file mode 100644 index 000000000..7c0924802 --- /dev/null +++ b/src/features/agents/AgentReply.test.tsx @@ -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(); + 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(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" }))); + rerender(); + 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(); + 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(); + expect(screen.queryByRole("button", { name: "Copy Markdown" })).not.toBeInTheDocument(); +}); diff --git a/src/features/agents/AgentReply.tsx b/src/features/agents/AgentReply.tsx new file mode 100644 index 000000000..44a87a62d --- /dev/null +++ b/src/features/agents/AgentReply.tsx @@ -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(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 ( +
+ + {text.trim() && ( +
+ + setCopied(text) } satisfies CopyReply} + /> + {task && task.status !== "archived" && ( + + )} + {task && reply && operations.regenerate && ( + + )} + {task && reply && operations.fork && ( + + )} + +
+ )} +
+ ); +} diff --git a/src/features/agents/Markdown.test.tsx b/src/features/agents/Markdown.test.tsx index d80f270a5..683213ef2 100644 --- a/src/features/agents/Markdown.test.tsx +++ b/src/features/agents/Markdown.test.tsx @@ -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( + + + , + ); + 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([ "", '
', diff --git a/src/features/agents/Markdown.tsx b/src/features/agents/Markdown.tsx index 005621f62..81f7a53af 100644 --- a/src/features/agents/Markdown.tsx +++ b/src/features/agents/Markdown.tsx @@ -84,6 +84,14 @@ function renderHtml(text: string): string { ADD_ATTR: ["data-mermaid"], }) as HTMLElement; root.querySelectorAll("li").forEach(wrapLooseText); + // WebKit cuts controls inside
 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>): () => void {
   let alive = true;
   const disposeImages = loadImages(root, cwd, images);
+  const copies: HTMLButtonElement[] = [];
   root.querySelectorAll("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("code").forEach((code) => {
     if (code.parentElement?.tagName === "PRE" || code.closest("a")) return;
@@ -173,6 +178,7 @@ function enhance(root: HTMLElement, cwd: string, images: Map {
     alive = false;
+    copies.forEach((copy) => copy.remove());
     disposeImages();
   };
 }
diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css
index e4018cb0e..48dc26303 100644
--- a/src/features/agents/Session.module.css
+++ b/src/features/agents/Session.module.css
@@ -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 
: on
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 { diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 44adb5544..a33171219 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -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 (
{finding && } @@ -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 ; case "agent": - return ; + return ( + + ); case "thought": return (
- Thinking + {working ? ( + + ) : ( + + )} + {working ? "Thinking" : "Reasoning"} -
{entry.text}
+
+ +
); @@ -829,7 +857,7 @@ const Entry = memo(function Entry({
Report to the parent agent
- +
); case "permission": @@ -1088,7 +1116,12 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract} ); - if (!entry.output) return
{head}
; + if (!entry.output) + return ( +
+
{head}
+
+ ); return (
@@ -1190,7 +1223,7 @@ function SubagentCard({ Result
- +
)} diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts index 83b03b812..ccaf35a9f 100644 --- a/src/features/agents/index.ts +++ b/src/features/agents/index.ts @@ -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", diff --git a/src/features/agents/messageEditing.test.tsx b/src/features/agents/messageEditing.test.tsx index 81a2096ef..a61238a24 100644 --- a/src/features/agents/messageEditing.test.tsx +++ b/src/features/agents/messageEditing.test.tsx @@ -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 }; diff --git a/src/features/agents/model.ts b/src/features/agents/model.ts index 25b628d42..d2bfe8ec9 100644 --- a/src/features/agents/model.ts +++ b/src/features/agents/model.ts @@ -126,6 +126,49 @@ export function turnNumbers(entries: AgentEntry[]): Map { return out; } +export interface ReplyTurn { + turn: number; + prompt: Extract; + steered: boolean; +} + +/** Only the last reply of a turn offers actions that change the conversation. */ +export function replyTurns(entries: AgentEntry[]): Map { + const out = new Map(); + 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)); diff --git a/src/features/agents/replyActions.test.ts b/src/features/agents/replyActions.test.ts new file mode 100644 index 000000000..811ff3196 --- /dev/null +++ b/src/features/agents/replyActions.test.ts @@ -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(), + entries: new Map(), + 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 = { + 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((resolve) => { + finish = resolve; + }), + ); + const first = run("regenerateReply"); + await run("regenerateReply"); + expect(backend.rerunFrom).toHaveBeenCalledTimes(1); + finish(); + await first; +}); diff --git a/src/features/agents/replyActions.ts b/src/features/agents/replyActions.ts new file mode 100644 index 000000000..7c84b2b7b --- /dev/null +++ b/src/features/agents/replyActions.ts @@ -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(); +async function runReplyOperation(task: string, title: string, run: () => Promise) { + 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)); + }, + }, +]; diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index bd6a632a7..2ba53f2be 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -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; } diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index f88c95baf..4d3ee08db 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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}'" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 7385a3e18..d2de7cb89 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "思考过程" } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index ea589dd4c..fb94e75d9 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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; diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 4faeac875..61bad8011 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -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", diff --git a/src/ui/openidea/general/quote.svg b/src/ui/openidea/general/quote.svg new file mode 100644 index 000000000..13567cf82 --- /dev/null +++ b/src/ui/openidea/general/quote.svg @@ -0,0 +1,3 @@ + + +