diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index f30c09a..3a4785d 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -2,8 +2,8 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; -use serde::Deserialize; -use tauri::State; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, State}; use crate::acp::binary_cache; use crate::acp::error::AcpError; @@ -18,6 +18,26 @@ use crate::db::service::agent_setting_service; use crate::db::AppDatabase; use crate::models::agent::AgentType; +const ACP_AGENTS_UPDATED_EVENT: &str = "app://acp-agents-updated"; + +#[derive(Serialize, Clone)] +#[serde(rename_all = "snake_case")] +struct AcpAgentsUpdatedEventPayload { + reason: &'static str, + agent_type: Option, +} + +fn emit_acp_agents_updated( + app: &tauri::AppHandle, + reason: &'static str, + agent_type: Option, +) { + let _ = app.emit( + ACP_AGENTS_UPDATED_EVENT, + AcpAgentsUpdatedEventPayload { reason, agent_type }, + ); +} + fn parse_version_output(output: &std::process::Output) -> Option { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -1531,6 +1551,7 @@ pub async fn acp_update_agent_preferences( codex_auth_json: Option, codex_config_toml: Option, db: State<'_, AppDatabase>, + app: tauri::AppHandle, ) -> Result<(), AcpError> { let default = agent_setting_service::AgentDefaultInput { agent_type, @@ -1585,6 +1606,7 @@ pub async fn acp_update_agent_preferences( codex_config_toml.as_deref(), )?; } + emit_acp_agents_updated(&app, "preferences_updated", Some(agent_type)); return Ok(()); } @@ -1595,6 +1617,7 @@ pub async fn acp_update_agent_preferences( if let Some(raw) = config_json.as_deref() { persist_agent_local_config_json(agent_type, Some(raw))?; } + emit_acp_agents_updated(&app, "preferences_updated", Some(agent_type)); return Ok(()); } @@ -1613,11 +1636,15 @@ pub async fn acp_update_agent_preferences( let local_patch_json = serde_json::to_string(&local_patch_value) .map_err(|e| AcpError::protocol(format!("serialize local patch failed: {e}")))?; persist_agent_local_config_json(agent_type, Some(local_patch_json.as_str()))?; + emit_acp_agents_updated(&app, "preferences_updated", Some(agent_type)); Ok(()) } #[tauri::command] -pub async fn acp_download_agent_binary(agent_type: AgentType) -> Result<(), AcpError> { +pub async fn acp_download_agent_binary( + agent_type: AgentType, + app: tauri::AppHandle, +) -> Result<(), AcpError> { let meta = registry::get_agent_meta(agent_type); match meta.distribution { registry::AgentDistribution::Binary { @@ -1639,6 +1666,7 @@ pub async fn acp_download_agent_binary(agent_type: AgentType) -> Result<(), AcpE let _ = binary_cache::ensure_binary_for_agent(agent_type, version, fallback.url, cmd) .await?; + emit_acp_agents_updated(&app, "binary_downloaded", Some(agent_type)); Ok(()) } registry::AgentDistribution::Npx { .. } | registry::AgentDistribution::Uvx { .. } => Err( @@ -1678,6 +1706,7 @@ pub async fn acp_prepare_npx_agent( agent_type: AgentType, registry_version: Option, db: State<'_, AppDatabase>, + app: tauri::AppHandle, ) -> Result { let meta = registry::get_agent_meta(agent_type); match meta.distribution { @@ -1720,6 +1749,7 @@ pub async fn acp_prepare_npx_agent( ) .await .map_err(|e| AcpError::protocol(e.to_string()))?; + emit_acp_agents_updated(&app, "npx_prepared", Some(agent_type)); Ok(resolved) } registry::AgentDistribution::Binary { .. } => Err(AcpError::protocol( @@ -1736,6 +1766,7 @@ pub async fn acp_prepare_uvx_agent( agent_type: AgentType, registry_version: Option, db: State<'_, AppDatabase>, + app: tauri::AppHandle, ) -> Result { let meta = registry::get_agent_meta(agent_type); match meta.distribution { @@ -1778,6 +1809,7 @@ pub async fn acp_prepare_uvx_agent( ) .await .map_err(|e| AcpError::protocol(e.to_string()))?; + emit_acp_agents_updated(&app, "uvx_prepared", Some(agent_type)); Ok(resolved) } registry::AgentDistribution::Npx { .. } => Err(AcpError::protocol( @@ -1793,6 +1825,7 @@ pub async fn acp_prepare_uvx_agent( pub async fn acp_uninstall_agent( agent_type: AgentType, db: State<'_, AppDatabase>, + app: tauri::AppHandle, ) -> Result<(), AcpError> { let meta = registry::get_agent_meta(agent_type); match meta.distribution { @@ -1810,6 +1843,7 @@ pub async fn acp_uninstall_agent( agent_setting_service::set_installed_version(&db.conn, agent_type, None) .await .map_err(|e| AcpError::protocol(e.to_string()))?; + emit_acp_agents_updated(&app, "agent_uninstalled", Some(agent_type)); Ok(()) } @@ -1817,6 +1851,7 @@ pub async fn acp_uninstall_agent( pub async fn acp_reorder_agents( agent_types: Vec, db: State<'_, AppDatabase>, + app: tauri::AppHandle, ) -> Result<(), AcpError> { if agent_types.is_empty() { return Ok(()); @@ -1831,6 +1866,7 @@ pub async fn acp_reorder_agents( AcpError::protocol(message) } })?; + emit_acp_agents_updated(&app, "agent_reordered", None); Ok(()) } diff --git a/src-tauri/src/commands/system_settings.rs b/src-tauri/src/commands/system_settings.rs index 74bf96d..370dd0c 100644 --- a/src-tauri/src/commands/system_settings.rs +++ b/src-tauri/src/commands/system_settings.rs @@ -1,5 +1,5 @@ use sea_orm::DatabaseConnection; -use tauri::State; +use tauri::{Emitter, State}; use crate::app_error::AppCommandError; use crate::db::service::app_metadata_service; @@ -9,6 +9,7 @@ use crate::network::proxy; const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings"; const SYSTEM_LANGUAGE_SETTINGS_KEY: &str = "system_language_settings"; +const LANGUAGE_SETTINGS_UPDATED_EVENT: &str = "app://language-settings-updated"; fn normalize_proxy_settings( settings: SystemProxySettings, @@ -118,6 +119,7 @@ pub async fn get_system_language_settings( pub async fn update_system_language_settings( settings: SystemLanguageSettings, db: State<'_, AppDatabase>, + app: tauri::AppHandle, ) -> Result { let serialized = serde_json::to_string(&settings).map_err(|e| { AppCommandError::invalid_input("Failed to serialize language settings") @@ -128,5 +130,7 @@ pub async fn update_system_language_settings( .await .map_err(AppCommandError::from)?; + let _ = app.emit(LANGUAGE_SETTINGS_UPDATED_EVENT, &settings); + Ok(settings) } diff --git a/src/components/chat/agent-selector.tsx b/src/components/chat/agent-selector.tsx index 8e1c956..f79a6e5 100644 --- a/src/components/chat/agent-selector.tsx +++ b/src/components/chat/agent-selector.tsx @@ -8,6 +8,8 @@ import { AGENT_LABELS } from "@/lib/types" import { AgentIcon } from "@/components/agent-icon" import { cn } from "@/lib/utils" +const ACP_AGENTS_UPDATED_EVENT = "app://acp-agents-updated" + interface AgentSelectorProps { defaultAgentType?: AgentType onSelect: (agentType: AgentType) => void @@ -31,16 +33,20 @@ export function AgentSelector({ useEffect(() => { let cancelled = false - acpListAgents() - .then((list) => { - if (cancelled) return + let latestRequestId = 0 + + const reloadAgents = async () => { + const requestId = latestRequestId + 1 + latestRequestId = requestId + try { + const list = await acpListAgents() + if (cancelled || requestId !== latestRequestId) return const sorted = [...list].sort( (a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name) ) const visible = sorted.filter((a) => a.enabled) setAgents(visible) onAgentsLoaded?.(visible) - // Auto-select default if it exists in the list if (defaultAgentType) { const found = visible.find( (a) => a.agent_type === defaultAgentType && a.available @@ -48,7 +54,6 @@ export function AgentSelector({ if (found) { setSelected(found.agent_type) } else { - // Fall back to first available const first = visible.find((a) => a.available) if (first) { setSelected(first.agent_type) @@ -62,15 +67,44 @@ export function AgentSelector({ onSelect(first.agent_type) } } - }) - .catch(() => { - if (!cancelled) { + } catch { + if (!cancelled && requestId === latestRequestId) { setAgents([]) onAgentsLoaded?.([]) } + } + } + + void reloadAgents() + const onWindowFocus = () => { + void reloadAgents() + } + window.addEventListener("focus", onWindowFocus) + + let unlisten: (() => void) | null = null + void import("@tauri-apps/api/event") + .then(({ listen }) => + listen(ACP_AGENTS_UPDATED_EVENT, () => { + void reloadAgents() + }) + ) + .then((dispose) => { + if (cancelled) { + dispose() + return + } + unlisten = dispose }) + .catch(() => { + // Ignore when non-tauri runtime. + }) + return () => { cancelled = true + window.removeEventListener("focus", onWindowFocus) + if (unlisten) { + unlisten() + } } }, [defaultAgentType, onAgentsLoaded, onSelect]) diff --git a/src/components/chat/welcome-input-panel.tsx b/src/components/chat/welcome-input-panel.tsx index fe7d020..3aef442 100644 --- a/src/components/chat/welcome-input-panel.tsx +++ b/src/components/chat/welcome-input-panel.tsx @@ -49,6 +49,8 @@ import { import { Message, MessageContent } from "@/components/ai-elements/message" import { ContentPartsRenderer } from "@/components/message/content-parts-renderer" +const ACP_AGENTS_UPDATED_EVENT = "app://acp-agents-updated" + interface WelcomeInputPanelProps { defaultAgentType?: AgentType workingDir?: string @@ -56,6 +58,11 @@ interface WelcomeInputPanelProps { isActive?: boolean } +interface AgentsUpdatedEventPayload { + reason?: string + agent_type?: AgentType | null +} + function normalizeErrorMessage(error: unknown): string { if (error instanceof Error) return error.message return String(error) @@ -318,6 +325,7 @@ export function WelcomeInputPanel({ // the DB conversation ID and the ACP session ID are available. const externalIdSavedRef = useRef(false) const sessionIdRef = useRef(null) + const refreshingCurrentAgentRef = useRef(false) useEffect(() => { if (connSessionId) { sessionIdRef.current = connSessionId @@ -348,6 +356,75 @@ export function WelcomeInputPanel({ const isConnecting = connStatus === "connecting" || connStatus === "downloading" + useEffect(() => { + let cancelled = false + let unlisten: (() => void) | null = null + + const syncCurrentAgentStatus = async () => { + if (cancelled) return + if (phase !== "welcome") return + if (!workingDir) return + if (refreshingCurrentAgentRef.current) return + if (connStatus === "prompting" || isConnecting) return + + refreshingCurrentAgentRef.current = true + try { + setAgentConnectError(null) + if (connStatus === "connected") { + await connDisconnect() + } + await connConnect(selectedAgentRef.current, workingDir, undefined, { + source: "auto_link", + }) + if (!cancelled) { + setAgentConnectError(null) + } + } catch (error) { + if (!cancelled) { + setAgentConnectError(normalizeErrorMessage(error)) + } + if (!isExpectedAutoLinkError(error)) { + console.error("[WelcomePanel] refresh current agent status:", error) + } + } finally { + refreshingCurrentAgentRef.current = false + } + } + + void import("@tauri-apps/api/event") + .then(({ listen }) => + listen(ACP_AGENTS_UPDATED_EVENT, (event) => { + if (cancelled) return + if (event.payload?.reason === "agent_reordered") return + const changedAgentType = event.payload?.agent_type + if ( + changedAgentType && + changedAgentType !== selectedAgentRef.current + ) { + return + } + void syncCurrentAgentStatus() + }) + ) + .then((dispose) => { + if (cancelled) { + dispose() + return + } + unlisten = dispose + }) + .catch(() => { + // Ignore when non-tauri runtime. + }) + + return () => { + cancelled = true + if (unlisten) { + unlisten() + } + } + }, [connConnect, connDisconnect, connStatus, isConnecting, phase, workingDir]) + const prevStatusRef = useRef(connStatus) // Accumulate history when prompting completes diff --git a/src/components/i18n-provider.tsx b/src/components/i18n-provider.tsx index ce3b6d9..a9040a0 100644 --- a/src/components/i18n-provider.tsx +++ b/src/components/i18n-provider.tsx @@ -34,6 +34,7 @@ interface AppI18nContextValue { } const AppI18nContext = createContext(null) +const LANGUAGE_SETTINGS_UPDATED_EVENT = "app://language-settings-updated" function subscribeSystemLocale(onStoreChange: () => void) { if (typeof window === "undefined") return () => {} @@ -128,6 +129,57 @@ export function AppI18nProvider({ [] ) + useEffect(() => { + if (typeof window === "undefined") return + + const onStorage = (event: StorageEvent) => { + if (event.key !== LANGUAGE_SETTINGS_STORAGE_KEY || !event.newValue) return + + try { + const next = normalizeLanguageSettings( + JSON.parse(event.newValue) as SystemLanguageSettings + ) + setLanguageSettingsState(next) + } catch { + // Ignore malformed storage payloads. + } + } + + window.addEventListener("storage", onStorage) + + let unlisten: (() => void) | null = null + let cancelled = false + + void import("@tauri-apps/api/event") + .then(({ listen }) => + listen( + LANGUAGE_SETTINGS_UPDATED_EVENT, + (event) => { + if (cancelled) return + setLanguageSettings(event.payload) + } + ) + ) + .then((dispose) => { + if (cancelled) { + dispose() + return + } + unlisten = dispose + }) + .catch(() => { + // Ignore when running in non-tauri environment. + }) + + return () => { + cancelled = true + window.removeEventListener("storage", onStorage) + if (unlisten) { + unlisten() + } + } + }, [setLanguageSettings]) + useEffect(() => { let cancelled = false