From 7b830ccfae76a4bb98d5ac7e1d83b7f61651c8dc Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 6 Oct 2026 16:56:54 +0800 Subject: [PATCH] feat(providers): merge update notices, Update all with progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider version checks now run as a round: the runtime emits one ProviderUpdatesAvailable toast listing every provider newly found with an update once the round's last check completes, instead of one notice per provider. The toast offers Update all for the entries whose verified update plan runs without a terminal. UpdateProvider becomes UpdateProviders: the runtime updates the requested providers sequentially under one run, publishes total/completed/current/ failed in ProvidersStatus, and drives one lifecycle toast (start, same-id progress replacement, finish) with a progress bar. Settings → Providers shows an Update all button beside refresh when automatic updates exist and a progress row while a run is active. Toasts carrying an action no longer auto-dismiss. Co-Authored-By: Claude Fable 5.1 --- crates/protocol/src/command.rs | 4 +- crates/protocol/src/event.rs | 48 +++-- crates/protocol/src/lib.rs | 13 +- crates/protocol/src/tests.rs | 57 ++++++ crates/runtime/src/app/providers.rs | 306 +++++++++++++++++++++++----- crates/runtime/src/pipe.rs | 2 +- crates/ui/src/provider_card.rs | 2 +- crates/ui/src/runtime_event.rs | 194 +++++++++++++++--- crates/ui/src/settings_page.rs | 71 +++++-- crates/ui/src/shell.rs | 39 ++-- crates/ui/src/store/intents.rs | 4 +- crates/ui/src/store/mod.rs | 20 ++ crates/ui/src/toast.rs | 9 +- locales/en.yml | 12 +- locales/zh-CN.yml | 12 +- 15 files changed, 643 insertions(+), 150 deletions(-) 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 的附件大小限制。"