Compare commits

...
12 changed files with 715 additions and 51 deletions

No files matched your search

+45 -2
View File
@@ -199,7 +199,12 @@ fn invalid(message: impl Into<String>) -> ResponseError {
fn from_codex(e: RpcError) -> ResponseError {
match e {
RpcError::Server { code, message, data } => {
let code = if message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
// Protocol errors can list method names containing "auth"; they are not sign-in failures.
let protocol = matches!(
code,
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
);
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
ResponseError { code, message, data }
}
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
@@ -426,10 +431,48 @@ impl CodexShim {
async fn rollback(&self, params: &Value) -> Result<Value, ResponseError> {
let session = str_param(params, "sessionId")?;
let turns = params.get("turns").and_then(Value::as_u64).filter(|n| *n > 0).ok_or_else(|| invalid("turns"))?;
self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await?;
match self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await {
Ok(_) => {}
Err(e)
if e.code == codes::METHOD_NOT_FOUND
|| (e.code == codes::INVALID_REQUEST
&& e.message.contains("unknown variant `thread/rollback`")) =>
{
self.revert(session, turns).await?;
}
Err(e) => return Err(e),
}
Ok(json!({}))
}
/// New Codex versions replace rollback counts with an exclusive turn id.
async fn revert(&self, session: &str, mut remaining: u64) -> Result<(), ResponseError> {
let mut cursor = Value::Null;
let mut seen = HashSet::new();
loop {
let page = self
.call(
"thread/turns/list",
json!({ "threadId": session, "sortDirection": "desc", "limit": remaining.min(100), "cursor": cursor }),
)
.await?;
let turns = page.get("data").and_then(Value::as_array).ok_or_else(|| invalid("Codex returned no turns"))?;
if remaining <= turns.len() as u64 {
let before = str_param(&turns[remaining as usize - 1], "id")?;
self.call("thread/revert", json!({ "threadId": session, "beforeTurnId": before })).await?;
return Ok(());
}
remaining -= turns.len() as u64;
let next = page.get("nextCursor").and_then(Value::as_str);
let Some(next) = next.filter(|next| seen.insert((*next).to_owned())) else {
return Err(invalid(
"Codex history has fewer turns than the conversation; reload the task before editing",
));
};
cursor = json!(next);
}
}
/// Stops following the session's thread (codex unloads it once nothing follows it).
async fn close(&self, params: &Value) -> Result<Value, ResponseError> {
let session = str_param(params, "sessionId")?;
@@ -593,6 +593,104 @@ async fn resume_replays_history_then_forks_and_rolls_back() {
assert_eq!(sent(&frames, "thread/unsubscribe")[0]["params"], json!({"threadId": thread}));
}
async fn rollback_replies(turns: u32, replies: Vec<(&str, Value)>) -> (Result<Value, RpcError>, Vec<Value>) {
let frames = replies
.into_iter()
.enumerate()
.flat_map(|(i, (method, mut reply))| {
let id = i + 1;
reply["id"] = json!(id);
[(true, json!({ "id": id, "method": method })), (false, reply)]
})
.collect();
let transcript = Transcript { meta: Value::Null, frames };
let (ours, theirs) = tokio::io::duplex(1 << 16);
let acp = attach(Arc::new(Client::default()), |events| {
let (read, write) = tokio::io::split(ours);
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
})
.unwrap();
let (read, write) = tokio::io::split(theirs);
let player = tokio::spawn(async move { play(&transcript, read, write).await });
let result = request(&acp, "_openidea/rollback", json!({ "sessionId": "s", "turns": turns })).await;
let (sent, _read, _write) = player.await.unwrap();
acp.kill();
(result, sent)
}
fn removed_rollback() -> Value {
json!({ "error": { "code": -32600, "message": "Invalid request: unknown variant `thread/rollback`, expected one of `thread/revert`, `account/gatewayOAuth/read`, `getAuthStatus`" } })
}
#[tokio::test(flavor = "multi_thread")]
async fn removed_rollback_reverts_before_the_target_turn_across_pages() {
let (result, frames) = rollback_replies(
3,
vec![
("thread/rollback", removed_rollback()),
(
"thread/turns/list",
json!({ "result": { "data": [{ "id": "last" }, { "id": "middle" }], "nextCursor": "older" } }),
),
(
"thread/turns/list",
json!({ "result": { "data": [{ "id": "edited" }, { "id": "retained" }], "nextCursor": null } }),
),
("thread/revert", json!({ "result": { "thread": { "id": "s", "turns": [] } } })),
],
)
.await;
assert_eq!(result.unwrap(), json!({}));
let pages = sent(&frames, "thread/turns/list");
assert_eq!(pages[0]["params"], json!({ "threadId": "s", "sortDirection": "desc", "limit": 3, "cursor": null }));
assert_eq!(pages[1]["params"]["cursor"], "older");
assert_eq!(pages[1]["params"]["limit"], 1);
assert_eq!(sent(&frames, "thread/revert")[0]["params"], json!({ "threadId": "s", "beforeTurnId": "edited" }));
}
#[tokio::test(flavor = "multi_thread")]
async fn method_not_found_reverts_the_latest_turn() {
let (result, frames) = rollback_replies(
1,
vec![
("thread/rollback", json!({ "error": { "code": -32601, "message": "Method not found" } })),
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": null } })),
("thread/revert", json!({ "result": {} })),
],
)
.await;
assert_eq!(result.unwrap(), json!({}));
assert_eq!(sent(&frames, "thread/revert")[0]["params"]["beforeTurnId"], "last");
}
#[tokio::test(flavor = "multi_thread")]
async fn rollback_refusals_are_not_retried_as_revert() {
let (result, frames) = rollback_replies(
1,
vec![("thread/rollback", json!({ "error": { "code": -32600, "message": "a turn is still running" } }))],
)
.await;
assert!(result.unwrap_err().to_string().contains("a turn is still running"));
assert_eq!(frames.len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn incomplete_or_repeated_history_pages_do_not_revert_the_wrong_turn() {
for next in [Value::Null, json!("older")] {
let (result, frames) = rollback_replies(
3,
vec![
("thread/rollback", removed_rollback()),
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": "older" } })),
("thread/turns/list", json!({ "result": { "data": [], "nextCursor": next } })),
],
)
.await;
assert!(result.unwrap_err().to_string().contains("reload the task"));
assert!(sent(&frames, "thread/revert").is_empty());
}
}
#[tokio::test(flavor = "multi_thread")]
async fn steering_adds_to_the_running_turn() {
let (results, _client, frames, _) = replay(Scenario::Steer).await;
+209
View File
@@ -0,0 +1,209 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { AgentTask } from "@/bindings";
import { registerActions } from "@/platform/actions";
import type { EditorInput } from "@/workbench/contributions";
import { confirmChecked, showError } from "@/workbench/messages";
import { ContextMenuHost, hideContextMenu } from "@/ui";
import { AgentsView } from "./AgentsView";
import { SESSION_KIND } from "./model";
import { archiveTask, deleteTask, openTask, useAgents } from "./store";
import { taskActions } from "./taskActions";
const editor = vi.hoisted(() => ({
active: undefined as EditorInput | undefined,
listener: undefined as ((input: EditorInput) => void) | undefined,
}));
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
maybeEditorService: () => ({
activeInput: () => editor.active,
onActiveInputChange: (listener: typeof editor.listener) => {
editor.listener = listener;
return () => {
editor.listener = undefined;
};
},
}),
}));
vi.mock("./store", async (original) => ({
...(await original<typeof import("./store")>()),
openTask: vi.fn((id: string) => editor.listener?.({ kind: "agent-session", key: id, data: { task: id } })),
archiveTask: vi.fn(async () => {}),
deleteTask: vi.fn(async () => {}),
}));
vi.mock("@/workbench/messages", () => ({
confirmChecked: vi.fn(async () => ({ checked: false })),
showError: vi.fn(async () => {}),
}));
function task(
id: string,
status: AgentTask["status"] = "ready",
isolation: AgentTask["isolation"] = "local",
): AgentTask {
return {
id,
title: id,
status,
isolation,
provider: "codex",
model: null,
cwd: "/project",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
};
}
const row = (id: string) => screen.getByText(id).closest('[role="treeitem"]')!;
const select = (id: string, modifiers: MouseEventInit = {}) =>
fireEvent.mouseDown(row(id), { button: 0, ...modifiers });
const selectedTasks = () =>
screen
.queryAllByRole("treeitem", { selected: true })
.filter((item) => item.getAttribute("aria-level") === "2")
.map((item) => item.querySelector("[title]")?.getAttribute("title"));
const context = (id: string) => {
fireEvent.mouseDown(row(id), { button: 2 });
fireEvent.contextMenu(row(id));
};
let dispose: () => void;
beforeEach(() => {
vi.clearAllMocks();
editor.active = undefined;
vi.mocked(confirmChecked).mockResolvedValue({ checked: false });
useAgents.setState({
loaded: true,
error: null,
tasks: new Map([task("one"), task("two"), task("three"), task("four")].map((t) => [t.id, t])),
});
dispose = registerActions(taskActions);
});
afterEach(() => {
dispose();
hideContextMenu();
});
function setup() {
render(
<>
<AgentsView />
<ContextMenuHost />
</>,
);
}
describe("Agents selection", () => {
it("extends and shrinks a range without opening its lead or losing selection to an editor echo", () => {
setup();
select("one");
select("three", { shiftKey: true });
expect(selectedTasks()).toEqual(["one", "two", "three"]);
expect(openTask).toHaveBeenCalledTimes(1);
act(() => editor.listener?.({ kind: SESSION_KIND, key: "one", data: { task: "one" } }));
expect(selectedTasks()).toEqual(["one", "two", "three"]);
select("two", { shiftKey: true });
expect(selectedTasks()).toEqual(["one", "two"]);
select("four");
expect(selectedTasks()).toEqual(["four"]);
});
it("keeps the initial active task as the anchor for repeated keyboard extension", () => {
editor.active = { kind: SESSION_KIND, key: "one", data: { task: "one" } };
setup();
const tree = screen.getByRole("tree");
fireEvent.keyDown(tree, { key: "ArrowDown", shiftKey: true });
fireEvent.keyDown(tree, { key: "ArrowDown", shiftKey: true });
expect(selectedTasks()).toEqual(["one", "two", "three"]);
expect(openTask).not.toHaveBeenCalled();
});
it.each(["metaKey", "ctrlKey"])("toggles individual tasks with %s", (modifier) => {
setup();
select("one");
select("three", { [modifier]: true });
expect(selectedTasks()).toEqual(["one", "three"]);
select("three", { [modifier]: true });
expect(selectedTasks()).toEqual(["one"]);
expect(openTask).toHaveBeenCalledTimes(1);
});
});
describe("Agents bulk actions", () => {
it("deletes the right-clicked selection after one confirmation, excluding group headers", async () => {
const tasks = [task("one", "running"), task("two"), task("three")];
useAgents.setState({ tasks: new Map(tasks.map((t) => [t.id, t])) });
setup();
select("one");
select("three", { shiftKey: true });
context("two");
fireEvent.click(screen.getByRole("menuitem", { name: "Delete (3)…" }));
await waitFor(() => expect(deleteTask).toHaveBeenCalledTimes(3));
expect(confirmChecked).toHaveBeenCalledTimes(1);
expect(confirmChecked).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("3 agent tasks") }),
);
expect(vi.mocked(deleteTask).mock.calls).toEqual([
["one", false],
["two", false],
["three", false],
]);
});
it("right-clicking outside the selection targets only that task", async () => {
setup();
select("one");
select("two", { shiftKey: true });
context("four");
fireEvent.click(screen.getByRole("menuitem", { name: "Delete…" }));
await waitFor(() => expect(deleteTask).toHaveBeenCalledExactlyOnceWith("four", false));
});
it("archives only eligible tasks in a mixed selection", async () => {
const tasks = [task("one", "running"), task("two"), task("three", "archived")];
useAgents.setState({ tasks: new Map(tasks.map((t) => [t.id, t])) });
setup();
fireEvent.doubleClick(screen.getByText("Archived"));
select("one");
select("three", { shiftKey: true });
context("two");
fireEvent.click(screen.getByRole("menuitem", { name: "Archive (1)" }));
await waitFor(() => expect(archiveTask).toHaveBeenCalledExactlyOnceWith("two"));
});
it("cancels the whole batch", async () => {
vi.mocked(confirmChecked).mockResolvedValue(null);
await taskActions.find((action) => action.id === "agents.deleteTasks")!.run({ ctx: {}, arg: ["one", "two"] });
expect(deleteTask).not.toHaveBeenCalled();
});
it.each([false, true])("applies worktree cleanup (%s) only to worktree tasks", async (checked) => {
const tasks = [task("one"), task("two", "ready", "worktree")];
useAgents.setState({ tasks: new Map(tasks.map((t) => [t.id, t])) });
vi.mocked(confirmChecked).mockResolvedValue({ checked });
await taskActions.find((action) => action.id === "agents.deleteTasks")!.run({ ctx: {}, arg: ["one", "two"] });
expect(vi.mocked(deleteTask).mock.calls).toEqual([
["one", false],
["two", checked],
]);
});
it("continues after an error and reports the failed task", async () => {
vi.mocked(deleteTask).mockRejectedValueOnce(new Error("Cannot remove worktree"));
await taskActions.find((action) => action.id === "agents.deleteTasks")!.run({ ctx: {}, arg: ["one", "two"] });
expect(deleteTask).toHaveBeenCalledTimes(2);
expect(showError).toHaveBeenCalledWith("Cannot delete agent tasks", "one: Cannot remove worktree");
});
});
+15 -35
View File
@@ -1,14 +1,13 @@
/** Agents tool window: tasks grouped by what they need from the user. */
import { useEffect, useMemo, useState } from "react";
import type { AgentStatus as Status, AgentTask } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { maybeEditorService, type EditorInput } from "@/workbench/contributions";
import { confirmChecked } from "@/workbench/messages";
import { EmptyState, Icon, IconButton, Spinner, T, Tree, showContextMenu, type MenuItem } from "@/ui";
import { SESSION_KIND, age, branchLabel, groupTasks, isBusy, type TaskGroup } from "./model";
import { archiveTask, deleteTask, openHome, openTask, useAgents } from "./store";
import { EmptyState, Icon, IconButton, Spinner, T, Tree, showContextMenu } from "@/ui";
import { SESSION_KIND, age, branchLabel, groupTasks, type TaskGroup } from "./model";
import { openHome, openTask, useAgents } from "./store";
import { taskMenu } from "./taskActions";
import styles from "./Agents.module.css";
import { tx, useTx } from "@/platform/i18n";
import { useTx } from "@/platform/i18n";
export function AgentsHeaderActions() {
return (
@@ -61,7 +60,7 @@ export function AgentsView() {
maybeEditorService()?.onActiveInputChange?.((input) => {
const task = sessionTask(input);
if (!task) return;
setSelected(new Set([task]));
setSelected((current) => (current.has(task) ? current : new Set([task])));
const group = groupTasks(useAgents.getState().tasks.values()).find((g) => g.tasks.some((t) => t.id === task));
const key = group && `g:${group.status}`;
if (key) setCollapsed((c) => (c.has(key) ? new Set([...c].filter((k) => k !== key)) : c));
@@ -102,10 +101,17 @@ export function AgentsView() {
selected={selected}
onSelectionChange={(sel, lead) => {
setSelected(sel);
if (lead?.kind === "task") openTask(lead.task.id);
if (sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) openTask(lead.task.id);
}}
onActivate={(n) => n.kind === "task" && openTask(n.task.id)}
onContextMenu={(n, _sel, e) => n?.kind === "task" && showContextMenu(e, taskMenu(n.task))}
onContextMenu={(n, sel, e) => {
if (n?.kind !== "task") return;
const selection = [...sel].flatMap((id) => {
const task = tasks.get(id);
return task ? [task] : [];
});
showContextMenu(e, taskMenu(selection));
}}
renderNode={(n) =>
n.kind === "group" ? (
<span className={styles.row}>
@@ -143,29 +149,3 @@ export function AgentsView() {
</div>
);
}
function taskMenu(task: AgentTask): MenuItem[] {
const busy = isBusy(task.status);
return [
{ label: "Open", run: () => openTask(task.id) },
{ label: "Open in Terminal", icon: "terminal", run: () => void executeAction("terminal.openAt", task.cwd) },
{ kind: "separator" },
{ label: "Archive", disabled: busy || task.status === "archived", run: () => void archiveTask(task.id) },
{ label: "Delete…", run: () => void confirmDelete(task) },
];
}
async function confirmDelete(task: AgentTask) {
const worktree = task.isolation === "worktree";
const r = await confirmChecked({
title: "Delete Agent Task",
message: worktree
? tx`Delete “${task.title}”? Its branch ${task.branch ?? ""} is kept.`
: tx`Delete “${task.title}”? Files it changed are kept.`,
ok: "Delete",
icon: "warning",
checkbox: worktree ? "Also remove its worktree folder" : undefined,
checked: worktree,
});
if (r) await deleteTask(task.id, worktree && r.checked);
}
+2 -2
View File
@@ -117,8 +117,8 @@
.stream:focus {
outline: none;
}
/* Off-screen entries skip layout, so resizing the pane doesn't reflow a long conversation. */
.stream > * {
/* Skip off-screen output layout; editable messages must repaint immediately when focus/content changes. */
.stream > :not(.message) {
content-visibility: auto;
contain-intrinsic-block-size: auto 80px;
}
+38 -10
View File
@@ -23,6 +23,7 @@ import {
showContextMenu,
} from "@/ui";
import { useTx, tx } from "@/platform/i18n";
import { isImeKey } from "@/platform/ime";
import { TaskStatusIcon } from "./AgentsView";
import { Composer } from "./Composer";
import { canSend, emptyDraft, retryParts, usageLabel, workTime } from "./composerModel";
@@ -697,9 +698,9 @@ function UserMessage({
initial={entry.text}
later={turn < turnCount - 1}
onCancel={() => setEditing(false)}
onSend={(text) => {
onSend={async (text) => {
await rerunFrom(task.id, turn, text);
setEditing(false);
void rerunFrom(task.id, turn, text).catch(fail);
}}
/>
);
@@ -770,12 +771,27 @@ function MessageEditor({
/** Turns follow it (they are dropped on send). */
later: boolean;
onCancel: () => void;
onSend: (text: string) => void;
onSend: (text: string) => Promise<void>;
}) {
const tx = useTx();
const [text, setText] = useState(initial);
const send = () => {
if (text.trim()) onSend(text.trim());
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const pending = useRef(false);
const send = async () => {
if (!text.trim() || pending.current) return;
pending.current = true;
setSending(true);
setError(null);
try {
await onSend(text.trim());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
notifications().notify({ level: "error", title: "Couldn't send the message", body: String(e) });
} finally {
pending.current = false;
setSending(false);
}
};
return (
<div className={styles.message}>
@@ -784,19 +800,30 @@ function MessageEditor({
value={text}
rows={Math.min(Math.max(text.split("\n").length, 2), 12)}
autoFocus
readOnly={sending}
aria-label={tx("Message")}
onChange={(e) => setText(e.target.value)}
onFocus={(e) => e.currentTarget.setSelectionRange(text.length, text.length)}
onKeyDown={(e) => {
if (isImeKey(e.nativeEvent)) return;
if (e.key === "Escape") {
e.preventDefault();
onCancel();
e.stopPropagation();
if (!pending.current) onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
send();
void send();
}
}}
/>
{error && (
<div className={styles.error} role="alert">
<Icon name="error" size={14} />
<span>
<T>Couldn't send the message</T>: {error}
</span>
</div>
)}
<div className={styles.editBar}>
{later && (
<span className={styles.muted}>
@@ -804,11 +831,12 @@ function MessageEditor({
</span>
)}
<span className={styles.editButtons}>
<Button size="small" onClick={onCancel}>
<Button size="small" disabled={sending} onClick={onCancel}>
Cancel
</Button>
<Button size="small" variant="primary" disabled={!text.trim()} onClick={send}>
Send
<Button size="small" variant="primary" disabled={!text.trim() || sending} onClick={() => void send()}>
{sending && <Spinner size={12} />}
<T>Send</T>
</Button>
</span>
</div>
+2
View File
@@ -21,6 +21,7 @@ import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TOOL_WINDOW } from "./model";
import { sendPaths, sendSelection } from "./references";
import { focusedSession } from "./sessionFocus";
import { connect, disconnect, openHome, useAgents } from "./store";
import { taskActions } from "./taskActions";
/** Settings › Tools › Agents (also used by the Settings window). */
export function activateSettingsPages(): () => void {
@@ -89,6 +90,7 @@ export function activate(): () => void {
: [],
}),
registerActions([
...taskActions,
{
id: "agents.findInConversation",
title: "Find in Conversation",
+171
View File
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { AgentEntry, AgentTask } from "@/bindings";
const ipc = vi.hoisted(() => ({ rollback: vi.fn(), prompt: vi.fn(), cancel: vi.fn() }));
const entries: AgentEntry[] = [
{ type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false },
{ type: "agent", text: "Original answer" },
];
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
commands: {
agentEntries: async () => ({ status: "ok", data: entries }),
agentChanges: async () => ({ status: "ok", data: [] }),
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
agentRollback: ipc.rollback,
agentPrompt: ipc.prompt,
agentCancel: ipc.cancel,
},
}));
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
}));
import { SessionEditor } from "./SessionEditor";
import { updateComposer, useAgents } from "./store";
const task: AgentTask = {
id: "editing",
title: "Test task",
provider: "codex",
status: "ready",
isolation: "local",
model: null,
cwd: "/project",
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 },
};
beforeEach(() => {
ipc.rollback.mockReset().mockResolvedValue({ status: "ok", data: null });
ipc.prompt.mockReset().mockResolvedValue({ status: "ok", data: null });
ipc.cancel.mockReset().mockResolvedValue({ status: "ok", data: null });
useAgents.setState({ tasks: new Map([[task.id, task]]), entries: new Map(), composers: new Map(), focus: null });
});
async function edit() {
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const box = screen.getAllByRole("textbox", { name: "Message" })[0]!;
fireEvent.change(box, { target: { value: "Edited message" } });
return { user, box, send: screen.getAllByRole("button", { name: "Send" })[0]! };
}
describe("editing a sent agent message", () => {
it("keeps Enter, Shift+Enter and IME confirmation for editing", async () => {
const { box } = await edit();
fireEvent.keyDown(box, { key: "Enter" });
fireEvent.keyDown(box, { key: "Enter", shiftKey: true });
fireEvent.keyDown(box, { key: "Enter", metaKey: true, isComposing: true });
fireEvent.keyDown(box, { key: "Enter", metaKey: true, keyCode: 229 });
expect(ipc.rollback).not.toHaveBeenCalled();
expect(box).toHaveValue("Edited message");
});
it("keeps the draft and shows a rollback failure beside it", async () => {
ipc.rollback.mockResolvedValue({ status: "error", error: { kind: "internal", message: "Rollback failed" } });
const { user, box, send } = await edit();
await user.click(send);
expect(await screen.findByRole("alert")).toHaveTextContent("Rollback failed");
expect(box).toHaveValue("Edited message");
expect(box).toBeInTheDocument();
expect(send).toBeEnabled();
expect(ipc.prompt).not.toHaveBeenCalled();
ipc.rollback.mockResolvedValue({ status: "ok", data: null });
await user.click(send);
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("rolls back before sending the edited text when Send is clicked", async () => {
let finish!: (value: unknown) => void;
ipc.rollback.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const { user, box, send } = await edit();
await user.click(send);
expect(ipc.rollback).toHaveBeenCalledWith(task.id, 0);
expect(ipc.prompt).not.toHaveBeenCalled();
expect(send).toBeDisabled();
fireEvent.keyDown(box, { key: "Enter", metaKey: true });
expect(ipc.rollback).toHaveBeenCalledTimes(1);
await act(async () => finish({ status: "ok", data: null }));
await waitFor(() =>
expect(ipc.prompt).toHaveBeenCalledWith(task.id, { text: "Edited message", files: [], images: [] }, false),
);
});
it.each(["", "Another draft"])("recovers the edit after rollback if sending fails (draft: %j)", async (draft) => {
ipc.rollback.mockImplementation(async () => {
// The backend emits Truncated before replying, removing the inline editor.
useAgents.setState({ entries: new Map([[task.id, []]]) });
return { status: "ok", data: null };
});
let finish!: (value: unknown) => void;
ipc.prompt.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const { user, box, send } = await edit();
act(() => updateComposer(task.id, { text: draft }));
await user.click(send);
expect(box).not.toBeInTheDocument();
await act(async () => finish({ status: "error", error: { kind: "internal", message: "Send failed" } }));
expect(screen.getByRole("textbox", { name: "Message" })).toHaveValue(
draft ? `Edited message\n${draft}` : "Edited message",
);
expect(screen.getByRole("textbox", { name: "Message" })).toHaveFocus();
ipc.prompt.mockResolvedValue({ status: "ok", data: null });
await user.click(screen.getByRole("button", { name: "Send" }));
expect(ipc.prompt).toHaveBeenCalledTimes(2);
expect(ipc.rollback).toHaveBeenCalledTimes(1);
});
it.each([{ metaKey: true }, { ctrlKey: true }])("still sends with modified Enter (%j)", async (modifier) => {
const { box } = await edit();
fireEvent.keyDown(box, { key: "Enter", ...modifier });
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
});
it("waits for a running turn to stop before rolling it back", async () => {
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
const { user, send } = await edit();
await user.click(send);
expect(ipc.cancel).toHaveBeenCalledWith(task.id, null);
expect(ipc.rollback).not.toHaveBeenCalled();
act(() => useAgents.setState({ tasks: new Map([[task.id, task]]) }));
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
});
it.each(["Cancel", "Escape"])(
"%s restores the message and discards edits without touching the conversation",
async (method) => {
const { user, box } = await edit();
if (method === "Cancel") await user.click(screen.getByRole("button", { name: "Cancel" }));
else fireEvent.keyDown(box, { key: "Escape" });
expect(box).not.toBeInTheDocument();
expect(screen.getByText("Original message")).toBeInTheDocument();
expect(screen.getByText("Original answer")).toBeInTheDocument();
expect(ipc.rollback).not.toHaveBeenCalled();
expect(ipc.prompt).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(screen.getAllByRole("textbox", { name: "Message" })[0]).toHaveValue("Original message");
},
);
});
+9 -1
View File
@@ -415,7 +415,15 @@ export async function rerunFrom(task: string, turn: number, text: string): Promi
await settled(task);
}
await call(commands.agentRollback(task, turn));
await call(commands.agentPrompt(task, { text, files: [], images: [] }, false));
try {
await call(commands.agentPrompt(task, { text, files: [], images: [] }, false));
} catch (e) {
// Rollback removes the inline editor; recover its text in the session composer.
const current = composerDraft(task);
updateComposer(task, { text: current.text ? `${text}\n${current.text}` : text });
focusComposer(task);
throw e;
}
}
/** Resolves once the task's turn has ended (or after a while). */
+115
View File
@@ -0,0 +1,115 @@
import type { AgentTask } from "@/bindings";
import type { Action } from "@/platform/actions";
import { tx } from "@/platform/i18n";
import type { MenuItem } from "@/ui";
import { showError, confirmChecked } from "@/workbench/messages";
import { isBusy } from "./model";
import { archiveTask, deleteTask, openTask, useAgents } from "./store";
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
function resolveTasks(arg: unknown): AgentTask[] {
if (!Array.isArray(arg)) return [];
return [...new Set(arg)].flatMap((id: unknown) => {
const task = typeof id === "string" ? useAgents.getState().tasks.get(id) : undefined;
return task ? [task] : [];
});
}
async function runTasks(tasks: AgentTask[], run: (task: AgentTask) => Promise<unknown>, title: string) {
const errors: string[] = [];
for (const task of tasks) {
try {
await run(task);
} catch (error) {
errors.push(`${task.title}: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (errors.length) await showError(title, errors.join("\n"));
}
async function confirmDelete(tasks: AgentTask[]) {
if (!tasks.length) return;
const task = tasks[0]!;
const multiple = tasks.length > 1;
const worktree = tasks.some((t) => t.isolation === "worktree");
const r = await confirmChecked({
title: multiple ? "Delete Agent Tasks" : "Delete Agent Task",
message: multiple
? tx`Delete ${tasks.length} agent tasks? Their branches and files outside worktrees are kept.`
: worktree
? tx`Delete “${task.title}”? Its branch ${task.branch ?? ""} is kept.`
: tx`Delete “${task.title}”? Files it changed are kept.`,
ok: "Delete",
icon: "warning",
checkbox: worktree
? multiple
? "Also remove their worktree folders"
: "Also remove its worktree folder"
: undefined,
checked: worktree,
});
if (r)
await runTasks(
tasks,
(t) => deleteTask(t.id, t.isolation === "worktree" && r.checked),
tx("Cannot delete agent tasks"),
);
}
export const taskActions: Action[] = [
{
id: "agents.openTask",
title: "Open Agent Task",
category: "Agents",
hidden: true,
run: ({ arg }) => {
if (typeof arg === "string") openTask(arg);
},
},
{
id: "agents.archiveTasks",
title: "Archive Agent Tasks",
category: "Agents",
hidden: true,
run: ({ arg }) =>
runTasks(resolveTasks(arg).filter(canArchive), (task) => archiveTask(task.id), tx("Cannot archive agent tasks")),
},
{
id: "agents.deleteTasks",
title: "Delete Agent Tasks",
category: "Agents",
hidden: true,
run: ({ arg }) => confirmDelete(resolveTasks(arg)),
},
];
export function taskMenu(tasks: AgentTask[]): MenuItem[] {
if (!tasks.length) return [];
const ids = tasks.map((task) => task.id);
const task = tasks[0]!;
const multiple = tasks.length > 1;
const archivable = tasks.filter(canArchive).length;
return [
...(!multiple
? ([
{ kind: "action", action: "agents.openTask", label: "Open", arg: task.id },
{ kind: "action", action: "terminal.openAt", label: "Open in Terminal", icon: "terminal", arg: task.cwd },
{ kind: "separator" },
] satisfies MenuItem[])
: []),
{
kind: "action",
action: "agents.archiveTasks",
label: multiple ? tx`Archive (${archivable})` : "Archive",
arg: ids,
disabled: !archivable,
},
{
kind: "action",
action: "agents.deleteTasks",
label: multiple ? tx`Delete (${tasks.length})…` : "Delete…",
arg: ids,
},
];
}
+10 -1
View File
@@ -4739,5 +4739,14 @@
"Cannot select Python interpreter": "无法选择 Python 解释器",
"Python Interpreter: {label}": "Python 解释器:{label}",
"Add to .gitignore": "添加到 .gitignore",
"Add to .git/info/exclude": "添加到 .git/info/exclude"
"Add to .git/info/exclude": "添加到 .git/info/exclude",
"Delete Agent Tasks": "删除 Agent 任务",
"Open Agent Task": "打开 Agent 任务",
"Archive Agent Tasks": "归档 Agent 任务",
"Delete {count} agent tasks? Their branches and files outside worktrees are kept.": "是否删除这 {count} 个 Agent 任务?它们的分支及 worktree 以外的文件将保留。",
"Also remove their worktree folders": "同时删除它们的 worktree 目录",
"Cannot delete agent tasks": "无法删除 Agent 任务",
"Cannot archive agent tasks": "无法归档 Agent 任务",
"Archive ({count})": "归档({count})",
"Delete ({count})…": "删除({count})…"
}
+1
View File
@@ -267,6 +267,7 @@ export function Tree<T>(props: TreeProps<T>) {
commitSelection(next, row.key);
} else {
const a = anchor != null ? (indexOfKey.get(anchor) ?? i) : leadIndex >= 0 ? leadIndex : i;
setAnchor(rows[a]!.key);
const [s, e] = a < i ? [a, i] : [i, a];
const next = new Set<string>();
for (let j = s; j <= e; j++) if (!rows[j]!.loading) next.add(rows[j]!.key);