diff --git a/crates/protocol/src/command.rs b/crates/protocol/src/command.rs index 02dc0a2d0..d8bd5fc8c 100644 --- a/crates/protocol/src/command.rs +++ b/crates/protocol/src/command.rs @@ -112,8 +112,8 @@ pub enum Command { /// provider profile (Codex, Claude Code). RefreshProviderUsage, CheckProviderVersions, - UpdateProvider { - provider: ProviderKind, + UpdateProviders { + providers: Vec, }, SetSidebarCollapsed { collapsed: bool, diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 2d368e58f..0693ea74d 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -173,6 +173,7 @@ pub struct ProvidersStatus { pub model_catalogs: HashMap>, pub models_loading: HashMap, pub provider_versions: HashMap, + pub update_run: Option, pub tcode_update: TcodeUpdateStatus, pub provider_snapshots: HashMap, /// Latest account usage per profile id (Codex / Claude Code only). @@ -291,6 +292,21 @@ pub enum PluginOperationTarget { }, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderUpdateRun { + pub total: usize, + pub completed: usize, + pub current: Option, + pub failed: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderUpdateAvailable { + pub provider: ProviderKind, + pub version: String, + pub automatic: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct ProviderVersionStatus { pub installed: Option, @@ -501,6 +517,21 @@ pub enum MergeWorktreeFailure { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", content = "content", rename_all = "snake_case")] pub enum RuntimeToast { + ProviderUpdatesAvailable { + updates: Vec, + }, + ProviderUpdateStarted { + operation: RuntimeOperationId, + run: ProviderUpdateRun, + }, + ProviderUpdateProgress { + operation: RuntimeOperationId, + run: ProviderUpdateRun, + }, + ProviderUpdateFinished { + operation: RuntimeOperationId, + run: ProviderUpdateRun, + }, GitBusy, GitStarted { operation: RuntimeOperationId, @@ -558,8 +589,6 @@ pub enum RuntimeToast { pub enum RuntimeError { External(String), PersistSettings { error: String }, - UpdateUnknown { provider: ProviderKind }, - UpdateFailed { provider: ProviderKind }, TerminalStart { error: String }, TerminalRestart { error: String }, PersistProject { error: String }, @@ -586,19 +615,9 @@ pub enum RuntimeError { #[serde(tag = "type", content = "content", rename_all = "snake_case")] pub enum RuntimeNotice { ProviderMessage(String), - UpdateAvailable { - provider: ProviderKind, - version: String, - }, TcodeUpdateAvailable { version: String, }, - UpdatingProvider { - provider: ProviderKind, - }, - UpdateDone { - provider: ProviderKind, - }, NativeRewindCompleted { mode: RewindMode, }, @@ -631,10 +650,7 @@ impl RuntimeNotice { pub fn severity(&self) -> NoticeSeverity { match self { Self::ProviderMessage(_) | Self::WorktreeMergeFailed { .. } => NoticeSeverity::Warning, - Self::UpdateAvailable { .. } - | Self::TcodeUpdateAvailable { .. } - | Self::UpdatingProvider { .. } - | Self::UpdateDone { .. } + Self::TcodeUpdateAvailable { .. } | Self::NativeRewindCompleted { .. } | Self::PlanSaved { .. } | Self::SwitchedBranch { .. } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 282fd5455..52f032c65 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -17,10 +17,11 @@ pub use event::{ AcpMarketplaceItem, EventEnvelope, ExternalImportState, ExternalImportStatus, GitActionRequest, GitStatusStatus, IndexSnapshot, IndexSummary, MergeWorktreeFailure, NoticeSeverity, PluginCatalogState, PluginChallenge, PluginChallengeKind, PluginOperationTarget, - PluginStaleReason, ProviderPluginCatalog, ProviderVersionStatus, ProvidersStatus, - QueuedMessageStatus, RuntimeEffect, RuntimeError, RuntimeNotice, RuntimeNotification, - RuntimeOperationId, RuntimeToast, ServerEvent, SessionEventRecord, SessionStatus, - TcodeUpdateStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic, + PluginStaleReason, ProviderPluginCatalog, ProviderUpdateAvailable, ProviderUpdateRun, + ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError, + RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent, + SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus, + TerminalSplitStatus, TerminalStatus, Topic, }; pub use query::{ ExternalThread, GitDiffResult, GitDiffScope, GitFileText, HostedDevice, HostingAction, @@ -49,7 +50,9 @@ pub use wire::{ // `ProviderKind` has no unknown fallback; version 9 sends Orchestrate rows that // leave bundled guidance out, which an older peer would read as no guidance. // -// Unreleased: provider plugin catalogs, their commands, challenges and switches. +// Unreleased: provider plugin catalogs, their commands, challenges and switches; +// provider update availability merged per check round, UpdateProvider replaced +// by UpdateProviders, and sequential update run status with toast progress. pub const PROTOCOL_VERSION: u32 = 9; #[cfg(test)] diff --git a/crates/protocol/src/tests.rs b/crates/protocol/src/tests.rs index 5f78080c0..344424166 100644 --- a/crates/protocol/src/tests.rs +++ b/crates/protocol/src/tests.rs @@ -6,6 +6,63 @@ use serde_json::json; use super::*; +#[test] +fn provider_update_command_and_toasts_have_literal_wire_contracts() { + let command = Command::UpdateProviders { + providers: vec![agent::ProviderKind::ClaudeCode, agent::ProviderKind::Codex], + }; + let wire = + json!({"type": "update_providers", "content": {"providers": ["claude_code", "codex"]}}); + assert_eq!(serde_json::to_value(&command).unwrap(), wire); + assert_eq!(serde_json::from_value::(wire).unwrap(), command); + let updates = RuntimeToast::ProviderUpdatesAvailable { + updates: vec![ProviderUpdateAvailable { + provider: agent::ProviderKind::Codex, + version: "2.0.0".into(), + automatic: true, + }], + }; + let wire = json!({"type": "provider_updates_available", "content": {"updates": [{"provider": "codex", "version": "2.0.0", "automatic": true}]}}); + assert_eq!(serde_json::to_value(&updates).unwrap(), wire); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + updates + ); + let run = ProviderUpdateRun { + total: 2, + completed: 1, + current: Some(agent::ProviderKind::Codex), + failed: vec![agent::ProviderKind::ClaudeCode], + }; + for (name, toast) in [ + ( + "provider_update_started", + RuntimeToast::ProviderUpdateStarted { + operation: RuntimeOperationId(7), + run: run.clone(), + }, + ), + ( + "provider_update_progress", + RuntimeToast::ProviderUpdateProgress { + operation: RuntimeOperationId(7), + run: run.clone(), + }, + ), + ( + "provider_update_finished", + RuntimeToast::ProviderUpdateFinished { + operation: RuntimeOperationId(7), + run, + }, + ), + ] { + let wire = json!({"type": name, "content": {"operation": 7, "run": {"total": 2, "completed": 1, "current": "codex", "failed": ["claude_code"]}}}); + assert_eq!(serde_json::to_value(&toast).unwrap(), wire); + assert_eq!(serde_json::from_value::(wire).unwrap(), toast); + } +} + #[test] fn provider_update_status_accepts_older_hosts_and_preserves_terminal_requirement() { let older = json!({ diff --git a/crates/runtime/src/app/providers.rs b/crates/runtime/src/app/providers.rs index f317ccd34..afadb48cc 100644 --- a/crates/runtime/src/app/providers.rs +++ b/crates/runtime/src/app/providers.rs @@ -1,9 +1,25 @@ use super::*; +use tcode_protocol::{ProviderUpdateAvailable, ProviderUpdateRun}; + +#[derive(Default)] +struct VersionCheckRound { + pending: usize, + updates: Vec, +} + +struct UpdateRun { + operation: RuntimeOperationId, + status: ProviderUpdateRun, + pending: std::collections::VecDeque, +} pub struct ProviderCatalog { pub model_catalogs: HashMap>, pub models_loading: HashMap, pub provider_versions: HashMap, + version_rounds: HashMap, + next_version_round: u64, + update_run: Option, pub tcode_update: TcodeUpdateState, pub provider_snapshots: HashMap, /// Latest account usage per profile id (Codex / Claude Code). @@ -23,6 +39,9 @@ impl ProviderCatalog { model_catalogs, models_loading: HashMap::new(), provider_versions: HashMap::new(), + version_rounds: HashMap::new(), + next_version_round: 0, + update_run: None, tcode_update: TcodeUpdateState::default(), provider_snapshots: HashMap::new(), provider_usage: HashMap::new(), @@ -50,26 +69,59 @@ impl ProviderCatalog { fn complete_version_check( &mut self, + round: u64, provider: ProviderKind, revision: u64, installation: Option, assessment: provider_updates::Assessment, - ) -> Option { + ) -> Option { let status = self.provider_versions.entry(provider).or_default(); - if status.revision != revision { - return None; - } - let already = status.update_available; - status.checking = false; - status.installation = installation; - status.installed = assessment.current; - status.latest = assessment.latest; - status.update_available = assessment.update_available; - if status.update_available && !already { - status.latest.clone() + let update = if status.revision == revision { + let already = status.update_available; + status.checking = false; + status.installation = installation; + status.installed = assessment.current; + status.latest = assessment.latest; + status.update_available = assessment.update_available; + if status.update_available + && !already + && let Some(version) = &status.latest + { + Some(ProviderUpdateAvailable { + provider, + version: version.clone(), + automatic: status + .installation + .as_ref() + .and_then(|installation| installation.update.as_ref()) + .is_some_and(|command| !command.requires_terminal), + }) + } else { + None + } } else { None + }; + if let Some(round_state) = self.version_rounds.get_mut(&round) { + if let Some(update) = update { + round_state.updates.push(update); + } + round_state.pending -= 1; + if round_state.pending == 0 { + let mut round = self.version_rounds.remove(&round).unwrap(); + round.updates.sort_by_key(|update| { + ProviderKind::NATIVE + .iter() + .position(|provider| *provider == update.provider) + }); + return (!round.updates.is_empty()).then_some( + RuntimeToast::ProviderUpdatesAvailable { + updates: round.updates, + }, + ); + } } + None } fn complete_usage( @@ -122,6 +174,7 @@ impl ProviderCatalog { ) }) .collect(), + update_run: self.update_run.as_ref().map(|run| run.status.clone()), tcode_update: TcodeUpdateStatus { current: self.tcode_update.current.clone(), latest: self.tcode_update.latest.clone(), @@ -530,8 +583,13 @@ impl AppState { } /// Check every provider and the running tcode build in the background, - /// storing results and toasting once for each newly available update. + /// emitting one merged toast for newly available CLI updates per round. pub fn check_provider_versions(&mut self, cx: &mut HostCx) { + let round = self.providers.next_version_round; + self.providers.next_version_round += 1; + self.providers + .version_rounds + .insert(round, VersionCheckRound::default()); for provider in ProviderKind::NATIVE { let binary = self.resolve_provider_binary(provider); let status = self @@ -543,6 +601,11 @@ impl AppState { continue; } status.checking = true; + self.providers + .version_rounds + .get_mut(&round) + .unwrap() + .pending += 1; let revision = status.revision; let program = binary .as_ref() @@ -580,24 +643,22 @@ impl AppState { latest_output: latest.as_deref(), }); host_cx.enqueue(move |state, cx| { - if let Some(version) = state.providers.complete_version_check( + if let Some(toast) = state.providers.complete_version_check( + round, provider, revision, installation, assessment, ) { - emit_runtime( - cx, - RuntimeEvent::Notice(RuntimeNotice::UpdateAvailable { - provider, - version, - }), - ); + emit_runtime(cx, RuntimeEvent::Toast(toast)); } }); }); } + if self.providers.version_rounds[&round].pending == 0 { + self.providers.version_rounds.remove(&round); + } if self.providers.tcode_update.checking { return; } @@ -633,8 +694,83 @@ impl AppState { }); } - /// Revalidate the displayed installation before executing its update plan. - pub fn update_provider(&mut self, provider: ProviderKind, cx: &mut HostCx) { + pub fn update_providers(&mut self, providers: Vec, cx: &mut HostCx) { + if self.providers.update_run.is_some() { + return; + } + let mut seen = HashSet::new(); + let pending: std::collections::VecDeque<_> = providers + .into_iter() + .filter(|provider| seen.insert(*provider)) + .collect(); + if pending.is_empty() { + return; + } + let operation = self.next_operation_id(); + let status = ProviderUpdateRun { + total: pending.len(), + completed: 0, + current: pending.front().copied(), + failed: Vec::new(), + }; + emit_runtime( + cx, + RuntimeEvent::Toast(RuntimeToast::ProviderUpdateStarted { + operation, + run: status.clone(), + }), + ); + self.providers.update_run = Some(UpdateRun { + operation, + status, + pending, + }); + self.start_next_provider_update(cx); + } + + fn complete_provider_update(&mut self, provider: ProviderKind, ok: bool, cx: &mut HostCx) { + if let Some(status) = self.providers.provider_versions.get_mut(&provider) { + status.updating = false; + if ok { + status.update_available = false; + } + } + let Some(run) = self.providers.update_run.as_mut() else { + return; + }; + run.status.completed += 1; + if !ok { + run.status.failed.push(provider); + } + self.start_next_provider_update(cx); + } + + fn start_next_provider_update(&mut self, cx: &mut HostCx) { + let Some(run) = self.providers.update_run.as_mut() else { + return; + }; + let Some(provider) = run.pending.pop_front() else { + let mut run = self.providers.update_run.take().unwrap(); + run.status.current = None; + emit_runtime( + cx, + RuntimeEvent::Toast(RuntimeToast::ProviderUpdateFinished { + operation: run.operation, + run: run.status, + }), + ); + self.refresh_provider_status(cx); + self.check_provider_versions(cx); + return; + }; + run.status.current = Some(provider); + emit_runtime( + cx, + RuntimeEvent::Toast(RuntimeToast::ProviderUpdateProgress { + operation: run.operation, + run: run.status.clone(), + }), + ); let installation = self .providers .provider_versions @@ -646,11 +782,11 @@ impl AppState { .as_ref() .is_some_and(|command| !command.requires_terminal) }) else { - self.report_error(RuntimeError::UpdateUnknown { provider }, cx); + self.complete_provider_update(provider, false, cx); return; }; let Some(binary) = self.resolve_provider_binary(provider) else { - self.report_error(RuntimeError::UpdateUnknown { provider }, cx); + self.complete_provider_update(provider, false, cx); return; }; let status = self @@ -658,17 +794,11 @@ impl AppState { .provider_versions .entry(provider) .or_default(); - if status.updating { - return; - } status.updating = true; status.checking = false; status.revision += 1; let revision = status.revision; - emit_runtime( - cx, - RuntimeEvent::Notice(RuntimeNotice::UpdatingProvider { provider }), - ); + let settings = self.settings.clone(); let settings_store = self.settings_store.clone(); let host_cx = cx.clone(); @@ -695,8 +825,7 @@ impl AppState { status.updating = false; status.installation = None; } - state.report_error(RuntimeError::UpdateUnknown { provider }, cx); - state.check_provider_versions(cx); + state.complete_provider_update(provider, false, cx); return; } let command = current.update.expect("validated automatic update plan"); @@ -704,19 +833,7 @@ impl AppState { HostCx::spawn_detached(cx, async move { let ok = command.run().await; host_cx.enqueue(move |state, cx| { - if let Some(status) = state.providers.provider_versions.get_mut(&provider) { - status.updating = false; - } - if ok { - emit_runtime( - cx, - RuntimeEvent::Notice(RuntimeNotice::UpdateDone { provider }), - ); - state.refresh_provider_status(cx); - } else { - state.report_error(RuntimeError::UpdateFailed { provider }, cx); - } - state.check_provider_versions(cx); + state.complete_provider_update(provider, ok, cx); }); }); }); @@ -997,6 +1114,67 @@ pub(super) fn session_options( mod provider_lifecycle_tests { use super::*; + #[test] + fn version_round_merges_new_updates_and_finishes_after_stale_completion() { + let mut catalog = ProviderCatalog::new(HashMap::new(), HashMap::new()); + catalog.version_rounds.insert( + 0, + VersionCheckRound { + pending: 3, + updates: Vec::new(), + }, + ); + let assessment = provider_updates::check(ProviderCheckInput { + installed_output: Some("1.0.0"), + latest_output: Some("2.0.0"), + }); + assert_eq!( + catalog.complete_version_check(0, ProviderKind::Codex, 0, None, assessment.clone()), + None + ); + assert_eq!( + catalog.complete_version_check( + 0, + ProviderKind::ClaudeCode, + 0, + None, + assessment.clone() + ), + None + ); + let toast = + catalog.complete_version_check(0, ProviderKind::Pi, 1, None, assessment.clone()); + assert_eq!( + toast, + Some(RuntimeToast::ProviderUpdatesAvailable { + updates: vec![ + ProviderUpdateAvailable { + provider: ProviderKind::ClaudeCode, + version: "2.0.0".into(), + automatic: false + }, + ProviderUpdateAvailable { + provider: ProviderKind::Codex, + version: "2.0.0".into(), + automatic: false + }, + ] + }) + ); + catalog.version_rounds.insert( + 1, + VersionCheckRound { + pending: 1, + updates: Vec::new(), + }, + ); + assert_eq!( + catalog.complete_version_check(1, ProviderKind::Codex, 0, None, assessment), + None, + "unchanged availability must not notify again" + ); + } + #[test] fn changing_binary_discards_old_update_results_without_finishing_the_new_check() { use crate::app::test_support::*; @@ -1034,6 +1212,20 @@ mod provider_lifecycle_tests { ); status.checking = true; + state.providers.version_rounds.insert( + 0, + VersionCheckRound { + pending: 1, + updates: Vec::new(), + }, + ); + state.providers.version_rounds.insert( + 1, + VersionCheckRound { + pending: 1, + updates: Vec::new(), + }, + ); let result = provider_updates::check(ProviderCheckInput { installed_output: Some("3.0.0"), latest_output: Some("3.0.1"), @@ -1041,7 +1233,7 @@ mod provider_lifecycle_tests { assert_eq!( state .providers - .complete_version_check(provider, previous, None, result.clone()), + .complete_version_check(0, provider, previous, None, result.clone()), None ); let status = &state.providers.provider_versions[&provider]; @@ -1049,12 +1241,9 @@ mod provider_lifecycle_tests { assert!(!status.update_available); assert!(status.latest.is_none()); let revision = status.revision; - assert_eq!( - state - .providers - .complete_version_check(provider, revision, None, result,), - Some("3.0.1".into()) - ); + state + .providers + .complete_version_check(1, provider, revision, None, result); let status = &state.providers.provider_versions[&provider]; assert!(!status.checking); assert_eq!(status.installed.as_deref(), Some("3.0.0")); @@ -1067,10 +1256,17 @@ mod provider_lifecycle_tests { installed_output: Some("3.0.0"), latest_output: Some("3.0.2"), }); + state.providers.version_rounds.insert( + 2, + VersionCheckRound { + pending: 1, + updates: Vec::new(), + }, + ); assert_eq!( state .providers - .complete_version_check(provider, revision, None, result), + .complete_version_check(2, provider, revision, None, result), None ); assert!(!state.providers.provider_versions[&provider].update_available); diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index f05681ff0..9ed8b402c 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -373,7 +373,7 @@ fn dispatch_command(app: &mut AppState, cx: &mut HostCx, command: Command) -> Co Command::RefreshProviderStatus => app.refresh_provider_status(cx), Command::RefreshProviderUsage => app.refresh_provider_usage(cx), Command::CheckProviderVersions => app.check_provider_versions(cx), - Command::UpdateProvider { provider } => app.update_provider(provider, cx), + Command::UpdateProviders { providers } => app.update_providers(providers, cx), Command::SetSidebarCollapsed { collapsed } => app.set_sidebar_collapsed(collapsed, cx), Command::RunGitAction { session_id, diff --git a/crates/ui/src/provider_card.rs b/crates/ui/src/provider_card.rs index f7f5f661b..99e20f048 100644 --- a/crates/ui/src/provider_card.rs +++ b/crates/ui/src/provider_card.rs @@ -375,7 +375,7 @@ impl ProviderCard { let store = store.clone(); move |_, _, cx| { store.update(cx, |store, _cx| { - store.update_provider(provider); + store.update_providers(vec![provider]); }); } }), diff --git a/crates/ui/src/runtime_event.rs b/crates/ui/src/runtime_event.rs index 5d0b43cfc..e8654a9f5 100644 --- a/crates/ui/src/runtime_event.rs +++ b/crates/ui/src/runtime_event.rs @@ -1,4 +1,4 @@ -use agent::RewindMode; +use agent::{ProviderKind, RewindMode}; use tcode_core::git::GitAction; use tcode_protocol::{ GitActionRequest, MergeWorktreeFailure, NoticeSeverity, PluginOperationTarget, RuntimeEffect, @@ -42,14 +42,6 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve RuntimeError::PersistSettings { error } => { crate::tr!("errors.persist_settings", error = error).into_owned() } - RuntimeError::UpdateUnknown { provider } => { - crate::tr!("errors.update_unknown", provider = provider.display_name()) - .into_owned() - } - RuntimeError::UpdateFailed { provider } => { - crate::tr!("errors.update_failed", provider = provider.display_name()) - .into_owned() - } RuntimeError::TerminalStart { error } => { crate::tr!("errors.terminal_start", error = error).into_owned() } @@ -112,24 +104,9 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve }; let message = match notice { RuntimeNotice::ProviderMessage(message) => message.clone(), - RuntimeNotice::UpdateAvailable { provider, version } => crate::tr!( - "notice.update_available", - provider = provider.display_name(), - version = version - ) - .into_owned(), RuntimeNotice::TcodeUpdateAvailable { version } => { crate::tr!("notice.tcode_update_available", version = version).into_owned() } - RuntimeNotice::UpdatingProvider { provider } => crate::tr!( - "notice.updating_provider", - provider = provider.display_name() - ) - .into_owned(), - RuntimeNotice::UpdateDone { provider } => { - crate::tr!("notice.update_done", provider = provider.display_name()) - .into_owned() - } RuntimeNotice::NativeRewindCompleted { mode } => match mode { RewindMode::Files => crate::tr!("chat.rewind_files_done").into_owned(), RewindMode::Conversation => { @@ -211,6 +188,7 @@ pub(super) fn present_runtime_event(event: &RuntimeEvent) -> PresentedRuntimeEve pub(super) enum RuntimeToastDisposition { Push, Start(RuntimeOperationId), + Update(RuntimeOperationId), Finish(RuntimeOperationId), } @@ -220,7 +198,14 @@ pub(super) struct PresentedRuntimeToast { pub kind: ToastKind, pub title: String, pub detail: Option, - pub retry: Option, + pub action: Option, + pub progress: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum RuntimeToastAction { + RetryGit(GitActionRequest), + UpdateProviders(Vec), } fn git_action_toast_titles(action: GitAction) -> (String, String) { @@ -307,7 +292,104 @@ fn plugin_operation_toast_titles(target: &PluginOperationTarget) -> [String; 3] } pub(super) fn present_runtime_toast(toast: &RuntimeToast) -> PresentedRuntimeToast { - let (disposition, kind, title, detail, retry) = match toast { + match toast { + RuntimeToast::ProviderUpdatesAvailable { updates } => { + let automatic: Vec<_> = updates + .iter() + .filter(|update| update.automatic) + .map(|update| update.provider) + .collect(); + let title = if let [update] = updates.as_slice() { + crate::tr!( + "providers.updates_available_one", + provider = update.provider.display_name(), + version = &update.version + ) + .into_owned() + } else { + crate::tr!("providers.updates_available_many", count = updates.len()).into_owned() + }; + return PresentedRuntimeToast { + disposition: RuntimeToastDisposition::Push, + kind: ToastKind::Info, + title, + detail: (updates.len() > 1).then(|| { + updates + .iter() + .map(|update| { + format!("{} v{}", update.provider.display_name(), update.version) + }) + .collect::>() + .join("\n") + }), + action: (!automatic.is_empty()) + .then_some(RuntimeToastAction::UpdateProviders(automatic)), + progress: None, + }; + } + RuntimeToast::ProviderUpdateStarted { operation, run } + | RuntimeToast::ProviderUpdateProgress { operation, run } + | RuntimeToast::ProviderUpdateFinished { operation, run } => { + let finished = matches!(toast, RuntimeToast::ProviderUpdateFinished { .. }); + let failed = run + .failed + .iter() + .map(|provider| provider.display_name()) + .collect::>() + .join(", "); + let title = if finished { + if run.failed.is_empty() { + crate::tr!("providers.updated_count", count = run.completed).into_owned() + } else { + crate::tr!( + "providers.update_result", + updated = run.completed - run.failed.len(), + failed = run.failed.len(), + providers = &failed + ) + .into_owned() + } + } else { + crate::tr!( + "providers.update_progress", + provider = run + .current + .map(|provider| provider.display_name()) + .unwrap_or_default(), + completed = run.completed, + total = run.total + ) + .into_owned() + }; + return PresentedRuntimeToast { + disposition: if finished { + RuntimeToastDisposition::Finish(*operation) + } else if matches!(toast, RuntimeToast::ProviderUpdateStarted { .. }) { + RuntimeToastDisposition::Start(*operation) + } else { + RuntimeToastDisposition::Update(*operation) + }, + kind: if !finished { + ToastKind::Loading + } else if run.failed.is_empty() { + ToastKind::Success + } else if run.failed.len() == run.total { + ToastKind::Error + } else { + ToastKind::Warning + }, + title, + detail: (!failed.is_empty()).then(|| { + crate::tr!("providers.update_failures", providers = failed).into_owned() + }), + action: None, + progress: (!finished) + .then_some(100. * run.completed as f32 / run.total.max(1) as f32), + }; + } + _ => {} + } + let (disposition, kind, title, detail, action) = match toast { RuntimeToast::GitBusy => ( RuntimeToastDisposition::Push, ToastKind::Warning, @@ -338,7 +420,7 @@ pub(super) fn present_runtime_toast(toast: &RuntimeToast) -> PresentedRuntimeToa ToastKind::Error, crate::tr!("git.toast.failed").into_owned(), Some(detail.clone()), - Some(retry.clone()), + Some(RuntimeToastAction::RetryGit(retry.clone())), ), RuntimeToast::CommitMessageGenerated { message } => ( RuntimeToastDisposition::Push, @@ -432,7 +514,8 @@ pub(super) fn present_runtime_toast(toast: &RuntimeToast) -> PresentedRuntimeToa kind, title, detail, - retry, + action, + progress: None, } } @@ -442,6 +525,52 @@ mod tests { use super::*; + #[test] + fn merged_provider_toast_offers_only_automatic_updates_in_both_locales() { + let _locale_guard = crate::settings::TestLocaleGuard::acquire(); + for language in [crate::LANGUAGE_ENGLISH, crate::LANGUAGE_SIMPLIFIED_CHINESE] { + crate::set_locale(language); + let mut updates = vec![ + tcode_protocol::ProviderUpdateAvailable { + provider: ProviderKind::ClaudeCode, + version: "2.1.0".into(), + automatic: false, + }, + tcode_protocol::ProviderUpdateAvailable { + provider: ProviderKind::Codex, + version: "1.2.0".into(), + automatic: false, + }, + ]; + let manual = present_runtime_toast(&RuntimeToast::ProviderUpdatesAvailable { + updates: updates.clone(), + }); + assert!(manual.action.is_none()); + assert_eq!( + manual.detail.as_deref(), + Some("Claude Code v2.1.0\nCodex v1.2.0") + ); + assert_eq!( + manual.title, + if language == crate::LANGUAGE_ENGLISH { + "Updates available for 2 providers" + } else { + "有 2 个 Provider 可更新" + } + ); + updates[1].automatic = true; + let automatic = + present_runtime_toast(&RuntimeToast::ProviderUpdatesAvailable { updates }); + assert_eq!( + automatic.action, + Some(RuntimeToastAction::UpdateProviders(vec![ + ProviderKind::Codex + ])) + ); + } + crate::set_locale(crate::LANGUAGE_ENGLISH); + } + #[test] fn locale_effect_is_applied_only_at_ui_boundary() { let _locale_guard = crate::settings::TestLocaleGuard::acquire(); @@ -626,7 +755,10 @@ mod tests { retry: retry.clone(), }); assert_eq!(failed.detail.as_deref(), Some("git raw\0detail")); - assert_eq!(failed.retry.as_ref(), Some(&retry)); + assert_eq!( + failed.action, + Some(RuntimeToastAction::RetryGit(retry.clone())) + ); assert_eq!( present_runtime_toast(&RuntimeToast::CommitMessageGenerated { message: "generated raw\0message".into(), @@ -688,8 +820,8 @@ mod tests { Some("provider-warning\0diagnostic"), ), ( - RuntimeEvent::Notice(RuntimeNotice::UpdateDone { - provider: ProviderKind::Codex, + RuntimeEvent::Notice(RuntimeNotice::TcodeUpdateAvailable { + version: "1.0.0".into(), }), RuntimeEventSeverity::Success, None, diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index 922a8327d..2ca67797e 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -1748,17 +1748,24 @@ impl SettingsPage { } let checked_at = self.store.read(cx).providers_checked_at(); let checking = self.store.read(cx).providers_checking(); + let run = self.store.read(cx).provider_update_run(); + let automatic = self.store.read(cx).automatic_provider_updates(); let muted = cx.theme().muted_foreground; - let mut header = gpui_base::h_flex().w_full().items_center().gap_2().child( - div() - .flex_1() - .pl_3() - .text_size(px(11.)) - .font_medium() - .text_color(muted) - .child(crate::tr!("settings.providers_section")), - ); + let mut header = gpui_base::h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .pl_3() + .text_size(px(11.)) + .font_medium() + .text_color(muted) + .child(crate::tr!("settings.providers_section")), + ); if let Some(checked_at) = checked_at { let ago = humanize_ago(now_secs().saturating_sub(checked_at)); header = header.child( @@ -1781,6 +1788,19 @@ impl SettingsPage { this.open_acp_dialog(window, cx); })), ); + if !automatic.is_empty() { + header = header.child( + Button::new("update-all-providers") + .outline() + .xsmall() + .loading(run.is_some()) + .label(crate::tr!("providers.update_all")) + .on_click(cx.listener(move |this, _, _, cx| { + this.store + .update(cx, |store, _cx| store.update_providers(automatic.clone())); + })), + ); + } header = header.child( Button::new("refresh-providers") .ghost() @@ -1802,11 +1822,34 @@ impl SettingsPage { .map(|(_, card)| card.clone().into_any_element()) .collect(); - let mut section = v_flex() - .w_full() - .gap_3() - .child(header) - .child(crate::material::grouped(provider_rows, cx)); + let mut section = v_flex().w_full().gap_3().child(header); + if let Some(run) = run { + section = section.child( + v_flex() + .w_full() + .gap_2() + .px_3() + .child( + div().text_size(px(12.)).text_color(muted).child( + crate::tr!( + "providers.update_progress", + provider = run + .current + .map(|provider| provider.display_name()) + .unwrap_or_default(), + completed = run.completed, + total = run.total + ) + .into_owned(), + ), + ) + .child( + crate::widgets::progress::Progress::new("provider-update-progress") + .value(100. * run.completed as f32 / run.total.max(1) as f32), + ), + ); + } + section = section.child(crate::material::grouped(provider_rows, cx)); for (_, card) in &self.acp_cards { section = section.child(card.clone()); } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 6b2592107..fc8c080c7 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -48,8 +48,8 @@ use crate::preview_panel::PreviewPanel; use crate::preview_panel::lifecycle::BrowserLifecycle; use crate::remote::{AttachmentTarget, RemotePanel}; use crate::runtime_event::{ - RuntimeEventSeverity, RuntimeToastDisposition, apply_runtime_effect, present_runtime_event, - present_runtime_toast, + RuntimeEventSeverity, RuntimeToastAction, RuntimeToastDisposition, apply_runtime_effect, + present_runtime_event, present_runtime_toast, }; use crate::settings_page::SettingsPage; use crate::sidebar::SessionsSidebar; @@ -1146,21 +1146,35 @@ impl AppShell { self.operation_toasts.insert(operation, toast_id); toast_id } + RuntimeToastDisposition::Update(operation) => { + let Some(id) = self.operation_toasts.get(&operation) else { + return; + }; + *id + } RuntimeToastDisposition::Finish(operation) => self .operation_toasts .remove(&operation) .unwrap_or_else(|| self.take_toast_id()), }; - let action = presented.retry.and_then(|request| { + let action = presented.action.and_then(|action| { let store = self.store()?; + let label = match &action { + RuntimeToastAction::RetryGit(_) => crate::tr!("git.toast.retry"), + RuntimeToastAction::UpdateProviders(_) => crate::tr!("providers.update_all"), + } + .into_owned() + .into(); Some(ToastAction { - label: crate::tr!("git.toast.retry").into_owned().into(), + label, handler: Rc::new(move |window, cx| { window.remove_notification1::(toast_id as usize, cx); - let request = request.clone(); - store.update(cx, |store, _cx| { - store.retry_git_action(request); + store.update(cx, |store, _cx| match action.clone() { + RuntimeToastAction::RetryGit(request) => store.retry_git_action(request), + RuntimeToastAction::UpdateProviders(providers) => { + store.update_providers(providers) + } }); }), }) @@ -1168,7 +1182,7 @@ impl AppShell { self.show_toast( toast_id, presented.kind, - (presented.title, presented.detail), + (presented.title, presented.detail, presented.progress), action, window, cx, @@ -1185,14 +1199,15 @@ impl AppShell { &mut self, id: ToastId, kind: ToastKind, - content: (String, Option), + content: (String, Option, Option), action: Option, window: &mut Window, cx: &mut Context, ) { - let (title, detail) = content; + let (title, detail, progress) = content; + let has_action = action.is_some(); window.push_notification( - crate::toast::notification(id, kind, title, detail.map(Into::into), action), + crate::toast::notification(id, kind, title, detail.map(Into::into), action, progress), cx, ); @@ -1201,7 +1216,7 @@ impl AppShell { ToastKind::Warning => Some(Duration::from_secs(6)), ToastKind::Error | ToastKind::Loading => None, }; - if let Some(delay) = delay { + if let Some(delay) = delay.filter(|_| !has_action) { cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(delay).await; _ = this.update_in(cx, |_, window, cx| { diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index cb9de4928..2356cec5f 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -706,8 +706,8 @@ impl WorkspaceStore { pub fn delete_profile(&mut self, profile_id: String) { self.dispatch(Command::DeleteProfile { profile_id }); } - pub fn update_provider(&mut self, provider: ProviderKind) { - self.dispatch(Command::UpdateProvider { provider }); + pub fn update_providers(&mut self, providers: Vec) { + self.dispatch(Command::UpdateProviders { providers }); } pub fn set_active_model( &mut self, diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 127baa784..6b924e3fb 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -2081,6 +2081,26 @@ impl WorkspaceStore { .cloned() } + pub fn provider_update_run(&self) -> Option { + self.providers_replica.update_run.clone() + } + + pub fn automatic_provider_updates(&self) -> Vec { + agent::ProviderKind::NATIVE + .into_iter() + .filter(|provider| { + self.providers_replica + .provider_versions + .get(provider) + .is_some_and(|status| { + status.update_available + && status.update_command.is_some() + && !status.update_requires_terminal + }) + }) + .collect() + } + pub fn tcode_update_status(&self) -> tcode_protocol::TcodeUpdateStatus { self.providers_replica.tcode_update.clone() } diff --git a/crates/ui/src/toast.rs b/crates/ui/src/toast.rs index 9daad827f..4c5b682ca 100644 --- a/crates/ui/src/toast.rs +++ b/crates/ui/src/toast.rs @@ -38,6 +38,7 @@ pub fn notification( title: impl Into, detail: Option, action: Option, + progress: Option, ) -> Notification { let title: SharedString = title.into(); let loading_title = (kind == ToastKind::Loading).then(|| title.clone()); @@ -56,7 +57,7 @@ pub fn notification( ToastKind::Loading => notification, }; - if loading_title.is_some() || detail.is_some() { + if loading_title.is_some() || detail.is_some() || progress.is_some() { notification = notification.content(move |_, window, cx| { let has_loading_title = loading_title.is_some(); let mut content = v_flex() @@ -71,6 +72,12 @@ pub fn notification( .child(div().text_size(px(14.)).child(title)), ) }); + if let Some(value) = progress { + content = content.child( + crate::widgets::progress::Progress::new(("toast-progress", id as usize)) + .value(value), + ); + } if let Some(detail) = detail.clone() { let expanded = window diff --git a/locales/en.yml b/locales/en.yml index a8f527158..06e1b5769 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -499,6 +499,13 @@ providers: update_unmanaged: "Update this installation with its original installer or package manager. An automatic update route could not be verified." update_terminal: "Run the command below in a terminal on the machine hosting this provider." update_command: "Update command" + update_all: "Update all" + updates_available_one: "Update available: %{provider} v%{version}" + updates_available_many: "Updates available for %{count} providers" + update_progress: "Updating %{provider}… %{completed}/%{total}" + updated_count: "Providers updated: %{count}" + update_result: "%{updated} updated, %{failed} failed: %{providers}" + update_failures: "Could not update %{providers}. Check the installation and update command, or update manually." update_now: "Update now" updating: "Updating" update_manual: "or, update manually using" @@ -1033,8 +1040,6 @@ errors: provider_closed: "Provider session closed unexpectedly." persist_event: "Failed to persist event: %{error}" export_thread: "Failed to export thread: %{error}" - update_failed: "Failed to update %{provider}" - update_unknown: "Don't know how to update %{provider} automatically — please update it manually." worktree_add: "Failed to create worktree: %{error}" worktree_remove: "Failed to remove worktree: %{error}" zed_cli_missing: "Zed CLI not found (tried `zed`, `zeditor`, `zed-cli`; install via Zed → Install CLI)" @@ -1051,10 +1056,7 @@ notice: worktree_merge_conflict: "The branches diverged and could not be merged cleanly; the merge was aborted—resolve them manually" worktree_merge_git_error: "Could not merge the worktree branch: %{error}" dirty_tree: "Working tree has uncommitted changes" - update_available: "Update Available: %{provider} v%{version}" tcode_update_available: "Tcode %{version} available" - updating_provider: "Updating %{provider}…" - update_done: "%{provider} updated" attach: unsupported_type: "Unsupported file type for '%{name}'. Please attach image files only." too_large: "'%{name}' exceeds the 10MB attachment limit." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 28de90379..f53e14bae 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -495,6 +495,13 @@ providers: update_unmanaged: "请使用原安装工具或包管理器更新当前安装,尚无法确认自动更新方式。" update_terminal: "请在运行此提供方的主机终端中执行以下命令。" update_command: "更新命令" + update_all: "全部更新" + updates_available_one: "有可用更新:%{provider} v%{version}" + updates_available_many: "有 %{count} 个 Provider 可更新" + update_progress: "正在更新 %{provider}… %{completed}/%{total}" + updated_count: "已更新 %{count} 个 Provider" + update_result: "已更新 %{updated} 个,%{failed} 个更新失败:%{providers}" + update_failures: "无法更新 %{providers}。请检查安装和更新命令,或手动更新。" update_now: "立即更新" updating: "更新中" update_manual: "或者手动更新:" @@ -1026,8 +1033,6 @@ errors: provider_closed: "提供商会话意外关闭。" persist_event: "保存事件失败:%{error}" export_thread: "导出对话失败:%{error}" - update_failed: "更新 %{provider} 失败" - update_unknown: "无法自动更新 %{provider},请手动更新。" worktree_add: "创建工作树失败:%{error}" worktree_remove: "移除工作树失败:%{error}" zed_cli_missing: "找不到 Zed CLI(已尝试 zed / zeditor / zed-cli;请通过 Zed → Install CLI 安装)" @@ -1044,10 +1049,7 @@ notice: worktree_merge_conflict: "分支已分叉且无法干净合并;合并已中止,请手动解决" worktree_merge_git_error: "无法合并工作树分支:%{error}" dirty_tree: "工作树中有未提交的更改" - update_available: "有可用更新:%{provider} v%{version}" tcode_update_available: "Tcode %{version} 可用" - updating_provider: "正在更新 %{provider}…" - update_done: "%{provider} 已更新" attach: unsupported_type: "'%{name}' 的文件类型不受支持。请仅附加图片文件。" too_large: "'%{name}' 超过了 10MB 的附件大小限制。"