Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop-tauri/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

// ── Emit helpers ─────────────────────────────────────────────────────

pub fn emit_surface_mode_changed(
Expand Down Expand Up @@ -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<String>) {
let _ = app.emit(REFRESH_STARTED, RefreshStartedPayload { provider_ids });
}

pub fn emit_refresh_complete(app: &AppHandle, provider_count: usize, error_count: usize) {
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop-tauri/src/components/MenuCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface MenuCardProps {
showResetWhenExhausted?: boolean;
showAsUsed?: boolean;
compactMetrics?: boolean;
isRefreshing?: boolean;
onLayoutChange?: () => void;
}

Expand Down Expand Up @@ -419,6 +420,7 @@ export default function MenuCard({
showResetWhenExhausted = false,
showAsUsed = false,
compactMetrics = false,
isRefreshing = false,
onLayoutChange,
}: MenuCardProps) {
const { t } = useLocale();
Expand Down Expand Up @@ -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 (
<article className={cardClassName}>
<article className={cardClassName} aria-busy={isRefreshing}>
<header className="menu-card__header">
<div className="menu-card__title-row">
<div className="menu-card__name-group">
Expand Down
30 changes: 29 additions & 1 deletion apps/desktop-tauri/src/hooks/useProviders.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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);
});
});
30 changes: 21 additions & 9 deletions apps/desktop-tauri/src/hooks/useProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event";
import type {
ProviderUsageSnapshot,
RefreshCompletePayload,
RefreshStartedPayload,
} from "../types/bridge";
import {
getCachedProviders,
Expand Down Expand Up @@ -35,6 +36,7 @@ export interface UseProvidersResult {
providers: ProviderUsageSnapshot[];
/** True while a refresh cycle is in progress. */
isRefreshing: boolean;
refreshingProviderIds: ReadonlySet<string>;
/** Trigger a manual refresh. No-op if already refreshing. */
refresh: () => void;
/** Summary from the last completed refresh cycle, if any. */
Expand All @@ -57,7 +59,9 @@ export interface UseProvidersResult {
*/
export function useProviders(options: UseProvidersOptions = {}): UseProvidersResult {
const [providers, setProviders] = useState<ProviderUsageSnapshot[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [refreshingProviderIds, setRefreshingProviderIds] = useState<Set<string>>(
new Set(),
);
const [lastRefresh, setLastRefresh] = useState<RefreshCompletePayload | null>(
null,
);
Expand Down Expand Up @@ -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());
});
}, []);

Expand Down Expand Up @@ -138,7 +141,15 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes
const unlistenUpdated = listen<ProviderUsageSnapshot>(
"provider-updated",
(event) => {
if (!cancelled) queueSnapshot(event.payload);
if (!cancelled) {
queueSnapshot(event.payload);
setRefreshingProviderIds(
(current) =>
new Set(
[...current].filter((id) => id !== event.payload.providerId),
),
);
}
},
);

Expand All @@ -164,10 +175,10 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes
});
});

const unlistenStarted = listen("refresh-started", () => {
const unlistenStarted = listen<RefreshStartedPayload>("refresh-started", (event) => {
if (!cancelled) {
refreshingRef.current = true;
setIsRefreshing(true);
setRefreshingProviderIds(new Set(event.payload.providerIds));
}
});

Expand All @@ -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);
}
},
Expand All @@ -192,7 +203,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes
refreshPromise.catch(() => {
if (!cancelled) {
refreshingRef.current = false;
setIsRefreshing(false);
setRefreshingProviderIds(new Set());
}
});
};
Expand Down Expand Up @@ -277,7 +288,8 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes

return {
providers,
isRefreshing,
isRefreshing: refreshingProviderIds.size > 0,
refreshingProviderIds,
refresh,
lastRefresh,
hasCachedData: providers.length > 0,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src/surfaces/PopOutPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default function PopOutPanel({
const {
providers,
isRefreshing,
refreshingProviderIds,
refresh,
hasCachedData,
} = useProviders();
Expand Down Expand Up @@ -251,6 +252,7 @@ export default function PopOutPanel({
>
<MenuCard
provider={p}
isRefreshing={refreshingProviderIds.has(p.providerId)}
hideEmail={settings.hidePersonalInfo}
resetTimeRelative={settings.resetTimeRelative}
showResetWhenExhausted={settings.showResetWhenExhausted}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src/surfaces/TrayPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export default function TrayPanel({ state }: { state: BootstrapState }) {
const {
providers,
isRefreshing,
refreshingProviderIds,
refresh,
hasCachedData,
hasLoadedCache,
Expand Down Expand Up @@ -432,6 +433,7 @@ export default function TrayPanel({ state }: { state: BootstrapState }) {
>
<MenuCard
provider={p}
isRefreshing={refreshingProviderIds.has(p.providerId)}
hideEmail={settings.hidePersonalInfo}
resetTimeRelative={settings.resetTimeRelative}
showResetWhenExhausted={settings.showResetWhenExhausted}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop-tauri/src/types/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,10 @@ export interface RefreshCompletePayload {
errorCount: number;
}

export interface RefreshStartedPayload {
providerIds: string[];
}

export interface SafeDiagnostics {
appVersion: string;
platform: string;
Expand Down
42 changes: 40 additions & 2 deletions rust/src/agent_sessions.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::host::{CommandOptions, CommandRunner};
use crate::host::{CommandError, CommandOptions, CommandRunner};
use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveDate, Utc};
use futures::future::join_all;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -744,6 +744,30 @@ impl RemoteSessionFetcher {
results
}

async fn tailscale_hosts() -> Result<Vec<String>, 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,
Expand Down Expand Up @@ -858,6 +882,10 @@ impl RemoteSessionFetcher {
sanitized
}

pub fn merge_hosts(manual: &[String], automatic: &[String]) -> Vec<String> {
Self::sanitized_hosts(&manual.iter().chain(automatic).cloned().collect::<Vec<_>>())
}

pub fn validate_host(host: &str) -> Result<String, String> {
let host = host.trim();
if host.is_empty() {
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions rust/src/agent_sessions/parsers.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
use super::*;

impl TailscaleStatusParser {
pub fn hosts(json: &str) -> Result<Vec<String>, 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::<Vec<_>>();
hosts.sort_by_key(|host| host.to_ascii_lowercase());
Ok(RemoteSessionFetcher::sanitized_hosts(&hosts))
}
}

impl AgentPSOutputParser {
pub fn parse(output: &str) -> Vec<AgentProcessRecord> {
let mut seen_pids = HashSet::new();
Expand Down
33 changes: 33 additions & 0 deletions rust/src/agent_sessions/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading