mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(agents): restore archived tasks for continued conversation (#349)
Archived agent tasks could be opened but never returned to the active list, so their message composer stayed disabled. Unarchive is now available in the task header, in the archived-task notice above the composer, and in the task context menu (including mixed bulk selections). Restoring a task moves it back to the active list, keeps its conversation and worktree, and reconnects the existing agent session on the next message; the backend persists and broadcasts the restored status and refuses restoration while a stopped turn is still settling.
This commit is contained in:
@@ -760,6 +760,26 @@ impl AgentManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns an archived task to the active list without starting the agent.
|
||||
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
{
|
||||
let mut s = task.state.lock();
|
||||
if s.task.status != AgentStatus::Archived {
|
||||
return Ok(());
|
||||
}
|
||||
if s.busy {
|
||||
return Err(IdeError::invalid("the task is still stopping"));
|
||||
}
|
||||
s.task.status = AgentStatus::Ready;
|
||||
s.task.error = None;
|
||||
s.task.updated_ms = now_ms();
|
||||
}
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
|
||||
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
|
||||
@@ -817,3 +817,36 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn archived_tasks_can_be_restored_and_continue_their_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("again"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
mgr.archive(&task.id).unwrap();
|
||||
assert!(mgr.prompt(&task.id, text("again"), false).await.is_err());
|
||||
mgr.unarchive(&task.id).unwrap();
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
|
||||
assert!(mgr.task(&task.id).unwrap().live.lock().is_none());
|
||||
assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len());
|
||||
drop(mgr);
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
|
||||
mgr.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().iter().any(|call| call == "session/load"));
|
||||
assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len() + 2);
|
||||
mgr.unarchive(&task.id).unwrap();
|
||||
}
|
||||
|
||||
@@ -140,6 +140,10 @@ pub fn archive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.archive(&task)
|
||||
}
|
||||
|
||||
pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.unarchive(&task)
|
||||
}
|
||||
|
||||
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
|
||||
host.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
@@ -183,6 +187,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_respond", |c| async move {
|
||||
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
|
||||
});
|
||||
d.add("agent_unarchive", |c| async move { unarchive(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add(
|
||||
"agent_delete",
|
||||
|
||||
@@ -218,6 +218,12 @@ pub fn agent_archive(window: Window, state: State<'_, AppState>, task: String) -
|
||||
agent::archive(&state.host, window.label(), task)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_unarchive(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> {
|
||||
agent::unarchive(&state.host, window.label(), task)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_delete(
|
||||
|
||||
@@ -94,6 +94,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
agent::agent_cancel,
|
||||
agent::agent_respond,
|
||||
agent::agent_archive,
|
||||
agent::agent_unarchive,
|
||||
agent::agent_delete,
|
||||
agent::agent_changes,
|
||||
agent::agent_base_text,
|
||||
|
||||
@@ -112,6 +112,7 @@ export const commands = {
|
||||
/** Answers a permission request; `option: null` cancels it. */
|
||||
agentRespond: (task: string, permission: number, option: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_respond", { task, permission, option })),
|
||||
agentArchive: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_archive", { task })),
|
||||
agentUnarchive: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_unarchive", { task })),
|
||||
agentDelete: (task: string, removeWorktree: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_delete", { task, removeWorktree })),
|
||||
agentChanges: (task: string) => typedError<AgentChange[], IdeError>(__TAURI_INVOKE("agent_changes", { task })),
|
||||
/** Content of `path` when the task started (left side of its diff). */
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { archiveTask, unarchiveTask, deleteTask, openTask, useAgents } from "./store";
|
||||
import { taskActions } from "./taskActions";
|
||||
|
||||
const editor = vi.hoisted(() => ({
|
||||
@@ -32,6 +32,7 @@ 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 () => {}),
|
||||
unarchiveTask: vi.fn(async () => {}),
|
||||
deleteTask: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
@@ -183,6 +184,19 @@ describe("Agents bulk actions", () => {
|
||||
await waitFor(() => expect(archiveTask).toHaveBeenCalledExactlyOnceWith("two"));
|
||||
});
|
||||
|
||||
it("unarchives only archived tasks in a mixed selection", async () => {
|
||||
const tasks = [task("one"), task("two", "archived"), 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: "Unarchive (2)" }));
|
||||
await waitFor(() => expect(unarchiveTask).toHaveBeenCalledTimes(2));
|
||||
expect(vi.mocked(unarchiveTask).mock.calls).toEqual([["two"], ["three"]]);
|
||||
});
|
||||
|
||||
it("cancels the whole batch", async () => {
|
||||
vi.mocked(confirmChecked).mockResolvedValue(null);
|
||||
await taskActions.find((action) => action.id === "agents.deleteTasks")!.run({ ctx: {}, arg: ["one", "two"] });
|
||||
|
||||
@@ -216,6 +216,15 @@ function Header({ task, entries, head }: { task: AgentTask; entries: AgentEntry[
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{task.status === "archived" && (
|
||||
<IconButton
|
||||
icon="undo"
|
||||
title="Unarchive Task"
|
||||
size="small"
|
||||
action="agents.unarchiveTasks"
|
||||
actionArg={[task.id]}
|
||||
/>
|
||||
)}
|
||||
{!busy && task.status !== "archived" && (
|
||||
<IconButton icon="close" title="Archive Task" size="small" onClick={() => void archiveTask(task.id)} />
|
||||
)}
|
||||
@@ -1064,6 +1073,16 @@ function SessionComposer({
|
||||
);
|
||||
return (
|
||||
<div className={styles.composerArea}>
|
||||
{task.status === "archived" && (
|
||||
<div className={styles.banner}>
|
||||
<span className={styles.bannerText}>
|
||||
<T>This task is archived. Unarchive it to continue the conversation.</T>
|
||||
</span>
|
||||
<Button size="small" onClick={() => void executeAction("agents.unarchiveTasks", [task.id])}>
|
||||
Unarchive
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
|
||||
{task.queue.length > 0 && <Queue task={task} />}
|
||||
<Composer
|
||||
|
||||
@@ -489,5 +489,6 @@ export function addReferences(refs: Array<{ path: string; rel: string; mention:
|
||||
export const cancelTask = (task: string, subagent: string | null = null) => call(commands.agentCancel(task, subagent));
|
||||
export const respond = (task: string, permission: number, option: string | null) =>
|
||||
call(commands.agentRespond(task, permission, option));
|
||||
export const unarchiveTask = (task: string) => call(commands.agentUnarchive(task));
|
||||
export const archiveTask = (task: string) => call(commands.agentArchive(task));
|
||||
export const deleteTask = (task: string, removeWorktree: boolean) => call(commands.agentDelete(task, removeWorktree));
|
||||
|
||||
@@ -4,7 +4,7 @@ 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";
|
||||
import { archiveTask, unarchiveTask, deleteTask, openTask, useAgents } from "./store";
|
||||
|
||||
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
|
||||
|
||||
@@ -75,6 +75,18 @@ export const taskActions: Action[] = [
|
||||
run: ({ arg }) =>
|
||||
runTasks(resolveTasks(arg).filter(canArchive), (task) => archiveTask(task.id), tx("Cannot archive agent tasks")),
|
||||
},
|
||||
{
|
||||
id: "agents.unarchiveTasks",
|
||||
title: "Unarchive Agent Tasks",
|
||||
category: "Agents",
|
||||
hidden: true,
|
||||
run: ({ arg }) =>
|
||||
runTasks(
|
||||
resolveTasks(arg).filter((task) => task.status === "archived"),
|
||||
(task) => unarchiveTask(task.id),
|
||||
tx("Cannot unarchive agent tasks"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "agents.deleteTasks",
|
||||
title: "Delete Agent Tasks",
|
||||
@@ -89,6 +101,7 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] {
|
||||
const ids = tasks.map((task) => task.id);
|
||||
const task = tasks[0]!;
|
||||
const multiple = tasks.length > 1;
|
||||
const archived = tasks.filter((task) => task.status === "archived").length;
|
||||
const archivable = tasks.filter(canArchive).length;
|
||||
return [
|
||||
...(!multiple
|
||||
@@ -98,6 +111,16 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] {
|
||||
{ kind: "separator" },
|
||||
] satisfies MenuItem[])
|
||||
: []),
|
||||
...(archived
|
||||
? ([
|
||||
{
|
||||
kind: "action",
|
||||
action: "agents.unarchiveTasks",
|
||||
label: multiple ? tx`Unarchive (${archived})` : "Unarchive",
|
||||
arg: ids,
|
||||
},
|
||||
] satisfies MenuItem[])
|
||||
: []),
|
||||
{
|
||||
kind: "action",
|
||||
action: "agents.archiveTasks",
|
||||
|
||||
@@ -4899,5 +4899,11 @@
|
||||
"Credentials the IDE answers git's HTTPS prompts with, stored in the app's own secret file (not git's credential.helper). A rejected credential is forgotten automatically.": "IDE 用于应答 git HTTPS 提示的凭据,存储在应用自己的密钥文件中(而非 git 的 credential.helper)。被拒绝的凭据会自动删除。",
|
||||
"Saved git credentials": "已保存的 git 凭据",
|
||||
"Forget": "忘记",
|
||||
"Remember for this host": "记住此主机"
|
||||
"Remember for this host": "记住此主机",
|
||||
"Unarchive Task": "取消归档任务",
|
||||
"This task is archived. Unarchive it to continue the conversation.": "此任务已归档。取消归档后即可继续对话。",
|
||||
"Unarchive": "取消归档",
|
||||
"Unarchive Agent Tasks": "取消归档 Agent 任务",
|
||||
"Cannot unarchive agent tasks": "无法取消归档 Agent 任务",
|
||||
"Unarchive ({archived})": "取消归档({archived})"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user