diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index db78435a5..af9a96ef9 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -85,9 +85,30 @@ async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool) if let Some(check) = node_runtime { report.checks.push(check); } + mark_bundled_bridges(&mut report, bundled_dir.as_deref()); report } +/// Stamp `bundled` on bridge readouts whose resolved path lives inside the +/// app's bundled ACP tools dir. The doctor crate resolves those bridges via +/// the PATH prefix `apply_bundled_tools_env` injects, so a prefix match +/// against the same dir identifies them; the UI then presents them as +/// bundled instead of showing an app-internal resource path. +fn mark_bundled_bridges(report: &mut DoctorReport, bundled_dir: Option<&Path>) { + let Some(dir) = bundled_dir else { return }; + for check in &mut report.checks { + let in_bundled_dir = check + .bridge_path + .as_deref() + .is_some_and(|p| Path::new(p).starts_with(dir)); + if in_bundled_dir { + if let Some(bridge) = check.bridge.as_mut() { + bridge.bundled = Some(true); + } + } + } +} + /// Run a fix for a doctor check, identified by check ID and fix type. /// /// The actual shell command is looked up from the static check definitions — @@ -632,4 +653,52 @@ mod tests { assert_eq!(parse_node_major("not-a-version"), None); assert_eq!(parse_node_major(""), None); } + + fn agent_check_with_bridge(id: &str, bridge_path: &str) -> DoctorCheck { + let mut check = + node_runtime_doctor_check(CheckStatus::Pass, "Installed".to_string(), None, None); + check.id = id.to_string(); + check.bridge_path = Some(bridge_path.to_string()); + check.bridge = Some(AgentVersionInfo::default()); + check + } + + #[test] + fn mark_bundled_bridges_stamps_only_bridges_inside_bundled_dir() { + let mut report = DoctorReport { + checks: vec![ + agent_check_with_bridge( + "ai-agent-claude", + "/bundle/resources/acp/bin/claude-agent-acp", + ), + agent_check_with_bridge("ai-agent-pi", "/Users/me/.npm-global/bin/pi-acp"), + ], + }; + + mark_bundled_bridges(&mut report, Some(Path::new("/bundle/resources/acp/bin"))); + + assert_eq!( + report.checks[0].bridge.as_ref().unwrap().bundled, + Some(true), + ); + assert_eq!( + report.checks[1].bridge.as_ref().unwrap().bundled, + None, + "a user-installed bridge outside the bundled dir must not be stamped", + ); + } + + #[test] + fn mark_bundled_bridges_without_bundled_dir_is_a_no_op() { + let mut report = DoctorReport { + checks: vec![agent_check_with_bridge( + "ai-agent-claude", + "/bundle/resources/acp/bin/claude-agent-acp", + )], + }; + + mark_bundled_bridges(&mut report, None); + + assert_eq!(report.checks[0].bridge.as_ref().unwrap().bundled, None); + } } diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 27b35a8a4..1d40f8ddd 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -1315,6 +1315,9 @@ export interface AgentVersionInfo { updateCommand: string | null; /** 'updateMain' or 'updateBridge', matching this readout's slot. */ updateFixType: 'updateMain' | 'updateBridge' | null; + /** True when this binary ships bundled with Staged (resolved from the app's + * bundled ACP tools dir rather than a user install). */ + bundled: boolean | null; } export interface DoctorCheck { diff --git a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte index 91ee102a2..ac0924fd5 100644 --- a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte +++ b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte @@ -49,19 +49,11 @@ check.status !== 'pass' ); - /** Readouts (main + bridge) that have any version info worth surfacing. */ - interface ReadoutEntry { - slot: 'main' | 'bridge'; - info: AgentVersionInfo; + /** Whether a readout surfaces an update badge (under its own path line). */ + function showsUpdateBadge(info: AgentVersionInfo | null): boolean { + return info?.updateAvailable === true; } - const readouts = $derived( - ( - [ - { slot: 'main', info: check.main }, - { slot: 'bridge', info: check.bridge }, - ] as { slot: 'main' | 'bridge'; info: AgentVersionInfo | null }[] - ).filter((r): r is ReadoutEntry => r.info?.updateAvailable === true) - ); + const anyUpdateBadge = $derived(showsUpdateBadge(check.main) || showsUpdateBadge(check.bridge)); /** Update commands that will run when the user confirms (actionable only). */ const updateCommands = $derived( @@ -81,7 +73,7 @@ // is noise — and skip rows that already surface a result (Update button or // badge) so the spinner and the result never display together. const showFreshnessSpinner = $derived( - doctorState.freshnessLoading && check.status !== 'fail' && !canUpdate && readouts.length === 0 + doctorState.freshnessLoading && check.status !== 'fail' && !canUpdate && !anyUpdateBadge ); let showUpdateDialog = $state(false); @@ -141,6 +133,16 @@ } +{#snippet updateBadge(slot: 'main' | 'bridge', info: AgentVersionInfo | null)} + {#if info?.updateAvailable === true} + + + {slot === 'bridge' ? 'Bridge update' : 'Update'} available: + {info.installedVersion ?? '?'} → {info.latestVersion ?? '?'} + + {/if} +{/snippet} +
{check.message} {#if check.path} {check.path} + {@render updateBadge('main', check.main)} {/if} {#if check.bridgePath} - {check.bridgePath} + {#if check.bridge?.bundled} + ACP bundled with Staged + {:else} + {check.bridgePath} + {/if} + {@render updateBadge('bridge', check.bridge)} {/if} - {#each readouts as readout (readout.slot)} - - - {readout.slot === 'bridge' ? 'Bridge update' : 'Update'} available: - {readout.info.installedVersion ?? '?'} → {readout.info.latestVersion ?? '?'} - - {/each}
{#if showFreshnessSpinner} diff --git a/crates/doctor/src/types.rs b/crates/doctor/src/types.rs index e5f947ab9..7c39aaec8 100644 --- a/crates/doctor/src/types.rs +++ b/crates/doctor/src/types.rs @@ -94,6 +94,12 @@ pub struct AgentVersionInfo { /// `FixType::UpdateMain` or `FixType::UpdateBridge` matching this readout's /// slot. Always paired with `update_command`: both `Some` or both `None`. pub update_fix_type: Option, + /// Whether this binary ships bundled with the embedding app (resolved from + /// the app's bundled tools dir rather than a user install). The crate never + /// populates this — it has no notion of an app bundle; the embedding app + /// stamps it after checks run so the UI can present the binary as bundled. + #[serde(default)] + pub bundled: Option, } /// A single health-check result shown in the UI.