mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-10 10:41:30 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0486a6826 | ||
|
|
3c87a8eea7 | ||
|
|
aac924517c |
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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})…"
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user