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
69 changes: 69 additions & 0 deletions apps/staged/src-tauri/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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);
}
}
3 changes: 3 additions & 0 deletions apps/staged/src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 22 additions & 21 deletions apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -141,6 +133,16 @@
}
</script>

{#snippet updateBadge(slot: 'main' | 'bridge', info: AgentVersionInfo | null)}
{#if info?.updateAvailable === true}
<span class="update-badge" class:info-only={!info.updateCommand}>
<ArrowUpCircle size={11} />
{slot === 'bridge' ? 'Bridge update' : 'Update'} available:
{info.installedVersion ?? '?'} → {info.latestVersion ?? '?'}
</span>
{/if}
{/snippet}

<div
class="check-row"
class:pass={check.status === 'pass'}
Expand All @@ -167,17 +169,16 @@
<span class="check-message">{check.message}</span>
{#if check.path}
<span class="check-path">{check.path}</span>
{@render updateBadge('main', check.main)}
{/if}
{#if check.bridgePath}
<span class="check-path">{check.bridgePath}</span>
{#if check.bridge?.bundled}
<span class="check-path">ACP bundled with Staged</span>
{:else}
<span class="check-path">{check.bridgePath}</span>
{/if}
{@render updateBadge('bridge', check.bridge)}
{/if}
{#each readouts as readout (readout.slot)}
<span class="update-badge" class:info-only={!readout.info.updateCommand}>
<ArrowUpCircle size={11} />
{readout.slot === 'bridge' ? 'Bridge update' : 'Update'} available:
{readout.info.installedVersion ?? '?'} → {readout.info.latestVersion ?? '?'}
</span>
{/each}
</div>

{#if showFreshnessSpinner}
Expand Down
6 changes: 6 additions & 0 deletions crates/doctor/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FixType>,
/// 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<bool>,
}

/// A single health-check result shown in the UI.
Expand Down