diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 241e4fbaff..7ffd9f7275 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -169,9 +169,15 @@ async fn do_refresh_providers_with_policy( return Ok(()); } - events::emit_refresh_started(app); - let inputs = ProviderRefreshInputs::load(); + events::emit_refresh_started( + app, + inputs + .enabled_ids + .iter() + .map(|id| id.cli_name().to_string()) + .collect(), + ); let enabled_count = inputs.enabled_ids.len(); let handles = spawn_provider_refreshes(app, &inputs); diff --git a/apps/desktop-tauri/src-tauri/src/events.rs b/apps/desktop-tauri/src-tauri/src/events.rs index 13bde5f6b6..83660a4005 100644 --- a/apps/desktop-tauri/src-tauri/src/events.rs +++ b/apps/desktop-tauri/src-tauri/src/events.rs @@ -39,6 +39,12 @@ pub struct RefreshCompletePayload { pub error_count: usize, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RefreshStartedPayload { + pub provider_ids: Vec, +} + // ── Emit helpers ───────────────────────────────────────────────────── pub fn emit_surface_mode_changed( @@ -66,8 +72,8 @@ pub fn emit_provider_updated(app: &AppHandle, snapshot: &ProviderUsageSnapshot) let _ = app.emit(PROVIDER_UPDATED, snapshot); } -pub fn emit_refresh_started(app: &AppHandle) { - let _ = app.emit(REFRESH_STARTED, ()); +pub fn emit_refresh_started(app: &AppHandle, provider_ids: Vec) { + let _ = app.emit(REFRESH_STARTED, RefreshStartedPayload { provider_ids }); } pub fn emit_refresh_complete(app: &AppHandle, provider_count: usize, error_count: usize) { diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index a9d7ef1745..4cd7a5aa39 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -52,6 +52,7 @@ interface MenuCardProps { showResetWhenExhausted?: boolean; showAsUsed?: boolean; compactMetrics?: boolean; + isRefreshing?: boolean; onLayoutChange?: () => void; } @@ -419,6 +420,7 @@ export default function MenuCard({ showResetWhenExhausted = false, showAsUsed = false, compactMetrics = false, + isRefreshing = false, onLayoutChange, }: MenuCardProps) { const { t } = useLocale(); @@ -520,13 +522,14 @@ export default function MenuCard({ const cardClassName = [ "menu-card", provider.error ? "menu-card--error" : null, + isRefreshing ? "menu-card--refreshing" : null, hasDetails ? "menu-card--with-details" : "menu-card--header-only", ] .filter(Boolean) .join(" "); return ( -
+
diff --git a/apps/desktop-tauri/src/hooks/useProviders.test.tsx b/apps/desktop-tauri/src/hooks/useProviders.test.tsx index 81c1f9f336..058e7f0120 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.test.tsx +++ b/apps/desktop-tauri/src/hooks/useProviders.test.tsx @@ -369,12 +369,13 @@ describe("useProviders", () => { vi.useFakeTimers(); try { act(() => { - emitProviderEvent("refresh-started", {}); + emitProviderEvent("refresh-started", { providerIds: ["codex"] }); emitProviderEvent("provider-updated", provider("codex", 10)); emitProviderEvent("refresh-complete", { providerCount: 1, errorCount: 0, }); + }); expect(result.current.providers.map((snapshot) => snapshot.providerId)).toEqual([ @@ -389,4 +390,31 @@ describe("useProviders", () => { vi.useRealTimers(); } }); + + it("clears each provider from refresh state as it completes", async () => { + const { result } = renderHook(() => + useProviders({ refreshOnMount: false }), + ); + await waitFor(() => expect(result.current.hasLoadedCache).toBe(true)); + + act(() => + emitProviderEvent("refresh-started", { + providerIds: ["codex", "claude"], + }), + ); + expect([...result.current.refreshingProviderIds]).toEqual(["codex", "claude"]); + + act(() => emitProviderEvent("provider-updated", provider("codex", 10))); + expect([...result.current.refreshingProviderIds]).toEqual(["claude"]); + expect(result.current.isRefreshing).toBe(true); + + act(() => + emitProviderEvent("refresh-complete", { + providerCount: 2, + errorCount: 0, + }), + ); + expect(result.current.refreshingProviderIds.size).toBe(0); + expect(result.current.isRefreshing).toBe(false); + }); }); diff --git a/apps/desktop-tauri/src/hooks/useProviders.ts b/apps/desktop-tauri/src/hooks/useProviders.ts index c4a8c06d14..36d57e965a 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.ts +++ b/apps/desktop-tauri/src/hooks/useProviders.ts @@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event"; import type { ProviderUsageSnapshot, RefreshCompletePayload, + RefreshStartedPayload, } from "../types/bridge"; import { getCachedProviders, @@ -35,6 +36,7 @@ export interface UseProvidersResult { providers: ProviderUsageSnapshot[]; /** True while a refresh cycle is in progress. */ isRefreshing: boolean; + refreshingProviderIds: ReadonlySet; /** Trigger a manual refresh. No-op if already refreshing. */ refresh: () => void; /** Summary from the last completed refresh cycle, if any. */ @@ -57,7 +59,9 @@ export interface UseProvidersResult { */ export function useProviders(options: UseProvidersOptions = {}): UseProvidersResult { const [providers, setProviders] = useState([]); - const [isRefreshing, setIsRefreshing] = useState(false); + const [refreshingProviderIds, setRefreshingProviderIds] = useState>( + new Set(), + ); const [lastRefresh, setLastRefresh] = useState( null, ); @@ -106,10 +110,9 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes const refresh = useCallback(() => { if (refreshingRef.current) return; refreshingRef.current = true; - setIsRefreshing(true); refreshProviders().catch(() => { refreshingRef.current = false; - setIsRefreshing(false); + setRefreshingProviderIds(new Set()); }); }, []); @@ -138,7 +141,15 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes const unlistenUpdated = listen( "provider-updated", (event) => { - if (!cancelled) queueSnapshot(event.payload); + if (!cancelled) { + queueSnapshot(event.payload); + setRefreshingProviderIds( + (current) => + new Set( + [...current].filter((id) => id !== event.payload.providerId), + ), + ); + } }, ); @@ -164,10 +175,10 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes }); }); - const unlistenStarted = listen("refresh-started", () => { + const unlistenStarted = listen("refresh-started", (event) => { if (!cancelled) { refreshingRef.current = true; - setIsRefreshing(true); + setRefreshingProviderIds(new Set(event.payload.providerIds)); } }); @@ -177,7 +188,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes if (!cancelled) { if (!settingsReloadingRef.current) flushPendingSnapshots(); refreshingRef.current = false; - setIsRefreshing(false); + setRefreshingProviderIds(new Set()); setLastRefresh(event.payload); } }, @@ -192,7 +203,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes refreshPromise.catch(() => { if (!cancelled) { refreshingRef.current = false; - setIsRefreshing(false); + setRefreshingProviderIds(new Set()); } }); }; @@ -277,7 +288,8 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes return { providers, - isRefreshing, + isRefreshing: refreshingProviderIds.size > 0, + refreshingProviderIds, refresh, lastRefresh, hasCachedData: providers.length > 0, diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx index a3984446bb..9f46d42798 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx @@ -31,6 +31,7 @@ export default function PopOutPanel({ const { providers, isRefreshing, + refreshingProviderIds, refresh, hasCachedData, } = useProviders(); @@ -251,6 +252,7 @@ export default function PopOutPanel({ > Result, String> { + let options = CommandOptions { + timeout: Duration::from_secs(5), + initial_delay: Duration::ZERO, + extra_args: vec!["status".to_string(), "--json".to_string()], + ..CommandOptions::default() + }; + match CommandRunner::new().run_async("tailscale", None, &options).await { + Err(CommandError::BinaryNotFound(_)) => Ok(Vec::new()), + Err(_) => Err( + "Unable to query Tailscale peers; manual SSH hosts are still available.".to_string(), + ), + Ok(result) if result.exit_code == Some(0) && !result.timed_out => { + TailscaleStatusParser::hosts(&result.text).map_err(|_| { + "Tailscale returned an invalid status response; manual SSH hosts are still available." + .to_string() + }) + } + Ok(_) => Err( + "Tailscale status failed; manual SSH hosts are still available.".to_string(), + ), + } + } + async fn fetch_host(host: String, timeout: Duration) -> AgentSessionHostResult { let options = match Self::ssh_options(&host, timeout) { Ok(options) => options, @@ -858,6 +882,10 @@ impl RemoteSessionFetcher { sanitized } + pub fn merge_hosts(manual: &[String], automatic: &[String]) -> Vec { + Self::sanitized_hosts(&manual.iter().chain(automatic).cloned().collect::>()) + } + pub fn validate_host(host: &str) -> Result { let host = host.trim(); if host.is_empty() { @@ -910,7 +938,17 @@ impl AgentSessionDiscovery { let AgentSessionDiscoveryMode::Enabled { ssh_hosts } = mode else { return AgentSessionDiscoveryResult::Disabled; }; - let (local, remote) = tokio::join!(self.local.scan(), self.remote.fetch(&ssh_hosts)); + let (local, automatic) = + tokio::join!(self.local.scan(), RemoteSessionFetcher::tailscale_hosts()); + let (automatic_hosts, tailscale_error) = match automatic { + Ok(hosts) => (hosts, None), + Err(error) => (Vec::new(), Some(error)), + }; + let merged_hosts = RemoteSessionFetcher::merge_hosts(&ssh_hosts, &automatic_hosts); + let mut remote = self.remote.fetch(&merged_hosts).await; + if let Some(error) = tailscale_error { + remote.push(AgentSessionHostResult::failed("tailscale", error)); + } let mut hosts = Vec::with_capacity(remote.len() + 1); hosts.push(local); hosts.extend(remote); diff --git a/rust/src/agent_sessions/parsers.rs b/rust/src/agent_sessions/parsers.rs index b297b889a2..b425486326 100644 --- a/rust/src/agent_sessions/parsers.rs +++ b/rust/src/agent_sessions/parsers.rs @@ -1,5 +1,25 @@ use super::*; +impl TailscaleStatusParser { + pub fn hosts(json: &str) -> Result, String> { + let status: serde_json::Value = + serde_json::from_str(json).map_err(|_| "invalid Tailscale status JSON".to_string())?; + let mut hosts = status + .get("Peer") + .and_then(serde_json::Value::as_object) + .into_iter() + .flat_map(|peers| peers.values()) + .filter(|peer| peer.get("Online").and_then(serde_json::Value::as_bool) == Some(true)) + .filter_map(|peer| peer.get("DNSName").and_then(serde_json::Value::as_str)) + .map(|host| host.trim().trim_end_matches('.').to_string()) + .filter(|host| !host.is_empty()) + .filter(|host| RemoteSessionFetcher::validate_host(host).is_ok()) + .collect::>(); + hosts.sort_by_key(|host| host.to_ascii_lowercase()); + Ok(RemoteSessionFetcher::sanitized_hosts(&hosts)) + } +} + impl AgentPSOutputParser { pub fn parse(output: &str) -> Vec { let mut seen_pids = HashSet::new(); diff --git a/rust/src/agent_sessions/tests.rs b/rust/src/agent_sessions/tests.rs index 6418efd011..36fdb93723 100644 --- a/rust/src/agent_sessions/tests.rs +++ b/rust/src/agent_sessions/tests.rs @@ -101,6 +101,39 @@ bad line assert_eq!(hosts, vec!["good".to_string()]); } + #[test] + fn tailscale_parser_returns_online_peer_dns_names() { + let json = r#"{ + "Self": {"DNSName": "this-pc.tailnet.ts.net."}, + "Peer": { + "one": {"DNSName": "devbox.tailnet.ts.net.", "Online": true}, + "two": {"DNSName": "offline.tailnet.ts.net.", "Online": false}, + "three": {"DNSName": "", "Online": true} + } + }"#; + + assert_eq!( + TailscaleStatusParser::hosts(json).unwrap(), + vec!["devbox.tailnet.ts.net"] + ); + } + + #[test] + fn tailscale_parser_rejects_malformed_json() { + assert!(TailscaleStatusParser::hosts("{").is_err()); + } + + #[test] + fn automatic_and_manual_hosts_are_validated_and_deduplicated() { + assert_eq!( + RemoteSessionFetcher::merge_hosts( + &["manual".into(), "DEVBOX.tailnet.ts.net".into()], + &["devbox.tailnet.ts.net".into(), "-unsafe".into()], + ), + vec!["manual", "DEVBOX.tailnet.ts.net"] + ); + } + #[test] fn codex_rollout_parser_reads_first_line_metadata() { let metadata = CodexRolloutFirstLineParser::parse(