From 831cd59ab0686e403170d86d16386cad005a2ca2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:12:58 +0800 Subject: [PATCH] feat(remote): Run agent tasks on the remote host; show why the agent list cannot load --- crates/ide-host/src/ops/agent.rs | 114 ++++++++++++++++++++++++++ crates/ide-host/src/ops/mod.rs | 2 + src-tauri/src/commands/agent.rs | 49 ++++++----- src/features/agents/Agents.module.css | 8 ++ src/features/agents/AgentsView.tsx | 11 ++- src/features/agents/store.ts | 17 ++-- src/platform/locales/text/zh.json | 3 +- 7 files changed, 171 insertions(+), 33 deletions(-) create mode 100644 crates/ide-host/src/ops/agent.rs diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs new file mode 100644 index 000000000..020a1fcb2 --- /dev/null +++ b/crates/ide-host/src/ops/agent.rs @@ -0,0 +1,114 @@ +//! Agents tool window: coding agent tasks of the project (over ACP). In a remote +//! window they run on the host, next to the project they work on. +use super::Emit; +use crate::Host; +use crate::rpc::Dispatcher; +use ide_agent::{ + AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask, +}; +use ide_core::{IdeResult, IpcPath}; + +/// Streams the project's agent events to this window; returns every task. +pub fn subscribe(host: &Host, window: &str, on_event: Emit) -> IdeResult> { + Ok(host.project(window)?.agents.subscribe(on_event)) +} + +pub fn providers(host: &Host, window: &str) -> IdeResult> { + Ok(host.project(window)?.agents.providers()) +} + +/// Slash commands to complete for `provider` in `task`'s directory, or in the project +/// before a task exists (skills and custom commands on disk included). +pub async fn commands( + host: &Host, + window: &str, + provider: String, + task: Option, +) -> IdeResult> { + host.project(window)?.agents.commands(&provider, task.as_deref()).await +} + +pub fn entries(host: &Host, window: &str, task: String) -> IdeResult> { + host.project(window)?.agents.entries(&task) +} + +pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult { + host.project(window)?.agents.create(request).await +} + +/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. +pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> { + host.project(window)?.agents.prompt(&task, prompt, now).await +} + +/// Drops a queued message and returns it (to edit it). +pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeResult { + host.project(window)?.agents.unqueue(&task, &queued) +} + +/// Sends a queued message now, interrupting the current turn. +pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> { + host.project(window)?.agents.send_queued_now(&task, &queued) +} + +/// Changes a session setting (model, mode, thinking effort…). +pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> { + host.project(window)?.agents.set_config(&task, &option, value).await +} + +pub fn cancel(host: &Host, window: &str, task: String) -> IdeResult<()> { + host.project(window)?.agents.cancel(&task) +} + +/// Answers a permission request; `option: None` cancels it. +pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option) -> IdeResult<()> { + host.project(window)?.agents.respond(&task, permission, option) +} + +pub fn archive(host: &Host, window: &str, task: String) -> IdeResult<()> { + host.project(window)?.agents.archive(&task) +} + +pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> { + host.project(window)?.agents.delete(&task, remove_worktree).await +} + +pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult> { + host.project(window)?.agents.changes(&task).await +} + +/// Content of `path` when the task started (left side of its diff). +pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult> { + host.project(window)?.agents.base_text(&task, path.into_path_buf()).await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) }); + d.add("agent_providers", |c| async move { providers(&c.host, &c.window) }); + d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await }); + d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?) }); + d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await }); + d.add("agent_prompt", |c| async move { + prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await + }); + d.add("agent_unqueue", |c| async move { unqueue(&c.host, &c.window, c.arg("task")?, c.arg("queued")?) }); + d.add( + "agent_send_queued_now", + |c| async move { send_queued_now(&c.host, &c.window, c.arg("task")?, c.arg("queued")?) }, + ); + d.add("agent_set_config", |c| async move { + set_config(&c.host, &c.window, c.arg("task")?, c.arg("option")?, c.arg("value")?).await + }); + d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?) }); + d.add("agent_respond", |c| async move { + respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?) + }); + d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) }); + d.add( + "agent_delete", + |c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await }, + ); + d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await }); + d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await }); +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index b7cbe1d58..4f5587540 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -2,6 +2,7 @@ //! remote server ([`crate::rpc`]): each takes the [`Host`], the calling window //! and the command's arguments, and streams through [`Emit`] callbacks. +pub mod agent; pub mod analysis; pub mod build; pub mod bytes; @@ -55,6 +56,7 @@ impl Clone for Channel { /// Registers the commands of every module with the remote server. pub fn register_all(d: &mut crate::rpc::Dispatcher) { + agent::register(d); lsp::register(d); nav::register(d); refactor::register(d); diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index f04372c5e..1c69a9b19 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -1,8 +1,11 @@ +//! IPC commands of `ide_host::ops::agent` (the shared implementation). + use crate::state::AppState; use ide_agent::{ AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask, }; use ide_core::{IdeResult, IpcPath}; +use ide_host::ops::agent; use std::sync::Arc; use tauri::ipc::Channel; use tauri::{State, Window}; @@ -15,16 +18,19 @@ pub fn agent_subscribe( state: State<'_, AppState>, on_event: Channel, ) -> IdeResult> { - let project = state.project(window.label())?; - Ok(project.agents.subscribe(Arc::new(move |e| { - let _ = on_event.send(e); - }))) + agent::subscribe( + &state.host, + window.label(), + Arc::new(move |e| { + let _ = on_event.send(e); + }), + ) } #[tauri::command] #[specta::specta] pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult> { - Ok(state.project(window.label())?.agents.providers()) + agent::providers(&state.host, window.label()) } /// Slash commands to complete for `provider` in `task`'s directory, or in the project @@ -37,21 +43,19 @@ pub async fn agent_commands( provider: String, task: Option, ) -> IdeResult> { - let project = state.project(window.label())?; - project.agents.commands(&provider, task.as_deref()).await + agent::commands(&state.host, window.label(), provider, task).await } #[tauri::command] #[specta::specta] pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult> { - state.project(window.label())?.agents.entries(&task) + agent::entries(&state.host, window.label(), task) } #[tauri::command] #[specta::specta] pub async fn agent_create(window: Window, state: State<'_, AppState>, request: NewAgentTask) -> IdeResult { - let project = state.project(window.label())?; - project.agents.create(request).await + agent::create(&state.host, window.label(), request).await } /// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. @@ -64,8 +68,7 @@ pub async fn agent_prompt( prompt: AgentPrompt, now: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - project.agents.prompt(&task, prompt, now).await + agent::prompt(&state.host, window.label(), task, prompt, now).await } /// Drops a queued message and returns it (to edit it). @@ -77,7 +80,7 @@ pub fn agent_unqueue( task: String, queued: String, ) -> IdeResult { - state.project(window.label())?.agents.unqueue(&task, &queued) + agent::unqueue(&state.host, window.label(), task, queued) } /// Sends a queued message now, interrupting the current turn. @@ -89,7 +92,7 @@ pub fn agent_send_queued_now( task: String, queued: String, ) -> IdeResult<()> { - state.project(window.label())?.agents.send_queued_now(&task, &queued) + agent::send_queued_now(&state.host, window.label(), task, queued) } /// Changes a session setting (model, mode, thinking effort…). @@ -102,14 +105,13 @@ pub async fn agent_set_config( option: String, value: String, ) -> IdeResult<()> { - let project = state.project(window.label())?; - project.agents.set_config(&task, &option, value).await + agent::set_config(&state.host, window.label(), task, option, value).await } #[tauri::command] #[specta::specta] pub fn agent_cancel(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> { - state.project(window.label())?.agents.cancel(&task) + agent::cancel(&state.host, window.label(), task) } /// Answers a permission request; `option: null` cancels it. @@ -122,13 +124,13 @@ pub fn agent_respond( permission: u32, option: Option, ) -> IdeResult<()> { - state.project(window.label())?.agents.respond(&task, permission, option) + agent::respond(&state.host, window.label(), task, permission, option) } #[tauri::command] #[specta::specta] pub fn agent_archive(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> { - state.project(window.label())?.agents.archive(&task) + agent::archive(&state.host, window.label(), task) } #[tauri::command] @@ -139,15 +141,13 @@ pub async fn agent_delete( task: String, remove_worktree: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - project.agents.delete(&task, remove_worktree).await + agent::delete(&state.host, window.label(), task, remove_worktree).await } #[tauri::command] #[specta::specta] pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: String) -> IdeResult> { - let project = state.project(window.label())?; - project.agents.changes(&task).await + agent::changes(&state.host, window.label(), task).await } /// Content of `path` when the task started (left side of its diff). @@ -159,6 +159,5 @@ pub async fn agent_base_text( task: String, path: IpcPath, ) -> IdeResult> { - let project = state.project(window.label())?; - project.agents.base_text(&task, path.into_path_buf()).await + agent::base_text(&state.host, window.label(), task, path).await } diff --git a/src/features/agents/Agents.module.css b/src/features/agents/Agents.module.css index e2eb069e5..b52ea5300 100644 --- a/src/features/agents/Agents.module.css +++ b/src/features/agents/Agents.module.css @@ -9,6 +9,14 @@ min-height: 0; } +.loading { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--fg-muted); +} + .row { display: flex; align-items: center; diff --git a/src/features/agents/AgentsView.tsx b/src/features/agents/AgentsView.tsx index c4bd39177..04e2a5a5c 100644 --- a/src/features/agents/AgentsView.tsx +++ b/src/features/agents/AgentsView.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react"; import type { AgentStatus as Status, AgentTask } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { confirmChecked } from "@/workbench/messages"; -import { Icon, IconButton, Spinner, T, Tree, showContextMenu, type MenuItem } from "@/ui"; +import { EmptyState, Icon, IconButton, Spinner, T, Tree, showContextMenu, type MenuItem } from "@/ui"; import { age, branchLabel, groupTasks, isBusy, type TaskGroup } from "./model"; import { archiveTask, deleteTask, openHome, openTask, useAgents } from "./store"; import styles from "./Agents.module.css"; @@ -35,13 +35,20 @@ export function TaskStatusIcon({ status }: { status: Status }) { export function AgentsView() { const loaded = useAgents((s) => s.loaded); + const error = useAgents((s) => s.error); const tasks = useAgents((s) => s.tasks); const providers = useAgents((s) => s.providers); const [collapsed, setCollapsed] = useState>(() => new Set(["g:archived"])); const [selected, setSelected] = useState>(() => new Set()); const roots = useMemo(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]); const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]); - if (!loaded) return ; + if (!loaded) + return ( +
+ +
+ ); + if (error) return {error}; const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id; return ( diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 9606211ba..aa31e2ad3 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -31,6 +31,8 @@ export const HOME = "home"; interface AgentsState { loaded: boolean; + /** Why the tasks could not be loaded. */ + error: string | null; tasks: Map; /** Entries of the tasks opened in this window. */ entries: Map; @@ -69,6 +71,7 @@ const fallbackDraft: Draft = { provider: "claude", config: {}, isolation: "workt export const useAgents = create(() => ({ loaded: false, + error: null, tasks: new Map(), entries: new Map(), changes: new Map(), @@ -111,13 +114,17 @@ export async function connect(): Promise { channel.onmessage = (e) => { if (gen === generation) onEvent(e); }; - const [tasks, providers] = await Promise.all([ - call(commands.agentSubscribe(channel)), - call(commands.agentProviders()), - ]); + let tasks: AgentTask[], providers: AgentProvider[]; + try { + [tasks, providers] = await Promise.all([call(commands.agentSubscribe(channel)), call(commands.agentProviders())]); + } catch (e) { + if (gen === generation) useAgents.setState({ loaded: true, error: e instanceof Error ? e.message : String(e) }); + return; + } if (gen !== generation) return; useAgents.setState({ loaded: true, + error: null, tasks: new Map(tasks.map((t) => [t.id, t])), entries: new Map(), changes: new Map(), @@ -133,7 +140,7 @@ export async function connect(): Promise { export function disconnect() { generation++; - useAgents.setState({ loaded: false, tasks: new Map(), entries: new Map(), changes: new Map() }); + useAgents.setState({ loaded: false, error: null, tasks: new Map(), entries: new Map(), changes: new Map() }); } function onEvent(e: AgentEvent) { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 7e5ebae5c..949229827 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4159,5 +4159,6 @@ "Downloading the IDE backend…": "正在下载 IDE 后端…", "Installing the IDE backend on the host…": "正在主机上安装 IDE 后端…", "Starting the IDE backend…": "正在启动 IDE 后端…", - "Opening the project…": "正在打开项目…" + "Opening the project…": "正在打开项目…", + "Cannot load agent tasks": "无法加载智能体任务" }